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
+145 -37
View File
@@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest';
import { metricRecommendations } from '../../src/lib/metrics-insights.js';
import type { MapMetrics } from '../../src/lib/metrics.js';
const policy = {
id: 't5-policy',
mode: 'create-destroy' as const,
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
};
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5',
map: 'Moon',
@@ -10,44 +18,148 @@ const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
idle: 5,
unknown: 0,
playerHours: 10,
full: 0,
capacityObserved: 5,
noEmpty: 0,
availabilityObserved: 5,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy,
...values
});
describe('advisory hosting experiments', () => {
it('uses map capacity time rather than summed server time for expansion', () => {
describe('autoscaling policy signals', () => {
it('compares map availability with the current maximum without needing player limits', () => {
const [result] = metricRecommendations([
map({ observed: 100, active: 50, idle: 50, full: 1, capacityObserved: 4 })
map({
observed: 100,
active: 50,
idle: 50,
noEmpty: 0.1,
availabilityObserved: 4,
noEmptyAtMaximum: 0.1
})
]);
expect(result.kind).toBe('expand');
expect(result.evidence).toContain('25.0%');
expect(result.tentative).toBe(false);
expect(result).toMatchObject({
kind: 'maximum',
tentative: false,
href: '/autoscaling#t5-policy',
action: 'Review policy'
});
expect(result.evidence).toContain('2.5%');
expect(result.evidence).toContain('At or above the current maximum (3)');
expect(result.policySummary).toBe('Current policy: 1–3 lobbies, 1 spare');
});
it('offers a tentative trial even with a tiny recorded sample', () => {
it('reviews the spare target and create time for no-empty time below the maximum', () => {
const [result] = metricRecommendations([
map({ noEmpty: 1, noEmptyBelowMaximum: 0.9, noEmptyAtMaximum: 0.1 })
]);
expect(result.kind).toBe('spares');
expect(result.reason).toContain('below the current maximum of 3');
expect(result.reason).toContain('raising the spare target from 1');
expect(result.title).toContain('create time');
expect(result.evidence).toContain('below it: 0.9 h');
});
it('uses the larger no-empty duration and favors maximum review on a tie', () => {
const [result] = metricRecommendations([
map({ noEmpty: 1, noEmptyBelowMaximum: 0.5, noEmptyAtMaximum: 0.5 })
]);
expect(result.kind).toBe('maximum');
expect(result.reason).toContain('At least half');
});
it('uses the two-percent threshold without treating occupied time as demand', () => {
expect(
metricRecommendations([
map({ active: 9.99, idle: 0.01, noEmpty: 0.099, noEmptyAtMaximum: 0.099 })
])[0].kind
).toBe('watch');
expect(metricRecommendations([map({ noEmpty: 0.1, noEmptyAtMaximum: 0.1 })])[0].kind).toBe(
'maximum'
);
expect(metricRecommendations([map({ active: 0.1, idle: 9.9 })])[0].kind).toBe('watch');
});
it('marks a tiny recorded sample as tentative', () => {
const [result] = metricRecommendations([
map({
observed: 1 / 120,
active: 1 / 120,
idle: 0,
full: 1 / 120,
capacityObserved: 1 / 120
noEmpty: 1 / 120,
availabilityObserved: 1 / 120,
noEmptyAtMaximum: 1 / 120
})
]);
expect(result.kind).toBe('expand');
expect(result.kind).toBe('maximum');
expect(result.tentative).toBe(true);
expect(result.evidence).toContain('30 s');
});
it('uses explicitly tentative occupancy advice when capacity is missing', () => {
const [result] = metricRecommendations([map({ active: 8, idle: 2, capacityObserved: 0 })]);
expect(result.kind).toBe('expand');
it('measures evidence coverage with map availability time', () => {
const [result] = metricRecommendations([
map({
noEmpty: 1,
noEmptyBelowMaximum: 1,
availabilityObserved: 5,
availabilityUnknown: 2,
observed: 100
})
]);
expect(result.kind).toBe('spares');
expect(result.tentative).toBe(true);
expect(result.reason).toContain('not evidence of saturation');
expect(
metricRecommendations([map({ availabilityObserved: 4, availabilityUnknown: 1 })])[0]
.tentative
).toBe(false);
});
it('does not confuse unknown occupancy with an idle fleet', () => {
it('suggests adding a policy for a map without one', () => {
const [result] = metricRecommendations([map({ policy: null })]);
expect(result).toMatchObject({
kind: 'policy',
title: 'Consider adding an autoscaling policy',
href: '/autoscaling',
action: 'Add policy',
tentative: false
});
});
it('keeps mixed no-empty evidence tentative when most lobby occupancy is unknown', () => {
const [result] = metricRecommendations([
map({
observed: 2,
active: 2,
idle: 0,
unknown: 18,
availabilityObserved: 2,
availabilityUnknown: 0,
noEmpty: 2,
noEmptyAtMaximum: 2
})
]);
expect(result.kind).toBe('maximum');
expect(result.tentative).toBe(true);
expect(result.evidence).toContain('18 h of lobby time has unknown occupancy');
});
it('reviews paused or disabled policies before suggesting limit changes', () => {
const values = { noEmpty: 1, noEmptyAtMaximum: 1 };
const [paused] = metricRecommendations([
map({ ...values, policy: { ...policy, pausedReason: 'Review a failed host command.' } })
]);
expect(paused.kind).toBe('policy');
expect(paused.title).toBe('Review and resume the autoscaling policy');
expect(paused.reason).toContain('Review a failed host command.');
const [disabled] = metricRecommendations([
map({ ...values, policy: { ...policy, mode: 'off' } })
]);
expect(disabled.kind).toBe('policy');
expect(disabled.title).toBe('Review and enable the autoscaling policy');
});
it('does not confuse all-unknown occupancy with an idle fleet or a policy signal', () => {
const [result] = metricRecommendations([
map({
observed: 0,
@@ -55,37 +167,33 @@ describe('advisory hosting experiments', () => {
idle: 0,
unknown: 10,
playerHours: 0,
capacityObserved: 0
availabilityObserved: 0,
availabilityUnknown: 5,
policy: null
})
]);
expect(result.kind).toBe('capacity');
expect(result.kind).toBe('reporting');
expect(result.title).toBe('Check occupancy reporting');
expect(result.tentative).toBe(true);
});
it('keeps advice scoped to input maps and considers idle trials with incomplete capacity', () => {
const [result] = metricRecommendations([
map({ game: 't6', map: 'Town', active: 1, idle: 9, capacityObserved: 0 })
]);
expect(result).toMatchObject({
game: 't6',
map: 'Town',
kind: 'consolidate',
tentative: true
});
expect(result.reason).toContain('preserve access');
expect(metricRecommendations([])).toEqual([]);
});
it('distinguishes a zero-duration sample from failed occupancy reporting', () => {
const [result] = metricRecommendations([
map({ observed: 0, active: 0, idle: 0, playerHours: 0, capacityObserved: 0 })
map({ observed: 0, active: 0, idle: 0, playerHours: 0, availabilityObserved: 0 })
]);
expect(result.title).toBe('Let observations accumulate');
expect(result.reason).toContain('no elapsed observations');
expect(result.reason).toContain('no elapsed availability observations');
});
it('leaves the lineup alone when measured evidence does not trigger a rule', () => {
expect(metricRecommendations([map({ active: 9, idle: 1, full: 0 })])[0].kind).toBe('watch');
it('keeps advice scoped to input maps and sorts policy actions before quiet findings', () => {
const results = metricRecommendations([
map({ map: 'Moon' }),
map({ game: 't6', map: 'Town', noEmpty: 1, noEmptyBelowMaximum: 1 })
]);
expect(results.map(({ game, map, kind }) => ({ game, map, kind }))).toEqual([
{ game: 't6', map: 'Town', kind: 'spares' },
{ game: 't5', map: 'Moon', kind: 'watch' }
]);
expect(metricRecommendations([])).toEqual([]);
});
});