Add autoscaling policy model and deterministic planner

This commit is contained in:
2026-10-06 22:48:59 -04:00
parent a22461ab93
commit ed4d8a4802
8 changed files with 295 additions and 1 deletions
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { scalingSettings, type ScalingMember, type ScalingPolicy, type ScalingSnapshot } from '../../src/lib/autoscaling.js';
import { emptyObservation, planScaling } from '../../src/lib/server/autoscaling/planner.js';
import { mapIdentity } from '../../src/lib/server/autoscaling/snapshot.js';
const now = 2_000_000;
const policy: ScalingPolicy = { ...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'dry-run' }), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
const name = (n: number) => `t6_zombie_town_${n}`;
const member = (n: number, state: ScalingMember['state'] = 'empty'): ScalingMember => ({ name: name(n), identity: `instance:${n}`, state, players: state === 'occupied' ? 1 : state === 'empty' ? 0 : null, observedAt: now, source: 'rcon', emptySince: 1, blocker: null, operationId: null });
const snapshot = (...members: ScalingMember[]): ScalingSnapshot => ({ members, reservedNames: members.map(m => m.name), keyedNames: [1, 2, 3, 4].map(name), blockers: [] });
describe('autoscaling policy planning', () => {
it('requires explicit bounded maximum and valid variant', () => {
for (const input of [{}, { game: 't6', mapSlug: 'town' }, { game: 't5', mapSlug: 'town', maximum: 4 }, { game: 't6', mapSlug: 'town', maximum: 21 }, { ...policy, minimum: 5 }, { ...policy, spares: 0 }, { ...policy, emptyMinutes: 1.5 }]) expect(() => scalingSettings(input)).toThrow();
expect(scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4 }).mode).toBe('off');
});
it('creates a spare even with free player slots and bootstraps an empty map', () => {
expect(planScaling(policy, snapshot(member(1, 'occupied')), now)).toMatchObject({ action: 'create', server: name(2), desired: 2 });
expect(planScaling(policy, snapshot(), now)).toMatchObject({ action: 'create', desired: 1 });
expect(planScaling(policy, snapshot(member(1, 'occupied'), member(2)), now).action).toBe('wait');
});
it('uses nonconsecutive keyed identities without moving keys', () => {
expect(planScaling(policy, { ...snapshot(), keyedNames: [name(8), name(3)] }, now).server).toBe(name(3));
expect(planScaling(policy, { ...snapshot(), keyedNames: [] }, now).reason).toContain('saved key');
});
it('holds for pending creation, unknown, stopped, or protected members', () => {
for (const state of ['creating', 'removing', 'unknown', 'stopped', 'protected'] as const) expect(planScaling(policy, snapshot(member(1, state)), now).action).toBe('wait');
expect(planScaling(policy, { ...snapshot(), blockers: ['Unresolved instance'] }, now).action).toBe('wait');
});
it('holds for stale and future samples, cooldown, and disabled policies', () => {
for (const stamp of [null, now - 90_001, now + 1]) expect(planScaling(policy, snapshot({ ...member(1, 'occupied'), observedAt: stamp }), now).action).toBe('wait');
expect(planScaling({ ...policy, nextActionAt: now + 1 }, snapshot(), now).action).toBe('wait');
expect(planScaling({ ...policy, mode: 'off' }, snapshot(), now).action).toBe('wait');
});
it('never sacrifices occupied lobbies or minimum/spare capacity to a maximum', () => {
expect(planScaling(policy, snapshot(...[1, 2, 3, 4].map(n => member(n, 'occupied'))), now)).toMatchObject({ action: 'wait', desired: 4 });
expect(planScaling({ ...policy, maximum: 1 }, snapshot(member(1, 'occupied'), member(2)), now).action).toBe('wait');
expect(planScaling({ ...policy, minimum: 3 }, snapshot(member(1), member(2), member(3)), now).action).toBe('wait');
});
it('trims one longest-empty lobby and requires a full observed interval', () => {
expect(planScaling(policy, snapshot(member(1, 'occupied'), member(2), member(3), member(4)), now)).toMatchObject({ action: 'destroy', server: name(2), desired: 2 });
expect(planScaling(policy, snapshot({ ...member(1), emptySince: now }, { ...member(2), emptySince: now }), now).action).toBe('wait');
});
it('preserves map variants and refuses ambiguous engine maps', () => {
expect(mapIdentity({ name: 'legacy', game: 't6', map: 'zm_transit' })).toBeNull();
expect(mapIdentity({ name: name(1), map: 'zm_transit' })?.mapSlug).toBe('town');
expect(mapIdentity({ name: 'legacy', game: 't6', map_slug: 'farm', map: 'zm_transit' })?.mapSlug).toBe('farm');
});
});
describe('continuous empty evidence', () => {
it('does not turn rereads into new evidence', () => {
const previous = emptyObservation(member(1), undefined, now);
const reread = emptyObservation(member(1), previous, now + 60_000);
expect(reread).toMatchObject({ observedAt: now, emptySince: now });
expect(emptyObservation({ ...member(1), observedAt: now + 60_000 }, previous, now + 60_000).emptySince).toBe(now);
});
it('resets on unknown, players, lifecycle replacement, and observation gaps', () => {
const previous = emptyObservation(member(1), undefined, now);
expect(emptyObservation(member(1, 'occupied'), previous, now).emptySince).toBeNull();
expect(emptyObservation(member(1, 'unknown'), previous, now).emptySince).toBeNull();
expect(emptyObservation({ ...member(1), identity: 'replacement', observedAt: now + 1 }, previous, now + 1).emptySince).toBe(now + 1);
expect(emptyObservation({ ...member(1), observedAt: now + 120_000 }, previous, now + 120_000).emptySince).toBe(now + 120_000);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { prepareNativeHost } from '../helpers/native-host.js';
import { scalingSettings, type ScalingPolicy } from '../../src/lib/autoscaling.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
const town = 't6_zombie_town_1', farm = 't6_zombie_farm_1';
const policy: ScalingPolicy = { ...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'dry-run' }), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
it('separates variants, deduplicates pending identities, and initializes additive schema', async () => {
await prepareNativeHost([town, farm]);
const { db } = await import('../../src/lib/server/db/index.js');
const { scalingSnapshot } = await import('../../src/lib/server/autoscaling/snapshot.js');
const { ensureAutoscalingSchema } = await import('../../src/lib/server/autoscaling/schema.js');
ensureAutoscalingSchema(db()); ensureAutoscalingSchema(db());
const at = new Date().toISOString();
for (const server of [town, farm]) db().prepare(`INSERT INTO servers(name,container_state,container_checked_at,last_seen_at,last_checked_at,last_players) VALUES(?,'running',?,?,?,0)`).run(server, at, at, at);
expect(scalingSnapshot(policy).members.map(m => [m.name, m.state])).toEqual([[town, 'empty']]);
db().prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('create',?,'create','queued','Awaiting placement',?,'test')`).run(town, at);
db().prepare(`INSERT INTO server_creation_jobs(deployment_id,server,game_mode,map_slug,expected_map) VALUES('create',?,'zombie','town','zm_transit')`).run(town);
const snapshot = scalingSnapshot(policy);
expect(snapshot.members).toHaveLength(1);
expect(snapshot.members[0].state).toBe('creating');
expect(snapshot.reservedNames).toEqual([town]);
});
it('does not create schema mutations or host work when planning an empty map', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { scalingSnapshot } = await import('../../src/lib/server/autoscaling/snapshot.js');
const { planScaling } = await import('../../src/lib/server/autoscaling/planner.js');
db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run('t6_zombie_town_8', 'private-key');
expect(planScaling(policy, scalingSnapshot(policy), Date.now()).server).toBe('t6_zombie_town_8');
expect(db().prepare('SELECT COUNT(*) AS n FROM server_deployments').get()).toEqual({ n: 0 });
});