Pool Plutonium keys by game for launches and autoscaling
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { prepareNativeHost } from '../helpers/native-host.js';
|
||||
import { zombieMaps } from '../../src/lib/map-aliases.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
@@ -10,73 +9,90 @@ const settings = { game: 't6', mapSlug: 'town', maximum: 2, mode: 'dry-run' };
|
||||
async function setup() {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const scaling = await import('../../src/lib/server/autoscaling/index.js');
|
||||
const key = (name: string, value = `key-${name}`) => db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(name, value);
|
||||
return { db, scaling, key };
|
||||
const { saveServerKey, reserveServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||
return { db, scaling, saveServerKey, reserveServerKey };
|
||||
}
|
||||
|
||||
it.each(['off', 'dry-run', 'create-only', 'create-destroy'])('rejects new %s policies without enough keys through both service and API', async mode => {
|
||||
const { db, scaling, key } = await setup();
|
||||
key('t6_zombie_town_3');
|
||||
const input = { ...settings, mode };
|
||||
it.each(['off', 'dry-run', 'create-only', 'create-destroy'])('saves a %s policy without preallocating keys for its maximum', async mode => {
|
||||
if (mode.startsWith('create')) await prepareNativeHost();
|
||||
const { db, scaling } = await setup();
|
||||
const input = { ...settings, mode, maximum: 20 };
|
||||
const preview = scaling.previewScaling(input);
|
||||
expect(preview.blockers[0]).toContain('1 saved; 1 more required');
|
||||
expect(() => scaling.saveScaling(input, undefined, undefined, preview.token)).toThrow('Save keys');
|
||||
expect(preview.blockers).toEqual([]);
|
||||
expect(preview.decision.action).toBe('wait');
|
||||
if (mode !== 'off') expect(preview.decision.reason).toContain('No Plutonium keys');
|
||||
const { POST } = await import('../../src/routes/api/autoscaling/+server.js');
|
||||
const response = await POST({ request: new Request('http://localhost/api/autoscaling', { method: 'POST', body: JSON.stringify({ action: 'save', settings: input, token: preview.token }) }) });
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ error: expect.stringContaining('Save keys') });
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM autoscaling_policies').get()).toEqual({ n: 0 });
|
||||
expect(response.status).toBe(200);
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM autoscaling_policies').get()).toEqual({ n: 1 });
|
||||
expect(await scaling.runAutoscaling()).toBe(false);
|
||||
});
|
||||
|
||||
it('counts only supported, nonblank identities in the selected map and accepts exact nonconsecutive coverage', async () => {
|
||||
const { scaling, key } = await setup();
|
||||
for (const name of ['t6_zombie_town_0', 't6_zombie_town_21', 't6_zombie_town_01', 't6_zombie_town', 't6_town_2', 't5_zombie_theater_1', 't6_zombie_farm_1']) key(name);
|
||||
key('t6_zombie_town_2', ' ');
|
||||
key('t6_zombie_town_3');
|
||||
expect(scaling.previewScaling(settings).snapshot.keyedNames).toEqual(['t6_zombie_town_3']);
|
||||
key('t6_zombie_town_8');
|
||||
it('uses any free key for the selected game independently of its label or map', async () => {
|
||||
const { scaling, saveServerKey } = await setup();
|
||||
saveServerKey('t5', 'other-game-key', 'Town Lobby 1');
|
||||
saveServerKey('t6', 'game-key', 'Farm Lobby 8');
|
||||
const preview = scaling.previewScaling(settings);
|
||||
expect(preview.blockers).toEqual([]);
|
||||
expect(preview.snapshot.keyPool).toEqual({ total: 1, available: 1 });
|
||||
expect(preview.decision).toMatchObject({ action: 'create', server: 't6_zombie_town_1' });
|
||||
expect(scaling.saveScaling(settings, undefined, undefined, preview.token).maximum).toBe(2);
|
||||
});
|
||||
|
||||
it('requires coverage on every game map rather than pooling excess keys from another map', async () => {
|
||||
const { scaling, key } = await setup();
|
||||
for (const map of zombieMaps('t6')) for (const n of [3, 8]) if (map.slug !== 'town' || n === 3) key(`t6_zombie_${map.slug}_${n}`);
|
||||
key('t6_zombie_farm_9');
|
||||
const input = { ...settings, mapSlug: '*' };
|
||||
it('uses one shared pool for every map without requiring a key for each maximum', async () => {
|
||||
const { scaling, saveServerKey } = await setup();
|
||||
saveServerKey('t6', 'game-key');
|
||||
const input = { ...settings, mapSlug: '*', maximum: 20 };
|
||||
const preview = scaling.previewScaling(input);
|
||||
expect(preview.blockers).toEqual([expect.stringContaining('Town Survival')]);
|
||||
expect(() => scaling.saveScaling(input, undefined, undefined, preview.token)).toThrow('Town Survival');
|
||||
key('t6_zombie_town_8');
|
||||
expect(scaling.saveScaling(input, undefined, undefined, scaling.previewScaling(input).token).mapSlug).toBe('*');
|
||||
});
|
||||
|
||||
it('rechecks key deletion after preview and rejects increases beyond coverage', async () => {
|
||||
const { db, scaling, key } = await setup();
|
||||
key('t6_zombie_town_3'); key('t6_zombie_town_8');
|
||||
const preview = scaling.previewScaling(settings);
|
||||
db().prepare("DELETE FROM server_keys WHERE server_id='t6_zombie_town_8'").run();
|
||||
expect(() => scaling.saveScaling(settings, undefined, undefined, preview.token)).toThrow('preview changed');
|
||||
expect(() => scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token)).toThrow('Save keys');
|
||||
key('t6_zombie_town_8');
|
||||
const saved = scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
|
||||
const higher = { ...settings, maximum: 3 };
|
||||
expect(() => scaling.saveScaling(higher, saved.id, saved.revision, scaling.previewScaling(higher, saved.id, saved.revision).token)).toThrow('Save keys');
|
||||
expect(scaling.getScalingPolicy(saved.id)?.maximum).toBe(2);
|
||||
});
|
||||
|
||||
it('allows an existing live policy to pause and withdraw pending work after all keys are removed', async () => {
|
||||
await prepareNativeHost();
|
||||
const { db, scaling, key } = await setup();
|
||||
key('t6_zombie_town_3'); key('t6_zombie_town_8');
|
||||
const live = { ...settings, mode: 'create-only' };
|
||||
const saved = scaling.saveScaling(live, undefined, undefined, scaling.previewScaling(live).token);
|
||||
await scaling.runAutoscaling();
|
||||
db().prepare('DELETE FROM server_keys').run();
|
||||
const off = { ...settings, mode: 'off' };
|
||||
const preview = scaling.previewScaling(off, saved.id, saved.revision);
|
||||
expect(preview.blockers).toEqual([]);
|
||||
expect(scaling.saveScaling(off, saved.id, saved.revision, preview.token).mode).toBe('off');
|
||||
expect(db().prepare("SELECT state FROM server_deployments WHERE action='create'").get()).toEqual({ state: 'cancelled' });
|
||||
expect(preview.snapshot.keyPool).toEqual({ total: 1, available: 1 });
|
||||
expect(scaling.saveScaling(input, undefined, undefined, preview.token).mapSlug).toBe('*');
|
||||
});
|
||||
|
||||
it('rechecks game pool deletion and reservations after preview', async () => {
|
||||
const { db, scaling, saveServerKey, reserveServerKey } = await setup();
|
||||
const keyId = saveServerKey('t6', 'game-key');
|
||||
const preview = scaling.previewScaling(settings);
|
||||
db().prepare('DELETE FROM server_keys WHERE id=?').run(keyId);
|
||||
expect(() => scaling.saveScaling(settings, undefined, undefined, preview.token)).toThrow('preview changed');
|
||||
saveServerKey('t6', 'game-key');
|
||||
const available = scaling.previewScaling(settings);
|
||||
reserveServerKey('t6_zombie_farm_1', 't6');
|
||||
expect(() => scaling.saveScaling(settings, undefined, undefined, available.token)).toThrow('preview changed');
|
||||
const held = scaling.previewScaling(settings);
|
||||
expect(held.decision.reason).toContain('keys are in use');
|
||||
expect(held.snapshot.members).toEqual([]);
|
||||
expect(scaling.saveScaling(settings, undefined, undefined, held.token).maximum).toBe(2);
|
||||
});
|
||||
|
||||
it('shares capacity between live map policies and admits the job holding the last key', async () => {
|
||||
await prepareNativeHost();
|
||||
const { db, scaling, saveServerKey } = await setup();
|
||||
saveServerKey('t6', 'game-key');
|
||||
saveServerKey('t5', 'other-game-key');
|
||||
for (const mapSlug of ['town', 'farm']) {
|
||||
const input = { ...settings, mapSlug, mode: 'create-only' };
|
||||
scaling.saveScaling(input, undefined, undefined, scaling.previewScaling(input).token);
|
||||
}
|
||||
expect(await scaling.runAutoscaling()).toBe(true);
|
||||
const jobs = db().prepare('SELECT deployment_id,server FROM server_creation_jobs').all() as { deployment_id: string; server: string }[];
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0].server).toBe('t6_zombie_farm_1');
|
||||
const { scalingAdmission } = await import('../../src/lib/server/autoscaling/admission.js');
|
||||
expect(scalingAdmission(jobs[0].deployment_id)).toBeNull();
|
||||
const town = scaling.scalingReport().policies.find(p => p.policy.mapSlug === 'town')!;
|
||||
expect(town.snapshot.keyPool).toEqual({ total: 1, available: 0 });
|
||||
expect(town.decision.reason).toContain('keys are in use');
|
||||
expect(town.policy.pausedReason).toBeNull();
|
||||
expect(await scaling.runAutoscaling()).toBe(false);
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('reserves a lobby identity for an unsettled failed launch with a retained key', async () => {
|
||||
const { scaling, saveServerKey, reserveServerKey } = await setup();
|
||||
saveServerKey('t6', 'held-key');
|
||||
reserveServerKey('t6_zombie_town_1', 't6');
|
||||
saveServerKey('t6', 'free-key');
|
||||
const preview = scaling.previewScaling(settings);
|
||||
expect(preview.snapshot.reservedNames).toEqual(['t6_zombie_town_1']);
|
||||
expect(preview.decision).toMatchObject({ action: 'create', server: 't6_zombie_town_2' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user