Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s
CI / checks (push) Successful in 4m49s
This commit is contained in:
+122
-22
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user