Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s
CI / checks (push) Successful in 4m49s
This commit is contained in:
+241
-24
@@ -31,18 +31,59 @@ describe('historical metrics', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('only counts map saturation when every lobby has known capacity and is full', async () => {
|
||||
it('counts no-empty map time from players even without capacity or with unknown lobbies', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 2, 12);
|
||||
const sample = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
|
||||
recordMetrics([sample, { ...sample, server: 'two' }], at);
|
||||
recordMetrics([sample, { ...sample, server: 'two', players: 2 }], at + 30_000);
|
||||
recordMetrics([sample, { ...sample, server: 'two', capacity: null }], at + 60_000);
|
||||
recordMetrics(
|
||||
[
|
||||
{ ...sample, players: 1 },
|
||||
{ ...sample, server: 'two', players: 2 }
|
||||
],
|
||||
at + 30_000
|
||||
);
|
||||
recordMetrics(
|
||||
[
|
||||
{ ...sample, capacity: null },
|
||||
{ ...sample, server: 'two', capacity: null }
|
||||
],
|
||||
at + 60_000
|
||||
);
|
||||
recordMetrics([sample, { ...sample, server: 'two', players: null }], at + 90_000);
|
||||
const result = readMetrics(1, at + 120_000);
|
||||
expect(result.maps[0].full * 3600).toBeCloseTo(30);
|
||||
expect(result.maps[0].capacityObserved * 3600).toBeCloseTo(60);
|
||||
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(120);
|
||||
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
|
||||
expect(result.maps[0].availabilityUnknown).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an empty spare as available and all-unknown map observations as unknown', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 2, 12);
|
||||
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
|
||||
recordMetrics([sample, { ...sample, server: 'two', players: 0 }], at);
|
||||
recordMetrics(
|
||||
[
|
||||
{ ...sample, players: null },
|
||||
{ ...sample, server: 'two', players: 0 }
|
||||
],
|
||||
at + 30_000
|
||||
);
|
||||
recordMetrics(
|
||||
[
|
||||
{ ...sample, players: null },
|
||||
{ ...sample, server: 'two', players: null }
|
||||
],
|
||||
at + 60_000
|
||||
);
|
||||
const result = readMetrics(1, at + 90_000);
|
||||
expect(result.maps[0].noEmpty).toBe(0);
|
||||
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(60);
|
||||
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
|
||||
expect(result.pooledAvailability.value).toBe(0);
|
||||
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('clips the reporting window and splits observations across UTC chart boundaries', async () => {
|
||||
@@ -127,7 +168,10 @@ describe('historical metrics', () => {
|
||||
const later = readMetrics(1, at + 120_000);
|
||||
expect(later.summary.observed).toBe(0);
|
||||
expect(later.summary.unknown * 3600).toBeCloseTo(90);
|
||||
expect(later.maps[0].capacityObserved).toBe(0);
|
||||
expect(later.maps[0].availabilityObserved).toBe(0);
|
||||
expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
|
||||
expect(later.pooledAvailability.value).toBeNull();
|
||||
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
|
||||
});
|
||||
|
||||
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
|
||||
@@ -149,7 +193,7 @@ describe('historical metrics', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses complete collection boundaries across fleet changes and keeps map pressure in wall time', async () => {
|
||||
it('uses complete collection boundaries across fleet changes and keeps map availability in wall time', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 2, 23, 59, 40);
|
||||
@@ -169,9 +213,11 @@ describe('historical metrics', () => {
|
||||
).toBeCloseTo(30);
|
||||
const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!;
|
||||
expect(town.observed * 3600).toBeCloseTo(60);
|
||||
expect(town.capacityObserved * 3600).toBeCloseTo(30);
|
||||
expect(town.full * 3600).toBeCloseTo(30);
|
||||
expect(result.maps.find((map) => map.game === 't5')!.capacityObserved).toBe(0);
|
||||
expect(town.availabilityObserved * 3600).toBeCloseTo(30);
|
||||
expect(town.noEmpty * 3600).toBeCloseTo(30);
|
||||
expect(
|
||||
result.maps.find((map) => map.game === 't5')!.availabilityObserved * 3600
|
||||
).toBeCloseTo(30);
|
||||
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
|
||||
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
|
||||
result.summary[field]
|
||||
@@ -290,7 +336,12 @@ describe('historical metrics', () => {
|
||||
});
|
||||
expect(result.scopeFirstSampleAt).toBe(since - 30_000);
|
||||
expect(result.scopeLatestSampleAt).toBe(now - 30_000);
|
||||
expect(result.pooledPressure.value).toBeCloseTo(100 / 3);
|
||||
expect(result.pooledAvailability.value).toBeCloseTo(100 / 3);
|
||||
expect(result.running.average).toBe(1);
|
||||
expect(result.running.peak).toBe(1);
|
||||
expect(result.running.capturedHours * 3600).toBeCloseTo(45);
|
||||
expect(result.previous.running.average).toBe(1);
|
||||
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(30);
|
||||
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
|
||||
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
|
||||
result.summary[field]
|
||||
@@ -301,6 +352,9 @@ describe('historical metrics', () => {
|
||||
}
|
||||
const gameOnly = readMetrics({ ...query, map: null }, now);
|
||||
expect(gameOnly.maps.map((map) => map.map).sort()).toEqual(['Moon', 'Town']);
|
||||
expect(gameOnly.running.average).toBeCloseTo(4 / 3);
|
||||
expect(gameOnly.running.peak).toBe(2);
|
||||
expect(gameOnly.running.capturedHours * 3600).toBeCloseTo(45);
|
||||
const missing = readMetrics({ ...query, map: 'Unrecorded' }, now);
|
||||
expect(missing).toMatchObject({
|
||||
scopeFirstSampleAt: null,
|
||||
@@ -308,7 +362,8 @@ describe('historical metrics', () => {
|
||||
summary: { observed: 0, unknown: 0 },
|
||||
maps: [],
|
||||
servers: [],
|
||||
pooledPressure: { value: null },
|
||||
pooledAvailability: { value: null },
|
||||
running: { average: null, peak: 0, capturedHours: 0 },
|
||||
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true }
|
||||
});
|
||||
expect(missing.scopeOptions).toEqual(result.scopeOptions);
|
||||
@@ -332,6 +387,160 @@ describe('historical metrics', () => {
|
||||
expect(result.scopeLatestSampleAt).toBe(at - 30_000);
|
||||
});
|
||||
|
||||
it('averages concurrent tracked lobbies over captured wall time, including unknowns and excluding gaps', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const now = Date.UTC(2026, 0, 3, 12);
|
||||
const since = now - 86400_000;
|
||||
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
|
||||
const two = { ...one, server: 'two', players: 0 };
|
||||
const three = { ...one, server: 'three', players: null };
|
||||
const four = { ...one, server: 'four', players: null };
|
||||
recordMetrics([one, two, three, four], since - 120_000);
|
||||
recordMetrics([one, two, three], now - 180_000);
|
||||
recordMetrics([three], now - 150_000);
|
||||
recordMetrics([one, two], now - 30_000);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.running.average).toBeCloseTo(1.6);
|
||||
expect(result.running.peak).toBe(3);
|
||||
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
|
||||
expect(result.previous.running.average).toBe(4);
|
||||
expect(result.previous.running.peak).toBe(4);
|
||||
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
|
||||
expect(result.points.reduce((sum, point) => sum + point.capturedHours, 0)).toBeCloseTo(
|
||||
result.running.capturedHours
|
||||
);
|
||||
const point = result.points.find((point) => point.capturedHours > 0)!;
|
||||
expect((point.observed + point.unknown) / point.capturedHours).toBeCloseTo(1.6);
|
||||
expect(result.summary.observed + result.summary.unknown).toBeCloseTo(
|
||||
result.running.average! * result.running.capturedHours
|
||||
);
|
||||
});
|
||||
|
||||
it('matches current policy context through map aliases and prefers the durable owner', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const at = Date.UTC(2026, 0, 3, 12);
|
||||
const insert = db().prepare(
|
||||
`INSERT INTO autoscaling_policies
|
||||
(id,game,map_slug,settings,mode,paused_reason,owns_map,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`
|
||||
);
|
||||
insert.run(
|
||||
't5-all',
|
||||
't5',
|
||||
'*',
|
||||
JSON.stringify({ minimum: 1, maximum: 4, spares: 2 }),
|
||||
'create-destroy',
|
||||
null,
|
||||
1,
|
||||
'now'
|
||||
);
|
||||
insert.run(
|
||||
'moon',
|
||||
't5',
|
||||
'moon',
|
||||
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
|
||||
'off',
|
||||
'Operator paused.',
|
||||
0,
|
||||
'now'
|
||||
);
|
||||
insert.run(
|
||||
'town',
|
||||
't6',
|
||||
'town',
|
||||
JSON.stringify({ minimum: 2, maximum: 3, spares: 1 }),
|
||||
'create-destroy',
|
||||
null,
|
||||
1,
|
||||
'now'
|
||||
);
|
||||
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
|
||||
recordMetrics(
|
||||
[
|
||||
sample,
|
||||
{ ...sample, server: 'two', map: 'Kino Der Toten' },
|
||||
{ ...sample, server: 'three', game: 't6', map: 'Town Survival' },
|
||||
{ ...sample, server: 'four', game: 't6', map: 'zm_transit' },
|
||||
{ ...sample, server: 'five', game: 't6', map: 'Moon' },
|
||||
{ ...sample, server: 'six', map: 'zombie_cosmodrome' },
|
||||
{ ...sample, server: 'seven', map: 'unknown-map' }
|
||||
],
|
||||
at
|
||||
);
|
||||
const result = readMetrics(1, at + 30_000);
|
||||
expect(result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy).toEqual({
|
||||
id: 't5-all',
|
||||
mode: 'create-destroy',
|
||||
minimum: 1,
|
||||
maximum: 4,
|
||||
spares: 2,
|
||||
pausedReason: null
|
||||
});
|
||||
expect(result.maps.find((map) => map.map === 'Kino Der Toten')!.policy?.id).toBe('t5-all');
|
||||
expect(result.maps.find((map) => map.map === 'zombie_cosmodrome')!.policy?.id).toBe(
|
||||
't5-all'
|
||||
);
|
||||
expect(result.maps.find((map) => map.map === 'Town Survival')!.policy?.id).toBe('town');
|
||||
expect(result.maps.find((map) => map.map === 'zm_transit')!.policy).toBeNull();
|
||||
expect(
|
||||
result.maps.find((map) => map.game === 't6' && map.map === 'Moon')!.policy
|
||||
).toBeNull();
|
||||
expect(result.maps.find((map) => map.map === 'unknown-map')!.policy).toBeNull();
|
||||
db().prepare('UPDATE autoscaling_policies SET owns_map=0').run();
|
||||
expect(
|
||||
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
|
||||
.policy
|
||||
).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' });
|
||||
db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run();
|
||||
expect(
|
||||
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
|
||||
.policy?.pausedReason
|
||||
).toBe('Autoscaling is globally paused. Operator paused.');
|
||||
});
|
||||
|
||||
it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const at = Date.UTC(2026, 0, 3, 12);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO autoscaling_policies
|
||||
(id,game,map_slug,settings,mode,owns_map,updated_at) VALUES (?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
'moon',
|
||||
't5',
|
||||
'moon',
|
||||
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
|
||||
'create-destroy',
|
||||
1,
|
||||
'now'
|
||||
);
|
||||
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
|
||||
const two = { ...one, server: 'two', players: null };
|
||||
recordMetrics([one], at);
|
||||
recordMetrics([one, two], at + 30_000);
|
||||
recordMetrics([one, two, { ...one, server: 'three' }], at + 60_000);
|
||||
recordMetrics([one, { ...two, players: 0 }], at + 90_000);
|
||||
recordMetrics([{ ...one, players: null }, two], at + 120_000);
|
||||
const result = readMetrics(1, at + 150_000);
|
||||
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(90);
|
||||
expect(result.maps[0].noEmptyAtMaximum * 3600).toBeCloseTo(60);
|
||||
expect(result.maps[0].noEmptyBelowMaximum * 3600).toBeCloseTo(30);
|
||||
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
|
||||
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
|
||||
db()
|
||||
.prepare("UPDATE autoscaling_policies SET settings=json_set(settings,'$.maximum',4)")
|
||||
.run();
|
||||
const revised = readMetrics(1, at + 150_000).maps[0];
|
||||
expect(revised.noEmptyAtMaximum).toBe(0);
|
||||
expect(revised.noEmptyBelowMaximum * 3600).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it('compares adjacent equal windows and splits an observation at their shared boundary', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
@@ -391,35 +600,43 @@ describe('historical metrics', () => {
|
||||
expect(result.previous.summary.unknown).toBeLessThan(24);
|
||||
});
|
||||
|
||||
it('pools pressure by known map-hours rather than server-hours or fleet elapsed time', async () => {
|
||||
it('pools availability by known map-hours rather than lobby-hours or fleet elapsed time', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 3, 12);
|
||||
const full = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
|
||||
const occupied = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
|
||||
recordMetrics(
|
||||
[
|
||||
full,
|
||||
{ ...full, server: 'two' },
|
||||
{ ...full, server: 'three', map: 'Town', players: 0 },
|
||||
{ ...full, server: 'four', map: 'Farm', players: 2, capacity: null }
|
||||
occupied,
|
||||
{ ...occupied, server: 'two' },
|
||||
{ ...occupied, server: 'three', map: 'Town', players: 0 },
|
||||
{ ...occupied, server: 'four', map: 'Farm', players: 2, capacity: null },
|
||||
{ ...occupied, server: 'five', map: 'Unknown', players: null }
|
||||
],
|
||||
at
|
||||
);
|
||||
const result = readMetrics(1, at + 30_000);
|
||||
expect(result.pooledPressure).toMatchObject({ value: 50, coveredMaps: 2, totalMaps: 3 });
|
||||
expect(result.pooledPressure.fullMapHours * 3600).toBeCloseTo(30);
|
||||
expect(result.pooledPressure.knownMapHours * 3600).toBeCloseTo(60);
|
||||
expect(result.pooledAvailability).toMatchObject({ coveredMaps: 3, totalMaps: 4 });
|
||||
expect(result.pooledAvailability.value).toBeCloseTo(200 / 3);
|
||||
expect(result.pooledAvailability.noEmptyMapHours * 3600).toBeCloseTo(60);
|
||||
expect(result.pooledAvailability.knownMapHours * 3600).toBeCloseTo(90);
|
||||
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
|
||||
expect(result.summary.observed * 3600).toBeCloseTo(120);
|
||||
const empty = readMetrics(
|
||||
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })),
|
||||
at + 30_000
|
||||
);
|
||||
expect(empty.pooledPressure.value).toBe(0);
|
||||
const missing = readMetrics(
|
||||
expect(empty.pooledAvailability.value).toBe(0);
|
||||
const missingCapacity = readMetrics(
|
||||
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })),
|
||||
at + 30_000
|
||||
);
|
||||
expect(missing.pooledPressure.value).toBeNull();
|
||||
expect(missingCapacity.pooledAvailability.value).toBe(100);
|
||||
const unknown = readMetrics(
|
||||
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Unknown' })),
|
||||
at + 30_000
|
||||
);
|
||||
expect(unknown.pooledAvailability.value).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
Reference in New Issue
Block a user