Add autoscaling outcomes to Metrics (Refs #140)

This commit is contained in:
2026-10-10 17:51:31 -04:00
parent 0bfda267ec
commit 528ed7f37a
15 changed files with 612 additions and 6 deletions
+13
View File
@@ -131,6 +131,18 @@ export interface PooledAvailability {
totalMaps: number;
}
export interface AutoscalingMetrics {
/** Retained Provisioning outcomes whose finish time is within the selected window. */
added: number;
removed: number;
failed: number;
cancelled: number;
/** Successful creates with valid request/finish timestamps; includes queue time. */
readinessSamples: number;
readinessMeanSeconds: number | null;
readinessMaxSeconds: number | null;
}
export interface MetricsData {
days: MetricsDays;
since: number;
@@ -150,6 +162,7 @@ export interface MetricsData {
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
sampledServerCount: number;
matches: MatchMetrics;
autoscaling: AutoscalingMetrics;
maps: MapMetrics[];
points: MetricPoint[];
previous: PreviousMetrics;
+6
View File
@@ -8,6 +8,7 @@ import { installPerformanceIndexes } from './performance-indexes.js';
import {
installMetricsMatchRevisions,
installMetricsPolicyRevisions,
installMetricsProvisioningRevisions,
installReportRevisions
} from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
@@ -79,6 +80,11 @@ export const migrations: readonly Migration[] = [
version: 14,
name: 'metrics match report revisions',
run: installMetricsMatchRevisions
},
{
version: 15,
name: 'metrics provisioning report revisions',
run: installMetricsProvisioningRevisions
}
];
@@ -0,0 +1,89 @@
import { db } from '../db/index.js';
import { parseServerName, zombieMaps } from '../../map-aliases.js';
import type { AutoscalingMetrics, MetricsScope } from '../../metrics.js';
/** Provisioning sources survive policy/decision deletion and server removal. */
export function readAutoscalingMetrics(scope: MetricsScope, since: number, now: number) {
// Group in SQLite to avoid transferring every operation. IN deduplicates source
// links, so one operation always counts once. Keep older groups for scope options.
const groups = db()
.prepare(
`WITH operations AS (
SELECT server, action, state, finished_at >= ? AS in_window,
ROUND((julianday(finished_at)-julianday(started_at))*86400000)/1000.0 AS seconds
FROM server_deployments
WHERE id IN (SELECT operation_id FROM provisioning_sources WHERE kind='autoscaling')
AND finished_at <= ? AND state IN ('done','warning','failed','cancelled')
), measured AS (
SELECT *, CASE WHEN in_window AND action='create'
AND state IN ('done','warning') AND seconds >= 0 THEN seconds END AS readiness
FROM operations
)
SELECT server,
SUM(in_window AND action='create' AND state IN ('done','warning')) AS added,
SUM(in_window AND action='destroy' AND state IN ('done','warning')) AS removed,
SUM(in_window AND state='failed') AS failed,
SUM(in_window AND state='cancelled') AS cancelled,
COUNT(readiness) AS readinessSamples,
COALESCE(SUM(readiness),0) AS readinessSeconds,
MAX(readiness) AS readinessMaxSeconds
FROM measured GROUP BY server`
)
.all(new Date(since).toISOString(), new Date(now).toISOString()) as (Omit<
AutoscalingMetrics,
'readinessMeanSeconds'
> & { server: string; readinessSeconds: number })[];
const summary: AutoscalingMetrics = {
added: 0,
removed: 0,
failed: 0,
cancelled: 0,
readinessSamples: 0,
readinessMeanSeconds: null,
readinessMaxSeconds: null
};
const maps = new Map<string, { game: string; map: string }>();
let readinessSeconds = 0;
for (const group of groups) {
// Autoscaling names encode the selected variant. Never use today's server
// registration or the policy scope (which may cover every map in a game).
const parsed = parseServerName(group.server);
const catalog = zombieMaps(parsed?.game ?? null);
const entry = catalog.find(
(map) =>
map.slug === parsed?.mapSlug ||
map.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === parsed?.mapSlug
);
if (parsed && entry) {
const pair = { game: parsed.game, map: entry.friendly };
maps.set(JSON.stringify(pair), pair);
}
if (scope.game !== null && parsed?.game !== scope.game) continue;
if (scope.map !== null) {
const label = scope.map.toLowerCase();
// A canonical T6 engine map can describe several variants; do not
// silently combine Town, Farm, Bus Depot and TranZit for that label.
const matches = catalog.filter(
(map) =>
map.friendly.toLowerCase() === label ||
map.slug === label ||
map.canonical === label
);
if (!entry || matches.length !== 1 || matches[0].slug !== entry.slug) continue;
}
summary.added += group.added;
summary.removed += group.removed;
summary.failed += group.failed;
summary.cancelled += group.cancelled;
summary.readinessSamples += group.readinessSamples;
readinessSeconds += group.readinessSeconds;
if (group.readinessMaxSeconds !== null)
summary.readinessMaxSeconds = Math.max(
summary.readinessMaxSeconds ?? 0,
group.readinessMaxSeconds
);
}
if (summary.readinessSamples > 0)
summary.readinessMeanSeconds = readinessSeconds / summary.readinessSamples;
return { summary, maps: [...maps.values()] };
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { zombieMaps } from '../../map-aliases.js';
import { readAutoscalingMetrics } from './metrics-autoscaling.js';
import {
parseMetricsQuery,
type HeatmapCell,
@@ -207,10 +208,11 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
`SELECT DISTINCT game, map_name AS map FROM leaderboard_matches WHERE started_at <= ?`
)
.all(new Date(now).toISOString()) as { game: string; map: string }[];
const autoscaling = readAutoscalingMetrics(scope, since, now);
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const availableMaps = [
...new Map(
[...groups, ...matchMaps].map(({ game, map }) => [
[...groups, ...matchMaps, ...autoscaling.maps].map(({ game, map }) => [
JSON.stringify([game, map]),
{ game, map }
])
@@ -514,6 +516,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
summary,
running,
matches: matchMetrics(totalMatches),
autoscaling: autoscaling.summary,
sampledServerCount: sampledServers.size,
maps,
points,
+21
View File
@@ -1,5 +1,26 @@
import type Database from 'better-sqlite3';
/** Provisioning outcomes remain report inputs even after the policy is removed. */
export function installMetricsProvisioningRevisions(handle: Database.Database) {
for (const table of ['server_deployments', 'provisioning_sources'])
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const versions =
action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
const fields = ['server', 'action', 'state', 'started_at', 'finished_at'];
const condition =
table === 'provisioning_sources'
? versions.map((row) => `${row}.kind='autoscaling'`).join(' OR ')
: `EXISTS (SELECT 1 FROM provisioning_sources WHERE operation_id=${action === 'DELETE' ? 'OLD' : 'NEW'}.id AND kind='autoscaling')${
action === 'UPDATE'
? ` AND (${fields.map((field) => `OLD.${field} IS NOT NEW.${field}`).join(' OR ')})`
: ''
}`;
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_${table}_${action}
AFTER ${action} ON ${table} WHEN ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
}
/** Match demand includes active and interrupted matches, independent of leaderboard eligibility. */
export function installMetricsMatchRevisions(handle: Database.Database) {
for (const table of ['leaderboard_matches', 'leaderboard_match_players'])
@@ -0,0 +1,70 @@
<script lang="ts">
import type { AutoscalingMetrics } from '$lib/metrics.js';
import { metricNumber } from '$ui/metrics.js';
import { countLabel } from '$ui/plural.js';
import Card from '$ui/components/primitives/Card.svelte';
import Stat from '$ui/components/primitives/Stat.svelte';
let { metrics }: { metrics: AutoscalingMetrics } = $props();
const outcomes = $derived(metrics.added + metrics.removed + metrics.failed + metrics.cancelled);
const seconds = (value: number | null) => (value === null ? '—' : `${metricNumber(value)} s`);
</script>
<Card as="section" ariaLabelledby="metrics-autoscaling-title">
<header>
<h2 id="metrics-autoscaling-title">Autoscaling</h2>
<a href="/autoscaling">Open policies</a>
</header>
<div class="stats">
<Stat label="Lobbies added" value={metrics.added} tween={false} />
<Stat label="Lobbies removed" value={metrics.removed} tween={false} />
<Stat label="Failed operations" value={metrics.failed} tween={false} />
<Stat label="Cancelled operations" value={metrics.cancelled} tween={false} />
<Stat label="Request to ready · average" value={seconds(metrics.readinessMeanSeconds)} />
<Stat label="Request to ready · maximum" value={seconds(metrics.readinessMaxSeconds)} />
</div>
<p>
{#if outcomes === 0}
No completed, failed or cancelled autoscaling operations recorded in this period and
scope.
{:else}
Autoscaling operations finished in this period and scope. Added and removed counts
include successful operations only.
{/if}
{#if metrics.readinessSamples > 0}
Readiness uses {countLabel(metrics.readinessSamples, 'completed create')} with recorded timing,
including queue time.
{:else}
Request-to-ready time is unavailable without completed creates with recorded timing.
{/if}
</p>
</Card>
<style>
header {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
a {
font-size: var(--text-sm);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-5);
}
p {
margin: var(--space-4) 0 0;
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: 1.6;
}
</style>
+10 -1
View File
@@ -24,6 +24,14 @@ const matches = (value: unknown) =>
) &&
(value.averageGroupSize === null || nonnegative(value.averageGroupSize)) &&
(value.soloShare === null || (nonnegative(value.soloShare) && value.soloShare <= 100));
const autoscaling = (value: unknown) =>
record(value) &&
['added', 'removed', 'failed', 'cancelled', 'readinessSamples'].every(
(key) => nonnegative(value[key]) && Number.isInteger(value[key])
) &&
['readinessMeanSeconds', 'readinessMaxSeconds'].every(
(key) => value[key] === null || nonnegative(value[key])
);
const policy = (value: unknown) =>
value === null ||
(record(value) &&
@@ -40,7 +48,8 @@ function validReport(value: unknown): value is MetricsData {
!numeric(value.since) ||
!totals(value.summary) ||
!running(value.running) ||
!matches(value.matches)
!matches(value.matches) ||
!autoscaling(value.autoscaling)
)
return false;
if (