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
@@ -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,