Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s

This commit is contained in:
2026-10-10 14:48:52 -04:00
parent bd34c3c9e7
commit 33da4482a6
24 changed files with 1264 additions and 370 deletions
+6 -1
View File
@@ -5,7 +5,7 @@ import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
import { ensureRebalancingSchema } from '../rebalancing/schema.js';
import { migrateFileBlobs } from './file-blobs.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import { installReportRevisions } from '../reports/revisions.js';
import { installMetricsPolicyRevisions, installReportRevisions } from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrateHostSchema } from '../hosts/schema.js';
import { migrateAuthSchema } from '../auth/schema.js';
@@ -65,6 +65,11 @@ export const migrations: readonly Migration[] = [
name: 'incremental vacuum page map',
mode: 'autocommit',
run: enableIncrementalVacuum
},
{
version: 13,
name: 'metrics policy report revisions',
run: installMetricsPolicyRevisions
}
];
+122 -22
View File
@@ -1,6 +1,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 {
parseMetricsQuery,
type HeatmapCell,
@@ -10,6 +11,7 @@ import {
type MetricsDays,
type MetricsQuery,
type PreviousMetrics,
type RunningMetrics,
type ServerMetrics,
type Utilization
} from '../../metrics.js';
@@ -41,6 +43,18 @@ function emptyUtilization(): Utilization {
return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
}
function emptyRunning(): RunningMetrics {
return { average: null, peak: 0, capturedHours: 0 };
}
function addRunning(total: RunningMetrics, count: number, hours: number): void {
if (hours <= 0) return;
const lobbyHours = (total.average ?? 0) * total.capturedHours + count * hours;
total.capturedHours += hours;
total.average = lobbyHours / total.capturedHours;
total.peak = Math.max(total.peak, count);
}
function addObservation(total: Utilization, players: number | null, hours: number): void {
if (players === null) total.unknown += hours;
else {
@@ -71,6 +85,72 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
const since = now - days * 86400_000;
const bucketMs = days === 1 ? 3600_000 : 86400_000;
const handle = db();
// Current settings are context for historical counts, never a reconstruction
// of earlier policy revisions. Avoid loading autoscaling's mutation services.
const globalPaused =
(
handle.prepare("SELECT value FROM settings WHERE key='autoscaling_paused'").get() as
{ value: string } | undefined
)?.value === '1';
const policies = (
handle
.prepare(
`SELECT id,game,map_slug,mode,settings,paused_reason,owns_map
FROM autoscaling_policies ORDER BY owns_map DESC,map_slug,id`
)
.all() as {
id: string;
game: string;
map_slug: string;
mode: NonNullable<MapMetrics['policy']>['mode'];
settings: string;
paused_reason: string | null;
owns_map: number;
}[]
).map((row) => {
const { minimum, maximum, spares } = JSON.parse(row.settings) as {
minimum: number;
maximum: number;
spares: number;
};
return {
game: row.game,
mapSlug: row.map_slug,
ownsMap: row.owns_map === 1,
context: {
id: row.id,
mode: row.mode,
minimum,
maximum,
spares,
pausedReason: globalPaused
? ['Autoscaling is globally paused.', row.paused_reason]
.filter(Boolean)
.join(' ')
: row.paused_reason
}
};
});
const policyFor = (game: string, label: string): MapMetrics['policy'] => {
const normalized = label.toLowerCase();
const catalog = zombieMaps(game);
const named = catalog.find(
(entry) => entry.friendly.toLowerCase() === normalized || entry.slug === normalized
);
// T6 variants share canonical names; do not choose a variant arbitrarily.
const canonical = catalog.filter((entry) => entry.canonical === normalized);
const slug = named?.slug ?? (canonical.length === 1 ? canonical[0].slug : null);
if (!slug) return null;
const applicable = policies.filter(
(policy) => policy.game === game && (policy.mapSlug === slug || policy.mapSlug === '*')
);
return (
applicable.find((policy) => policy.ownsMap)?.context ??
applicable.find((policy) => policy.mapSlug === slug)?.context ??
applicable[0]?.context ??
null
);
};
// These small grouped results keep options and retained evidence independent
// of the chosen scope/window without transferring a second raw history scan.
const groups = handle
@@ -112,10 +192,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
maps: groups.map(({ game, map }) => ({ game, map }))
};
const summary = emptyUtilization();
const running = emptyRunning();
const previous: PreviousMetrics = {
since: since - days * 86400_000,
until: since,
summary: emptyUtilization(),
running: emptyRunning(),
firstSampleAt: null,
lastSampleAt: null,
partialHistory: false
@@ -131,6 +213,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
startAt: start,
endAt: end,
partial: start !== at || end !== at + bucketMs,
capturedHours: 0,
...emptyUtilization()
});
}
@@ -211,6 +294,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
if (priorHours > 0) {
previous.firstSampleAt ??= at;
previous.lastSampleAt = at;
addRunning(previous.running, samples.length, priorHours);
for (const sample of samples)
addObservation(previous.summary, sample.players, priorHours);
}
@@ -219,7 +303,11 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
if (at < since - ONLINE_MAX_MS) return;
const start = Math.max(at, since);
const hours = Math.max(0, end - start) / 3600_000;
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
addRunning(running, samples.length, hours);
const availability = new Map<
MapMetrics,
{ known: boolean; empty: boolean; lobbies: number }
>();
const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 };
// Split once for the entire batch; observations crossing UTC hour/day
// boundaries contribute to both buckets without shifting their duration.
@@ -228,7 +316,9 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
const index = Math.floor((cursor - points[0].at) / bucketMs);
const point = points[index];
const stop = Math.min(end, point.endAt);
slices.push({ point, hours: (stop - cursor) / 3600_000 });
const sliceHours = (stop - cursor) / 3600_000;
point.capturedHours += sliceHours;
slices.push({ point, hours: sliceHours });
cursor = stop;
}
for (const sample of samples) {
@@ -251,8 +341,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
map = {
game: sample.game,
map: sample.map,
full: 0,
capacityObserved: 0,
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: policyFor(sample.game, sample.map),
...emptyUtilization()
};
game.set(sample.map, map);
@@ -268,20 +362,24 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
if (sample.players > 0) counts.active++;
else if (sample.players === 0) counts.idle++;
}
const known =
sample.players !== null && sample.capacity !== null && sample.capacity > 0;
const full = known && sample.players! >= sample.capacity!;
const previous = pressure.get(map);
pressure.set(map, {
known: known && (previous?.known ?? true),
full: full && (previous?.full ?? true)
const prior = availability.get(map);
availability.set(map, {
known: sample.players !== null || (prior?.known ?? false),
empty: sample.players === 0 || (prior?.empty ?? false),
lobbies: (prior?.lobbies ?? 0) + 1
});
}
for (const [map, state] of pressure) {
for (const [map, state] of availability) {
if (state.known) {
map.capacityObserved += hours;
if (state.full) map.full += hours;
}
map.availabilityObserved += hours;
if (!state.empty) {
map.noEmpty += hours;
if (map.policy) {
if (state.lobbies >= map.policy.maximum) map.noEmptyAtMaximum += hours;
else map.noEmptyBelowMaximum += hours;
}
}
} else map.availabilityUnknown += hours;
}
addHeatmap(counts, start, end);
}
@@ -328,13 +426,14 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
previous.firstSampleAt === null;
for (let index = 0; index < cells.length; index++)
cells[index].distinctDates = cellDates[index].size;
const fullMapHours = maps.reduce((total, map) => total + map.full, 0);
const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0);
const pooledPressure = {
value: knownMapHours > 0 ? (100 * fullMapHours) / knownMapHours : null,
fullMapHours,
const noEmptyMapHours = maps.reduce((total, map) => total + map.noEmpty, 0);
const knownMapHours = maps.reduce((total, map) => total + map.availabilityObserved, 0);
const pooledAvailability = {
value: knownMapHours > 0 ? (100 * noEmptyMapHours) / knownMapHours : null,
noEmptyMapHours,
knownMapHours,
coveredMaps: maps.filter((map) => map.capacityObserved > 0).length,
unknownMapHours: maps.reduce((total, map) => total + map.availabilityUnknown, 0),
coveredMaps: maps.filter((map) => map.availabilityObserved > 0).length,
totalMaps: maps.length
};
return {
@@ -345,11 +444,12 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
scope,
scopeOptions,
summary,
running,
servers,
maps,
points,
previous,
heatmap: { timeZone: scope.timeZone, cells },
pooledPressure
pooledAvailability
};
}
+16
View File
@@ -1,5 +1,21 @@
import type Database from 'better-sqlite3';
/** Current policy settings are inputs to historical metrics advice. */
export function installMetricsPolicyRevisions(handle: Database.Database) {
for (const table of ['autoscaling_policies', 'settings'])
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const versions =
action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
const condition =
table === 'settings'
? `WHEN ${versions.map((row) => `${row}.key='autoscaling_paused'`).join(' OR ')}`
: '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_${table}_${action}
AFTER ${action} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
}
export function installReportRevisions(handle: Database.Database) {
handle.exec(`CREATE TABLE IF NOT EXISTS report_revisions (topic TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0);
INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);