Remove autoscaling action cooldown

This commit is contained in:
2026-10-07 15:51:52 -04:00
parent eadf97a428
commit a7a600b687
13 changed files with 80 additions and 27 deletions
+32 -3
View File
@@ -14,7 +14,7 @@ const maps = zombieMaps('t6');
it('satisfies a T5 game-wide policy with the ten standard Zombies maps', () => {
const now = 2_000_000;
const policy: ScalingPolicy = { ...scalingSettings({ ...settings, game: 't5' }),
id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
id: 'policy', revision: 1, pausedReason: null, updatedAt: '' };
const members: ScalingMember[] = zombieMaps('t5').map(map => ({ name: `t5_zombie_${map.slug}_1`, mapSlug: map.slug,
identity: map.slug, state: 'empty', players: 0, observedAt: now, source: 'rcon', emptySince: 1, blocker: null, operationId: null }));
const snapshot = { members, reservedNames: members.map(m => m.name), keyPool: { total: 20, available: 10 }, blockers: [] };
@@ -32,7 +32,7 @@ async function setup() {
it('applies targets and spare deficits independently to every map', () => {
const now = 2_000_000;
const policy: ScalingPolicy = { ...scalingSettings(settings), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
const policy: ScalingPolicy = { ...scalingSettings(settings), id: 'policy', revision: 1, pausedReason: null, updatedAt: '' };
const members: ScalingMember[] = maps.flatMap(map => [1, 2].map(n => ({ name: `t6_zombie_${map.slug}_${n}`, mapSlug: map.slug,
identity: `${map.slug}:${n}`, state: map.slug === 'town' ? 'occupied' : 'empty', players: map.slug === 'town' ? 1 : 0,
observedAt: now, source: 'rcon', emptySince: 1, blocker: null, operationId: null })));
@@ -42,7 +42,6 @@ it('applies targets and spare deficits independently to every map', () => {
snapshot.members = members.filter(m => m.name !== 't6_zombie_town_2');
snapshot.reservedNames = snapshot.members.map(m => m.name);
expect(planScaling(policy, snapshot, now)).toMatchObject({ action: 'create', server: 't6_zombie_town_2' });
expect(planScaling({ ...policy, nextActionAt: now + 1 }, snapshot, now).action).toBe('wait');
snapshot.members[0] = { ...snapshot.members[0], state: 'creating' };
expect(planScaling(policy, snapshot, now).action).toBe('wait');
});
@@ -87,3 +86,33 @@ it('holds game-wide growth when its shared pool is exhausted by another map', as
expect(await scaling.runAutoscaling()).toBe(false);
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 0 });
});
it('moves to the next map as soon as the prior creation settles, without waiting between actions', async () => {
const { host, versions } = await prepareNativeHost();
const { db } = await import('../../src/lib/server/db/index.js');
const { config } = await import('../../src/lib/server/config.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
for (const n of [1, 2]) db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES(?,'t6',?)").run(`key-${n}`, `game-key-${n}`);
vi.useFakeTimers({ toFake: ['Date'] });
try {
const at = new Date().toISOString();
scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
expect(await scaling.runAutoscaling()).toBe(true);
const job = db().prepare('SELECT deployment_id,server FROM server_creation_jobs').get() as { deployment_id: string; server: string };
expect(job.server).toBe('t6_zombie_transit_1');
expect(await scaling.runAutoscaling()).toBe(false);
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 1 });
// Record the first lobby becoming ready without advancing the clock.
db().prepare("UPDATE server_deployments SET state='done',finished_at=? WHERE id=?").run(at, job.deployment_id);
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
VALUES('ready-instance',?,?,?,?,28960,?)`).run(job.server, host.id, versions.get('t6'), config.defaultRuntimeImage, at);
db().prepare(`INSERT INTO servers(name,game,map,map_slug,container_state,container_checked_at,last_seen_at,last_checked_at,last_players)
VALUES(?,'t6','zm_transit','transit','running',?,?,?,0)`).run(job.server, at, at, at);
expect(await scaling.runAutoscaling()).toBe(true);
expect(db().prepare('SELECT server FROM server_creation_jobs ORDER BY rowid').all()).toEqual([
{ server: 't6_zombie_transit_1' }, { server: 't6_zombie_town_1' }
]);
expect(db().prepare('SELECT COUNT(*) AS n FROM autoscaling_decisions WHERE resolved=0').get()).toEqual({ n: 1 });
} finally { vi.useRealTimers(); }
});
+2 -3
View File
@@ -4,7 +4,7 @@ import { emptyObservation, planScaling } from '../../src/lib/server/autoscaling/
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: 'create-destroy' }), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
const policy: ScalingPolicy = { ...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'create-destroy' }), id: 'policy', revision: 1, 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), keyPool: { total: 4, available: 4 - members.length }, blockers: [] });
@@ -31,9 +31,8 @@ describe('autoscaling policy planning', () => {
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', () => {
it('holds for stale and future samples 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', () => {
+22
View File
@@ -114,3 +114,25 @@ it.each([false, true])('reconciles removed-mode creation while preserving submit
expect(db().prepare('SELECT COUNT(*) AS n FROM server_key_leases').get()).toEqual({ n: submitted ? 1 : 0 });
expect(db().prepare("SELECT COUNT(*) AS n FROM server_deployments WHERE action='create'").get()).toEqual({ n: 1 });
});
it('removes saved cooldowns on upgrade without changing the empty hold or pausing the policy', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
const input = { ...settings, emptyMinutes: 17 };
const saved = scaling.saveScaling(input, undefined, undefined, scaling.previewScaling(input).token);
db().exec('ALTER TABLE autoscaling_policies ADD COLUMN next_action_at INTEGER NOT NULL DEFAULT 0');
db().prepare("UPDATE autoscaling_policies SET next_action_at=?,settings=json_set(settings,'$.cooldownMinutes',60) WHERE id=?")
.run(Date.now() + 3600_000, saved.id);
closeDb();
const restored = scaling.getScalingPolicy(saved.id)!;
expect(restored).toEqual(saved);
expect(restored).not.toHaveProperty('cooldownMinutes');
expect(restored).not.toHaveProperty('nextActionAt');
expect(db().pragma('table_info(autoscaling_policies)')).not.toContainEqual(expect.objectContaining({ name: 'next_action_at' }));
expect(JSON.parse((db().prepare('SELECT settings FROM autoscaling_policies WHERE id=?').get(saved.id) as { settings: string }).settings))
.not.toHaveProperty('cooldownMinutes');
closeDb();
expect(scaling.getScalingPolicy(saved.id)).toEqual(saved);
expect(await scaling.runAutoscaling()).toBe(true);
expect(scaling.scalingReport().policies[0].policy.emptyMinutes).toBe(17);
});
+1 -1
View File
@@ -8,7 +8,7 @@ let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.useRealTimers(); await fixture.cleanup(); vi.restoreAllMocks(); });
const names = ['t6_zombie_town_1', 't6_zombie_town_2'];
const settings = { game: 't6', mapSlug: 'town', maximum: 2, mode: 'create-destroy', emptyMinutes: 1, cooldownMinutes: 1 };
const settings = { game: 't6', mapSlug: 'town', maximum: 2, mode: 'create-destroy', emptyMinutes: 1 };
async function setup(gameWide = false) {
const servers = gameWide ? [...names, ...zombieMaps('t6').filter(m => m.slug !== 'town').map(m => `t6_zombie_${m.slug}_1`)] : names;
+1 -1
View File
@@ -7,7 +7,7 @@ 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: 'create-destroy' }), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
const policy: ScalingPolicy = { ...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'create-destroy' }), id: 'policy', revision: 1, pausedReason: null, updatedAt: '' };
it('separates variants, deduplicates pending identities, and initializes additive schema', async () => {
await prepareNativeHost([town, farm]);