Save autoscaling policies without a preview step

This commit is contained in:
2026-10-07 15:56:13 -04:00
parent 7ede98ff87
commit daf78bc11c
12 changed files with 127 additions and 124 deletions
+5 -5
View File
@@ -12,7 +12,7 @@ async function setup(mode = 'create-destroy') {
const scaling = await import('../../src/lib/server/autoscaling/index.js');
db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('key-1','t6','test-key')").run();
const input = { ...settings, mode };
const saved = scaling.saveScaling(input, undefined, undefined, scaling.previewScaling(input).token);
const saved = scaling.saveScaling(input);
return { db, scaling, saved, input };
}
@@ -22,7 +22,7 @@ it('deletes policy evidence atomically while preserving lobbies, keys, and unrel
await scaling.runAutoscaling();
db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('other-key','t5','other-key')").run();
const otherSettings = { ...settings, game: 't5', mapSlug: 'theater', mode: 'off' };
const other = scaling.saveScaling(otherSettings, undefined, undefined, scaling.previewScaling(otherSettings).token);
const other = scaling.saveScaling(otherSettings);
for (const table of ['autoscaling_decisions', 'autoscaling_members', 'autoscaling_samples']) expect(db().prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE policy_id=?`).get(saved.id)).toEqual({ n: 1 });
scaling.deleteScaling(saved.id, saved.revision);
expect(scaling.getScalingPolicy(saved.id)).toBeUndefined();
@@ -31,7 +31,7 @@ it('deletes policy evidence atomically while preserving lobbies, keys, and unrel
expect(db().prepare('SELECT name FROM servers').all()).toEqual([{ name: 't6_zombie_town_1' }]);
expect(db().prepare('SELECT COUNT(*) AS n FROM server_keys').get()).toEqual({ n: 2 });
expect(db().prepare('PRAGMA foreign_key_check').all()).toEqual([]);
expect(scaling.saveScaling({ ...settings, mode: 'off' }, undefined, undefined, scaling.previewScaling({ ...settings, mode: 'off' }).token).id).not.toBe(saved.id);
expect(scaling.saveScaling({ ...settings, mode: 'off' }).id).not.toBe(saved.id);
});
it.each(['off', 'create-destroy'])('deletes a settled %s policy even after key loss and releases ownership', async mode => {
@@ -47,7 +47,7 @@ it.each(['off', 'create-destroy'])('deletes a settled %s policy even after key l
it('rejects stale revisions and preserves the latest settings', async () => {
const { scaling, saved } = await setup();
const off = { ...settings, mode: 'off' };
const edited = scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
const edited = scaling.saveScaling(off, saved.id, saved.revision);
expect(() => scaling.deleteScaling(saved.id, saved.revision)).toThrow('changed');
expect(scaling.getScalingPolicy(saved.id)).toEqual(edited);
expect(() => scaling.deleteScaling('missing', 1)).toThrow('not found');
@@ -60,7 +60,7 @@ it('blocks deletion with pending intent, then keeps Provisioning history after p
expect(() => scaling.deleteScaling(saved.id, saved.revision)).toThrow('outstanding Provisioning');
expect(scaling.getScalingPolicy(saved.id)?.mode).toBe('create-destroy');
const off = { ...settings, mode: 'off' };
const paused = scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
const paused = scaling.saveScaling(off, saved.id, saved.revision);
scaling.deleteScaling(saved.id, paused.revision);
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
expect(getDeployment(id)).toMatchObject({ deployment: { state: 'cancelled' }, sources: [expect.objectContaining({ kind: 'autoscaling', source_id: saved.id, label: 'Deleted autoscaling policy', href: null })] });
+15 -15
View File
@@ -49,11 +49,11 @@ it('applies targets and spare deficits independently to every map', () => {
it('persists a game rule, queues the selected map recipe, and protects every map', async () => {
const { db, scaling } = await setup();
db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES('other-key','t5','other-game')").run();
const preview = scaling.previewScaling(settings);
expect(preview.snapshot.keyPool).toEqual({ total: 1, available: 1 });
const saved = scaling.saveScaling(settings, undefined, undefined, preview.token);
expect(() => scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token)).toThrow();
expect(() => scaling.previewScaling({ ...settings, mapSlug: 'town' }, saved.id, saved.revision)).toThrow('cannot change');
const evaluation = scaling.evaluateScaling(settings);
expect(evaluation.snapshot.keyPool).toEqual({ total: 1, available: 1 });
const saved = scaling.saveScaling(settings);
expect(() => scaling.saveScaling(settings)).toThrow();
expect(() => scaling.evaluateScaling({ ...settings, mapSlug: 'town' }, saved.id, saved.revision)).toThrow('cannot change');
expect(await scaling.runAutoscaling()).toBe(true);
const job = db().prepare('SELECT deployment_id,map_slug,server FROM server_creation_jobs').get() as { deployment_id: string; map_slug: string; server: string };
expect(job).toMatchObject({ map_slug: 'transit', server: 't6_zombie_transit_1' });
@@ -65,24 +65,24 @@ it('persists a game rule, queues the selected map recipe, and protects every map
for (const map of maps) expect(scalingOwner('t6', map.slug)).toBe(saved.id);
expect(scalingOwner('t5', 'theater')).toBeNull();
expect(scalingOwnershipBlocker('t6_zombie_farm_1')).toContain(saved.id);
expect(scaling.previewScaling({ ...settings, mapSlug: 'town' }).blockers.join(' ')).toContain('overlapping');
expect(scaling.evaluateScaling({ ...settings, mapSlug: 'town' }).blockers.join(' ')).toContain('overlapping');
const off = { ...settings, mode: 'off' };
scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
scaling.saveScaling(off, saved.id, saved.revision);
expect(scalingOwner('t6', 'town')).toBeNull();
const mapSettings = { ...settings, mapSlug: 'town' };
const mapPolicy = scaling.saveScaling(mapSettings, undefined, undefined, scaling.previewScaling(mapSettings).token);
const mapPolicy = scaling.saveScaling(mapSettings);
const current = scaling.getScalingPolicy(saved.id)!;
expect(scaling.previewScaling(settings, saved.id, current.revision).blockers.join(' ')).toContain(mapPolicy.id);
expect(scaling.evaluateScaling(settings, saved.id, current.revision).blockers.join(' ')).toContain(mapPolicy.id);
});
it('holds game-wide growth when its shared pool is exhausted by another map', async () => {
const { db, scaling } = await setup();
db().prepare("INSERT INTO server_key_leases(server_id,key_id) VALUES('t6_zombie_town_2','key-1')").run();
const preview = scaling.previewScaling(settings);
expect(preview.snapshot.keyPool).toEqual({ total: 1, available: 0 });
expect(preview.blockers).toEqual([]);
expect(preview.decision).toMatchObject({ action: 'wait', reason: expect.stringContaining('keys are in use') });
scaling.saveScaling(settings, undefined, undefined, preview.token);
const evaluation = scaling.evaluateScaling(settings);
expect(evaluation.snapshot.keyPool).toEqual({ total: 1, available: 0 });
expect(evaluation.blockers).toEqual([]);
expect(evaluation.decision).toMatchObject({ action: 'wait', reason: expect.stringContaining('keys are in use') });
scaling.saveScaling(settings);
expect(await scaling.runAutoscaling()).toBe(false);
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 0 });
});
@@ -96,7 +96,7 @@ it('moves to the next map as soon as the prior creation settles, without waiting
vi.useFakeTimers({ toFake: ['Date'] });
try {
const at = new Date().toISOString();
scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
scaling.saveScaling(settings);
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');
+26 -26
View File
@@ -17,12 +17,12 @@ async function setup() {
it.each(['off', 'create-destroy'])('saves a %s policy without preallocating keys for its maximum', async mode => {
const { db, scaling } = await setup();
const input = { ...settings, mode, maximum: 20 };
const preview = scaling.previewScaling(input);
expect(preview.blockers).toEqual([]);
expect(preview.decision.action).toBe('wait');
if (mode !== 'off') expect(preview.decision.reason).toContain('No Plutonium keys');
const evaluation = scaling.evaluateScaling(input);
expect(evaluation.blockers).toEqual([]);
expect(evaluation.decision.action).toBe('wait');
if (mode !== 'off') expect(evaluation.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 }) }) });
const response = await POST({ request: new Request('http://localhost/api/autoscaling', { method: 'POST', body: JSON.stringify({ action: 'save', settings: input }) }) });
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);
@@ -32,36 +32,36 @@ it('uses any free key for the selected game independently of its label or map',
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.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);
const evaluation = scaling.evaluateScaling(settings);
expect(evaluation.snapshot.keyPool).toEqual({ total: 1, available: 1 });
expect(evaluation.decision).toMatchObject({ action: 'create', server: 't6_zombie_town_1' });
expect(scaling.saveScaling(settings).maximum).toBe(2);
});
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(preview.snapshot.keyPool).toEqual({ total: 1, available: 1 });
expect(scaling.saveScaling(input, undefined, undefined, preview.token).mapSlug).toBe('*');
const evaluation = scaling.evaluateScaling(input);
expect(evaluation.blockers).toEqual([]);
expect(evaluation.snapshot.keyPool).toEqual({ total: 1, available: 1 });
expect(scaling.saveScaling(input).mapSlug).toBe('*');
});
it('rechecks game pool deletion and reservations after preview', async () => {
it('saves against the current key pool without an intermediate confirmation', 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');
const saved = scaling.saveScaling(settings);
expect(scaling.scalingReport().policies[0].decision.reason).toContain('No Plutonium keys');
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);
expect(scaling.saveScaling({ ...settings, maximum: 3 }, saved.id, saved.revision).maximum).toBe(3);
const view = scaling.scalingReport().policies[0];
expect(view.decision.reason).toContain('keys are in use');
expect(view.snapshot.members).toEqual([]);
expect(await scaling.runAutoscaling()).toBe(false);
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 0 });
});
it('shares capacity between live map policies and admits the job holding the last key', async () => {
@@ -70,7 +70,7 @@ it('shares capacity between live map policies and admits the job holding the las
saveServerKey('t5', 'other-game-key');
for (const mapSlug of ['town', 'farm']) {
const input = { ...settings, mapSlug, mode: 'create-destroy' };
scaling.saveScaling(input, undefined, undefined, scaling.previewScaling(input).token);
scaling.saveScaling(input);
}
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 }[];
@@ -91,7 +91,7 @@ it('reserves a lobby identity for an unsettled failed launch with a retained key
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' });
const evaluation = scaling.evaluateScaling(settings);
expect(evaluation.snapshot.reservedNames).toEqual(['t6_zombie_town_1']);
expect(evaluation.decision).toMatchObject({ action: 'create', server: 't6_zombie_town_2' });
});
+8 -8
View File
@@ -11,7 +11,7 @@ async function setup() {
const { db } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
for (const n of [3, 8]) db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES(?,'t6',?)").run(`key-${n}`, `key-${n}`);
const saved = scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
const saved = scaling.saveScaling(settings);
return { db, scaling, saved };
}
@@ -34,15 +34,15 @@ it('withdraws an unsubmitted create and returns its game key when the policy pau
const { db, scaling, saved } = await setup();
await scaling.runAutoscaling();
const off = { ...settings, mode: 'off' };
scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
scaling.saveScaling(off, saved.id, saved.revision);
expect(db().prepare("SELECT state FROM server_deployments WHERE action='create'").get()).toEqual({ state: 'cancelled' });
expect(db().prepare('SELECT owns_map FROM autoscaling_policies').get()).toEqual({ owns_map: 0 });
expect(db().prepare('SELECT COUNT(*) AS n FROM server_key_leases').get()).toEqual({ n: 0 });
db().prepare('DELETE FROM server_keys').run();
const current = scaling.getScalingPolicy(saved.id)!;
const preview = scaling.previewScaling(settings, saved.id, current.revision);
expect(preview.blockers).toEqual([]);
expect(preview.snapshot.keyPool).toEqual({ total: 0, available: 0 });
const evaluation = scaling.evaluateScaling(settings, saved.id, current.revision);
expect(evaluation.blockers).toEqual([]);
expect(evaluation.snapshot.keyPool).toEqual({ total: 0, available: 0 });
});
it('blocks manual lifecycle and collection creation, including stale previews', async () => {
@@ -56,10 +56,10 @@ it('blocks manual lifecycle and collection creation, including stale previews',
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const blueprint = saveBlueprint({ name: 'Town', entries: [{ game: 't6', mapSlug: 'town', count: 1 }] });
expect(previewCollection(blueprint.id).blockers.join(' ')).toContain('Autoscaling');
const off = { ...settings, mode: 'off' }; scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
const off = { ...settings, mode: 'off' }; scaling.saveScaling(off, saved.id, saved.revision);
const preview = previewCollection(blueprint.id);
const current = scaling.getScalingPolicy(saved.id)!;
scaling.saveScaling(settings, saved.id, current.revision, scaling.previewScaling(settings, saved.id, current.revision).token);
scaling.saveScaling(settings, saved.id, current.revision);
expect(() => queueCollection({ previewId: preview.id })).toThrow('Autoscaling');
expect(db().prepare('SELECT COUNT(*) AS n FROM server_deployments').get()).toEqual({ n: 0 });
});
@@ -86,7 +86,7 @@ it('exact matching cannot remove an owned map absent from the collection', async
db().prepare("INSERT INTO servers(name,game,map,map_slug,container_state,container_checked_at,last_seen_at,last_players) VALUES(?,'t6','zm_transit','town','running',?,?,0)").run(town, at, at);
for (const n of [1, 2]) db().prepare("INSERT INTO server_keys(id,game,pluto_key) VALUES(?,'t6',?)").run(`key-${n}`, `key-${n}`);
const scaling = await import('../../src/lib/server/autoscaling/index.js');
scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
scaling.saveScaling(settings);
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const collection = saveBlueprint({ name: 'Moon only', entries: [{ game: 't5', mapSlug: 'moon', count: 1 }] });
+40 -20
View File
@@ -16,8 +16,7 @@ const settings = { game: 't6', mapSlug: 'town', maximum: 4, mode: 'create-destro
it('persists deduplicated paused decisions across restart without provisioning or reservations', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
const preview = scaling.previewScaling(settings);
const saved = scaling.saveScaling(settings, undefined, undefined, preview.token);
const saved = scaling.saveScaling(settings);
scaling.setScalingGlobalPause(true);
await scaling.runAutoscaling(); await scaling.runAutoscaling();
closeDb();
@@ -30,25 +29,47 @@ it('persists deduplicated paused decisions across restart without provisioning o
for (const table of ['server_deployments', 'native_creation_intents', 'native_instances']) expect(db().prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).toEqual({ n: 0 });
});
it('rejects duplicate policies, stale edits and changed previews', async () => {
it('saves edits directly while rejecting duplicate policies and stale revisions', async () => {
const scaling = await import('../../src/lib/server/autoscaling/index.js');
const preview = scaling.previewScaling(settings);
const saved = scaling.saveScaling(settings, undefined, undefined, preview.token);
expect(() => scaling.saveScaling(settings, undefined, undefined, preview.token)).toThrow('overlapping policy');
expect(() => scaling.previewScaling(settings, saved.id, 0)).toThrow('changed');
expect(() => scaling.saveScaling({ ...settings, maximum: 5 }, saved.id, saved.revision, preview.token)).toThrow('preview changed');
const saved = scaling.saveScaling(settings);
expect(() => scaling.saveScaling(settings)).toThrow('overlapping policy');
expect(() => scaling.saveScaling(settings, saved.id, 0)).toThrow('changed');
const edited = scaling.saveScaling({ ...settings, maximum: 5 }, saved.id, saved.revision);
expect(edited).toMatchObject({ maximum: 5, revision: saved.revision + 1 });
expect(() => scaling.saveScaling(settings, saved.id, saved.revision)).toThrow('changed');
expect(scaling.getScalingPolicy(saved.id)).toEqual(edited);
});
it('checks current fleet blockers during save and leaves rejected edits unchanged', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const beforeKeyLoss = scaling.previewScaling(settings, saved.id, saved.revision);
db().prepare("DELETE FROM server_keys WHERE id='key-10'").run();
expect(() => scaling.saveScaling(settings, saved.id, saved.revision, beforeKeyLoss.token)).toThrow('preview changed');
expect(scaling.saveScaling(settings, saved.id, saved.revision, scaling.previewScaling(settings, saved.id, saved.revision).token).maximum).toBe(4);
const scaling = await import('../../src/lib/server/autoscaling/index.js');
const saved = scaling.saveScaling(settings);
db().prepare("INSERT INTO servers(name,game,map_slug) VALUES('unknown-map','t6','unknown')").run();
expect(() => scaling.saveScaling({ ...settings, maximum: 5 }, saved.id, saved.revision)).toThrow('ambiguous map identity');
expect(scaling.getScalingPolicy(saved.id)).toEqual(saved);
});
it('creates and updates policies through the API without a preview or token', async () => {
const { POST } = await import('../../src/routes/api/autoscaling/+server.js');
const post = (body: object) => POST({ request: new Request('http://localhost/api/autoscaling', {
method: 'POST', body: JSON.stringify({ action: 'save', ...body })
}) });
const response = await post({ settings });
expect(response.status).toBe(200);
const { policy } = await response.json();
const update = { settings: { ...settings, maximum: 3 }, id: policy.id, revision: policy.revision };
const changed = await post(update);
expect(changed.status).toBe(200);
expect((await changed.json()).policy).toMatchObject({ maximum: 3, revision: policy.revision + 1 });
expect((await post(update)).status).toBe(409);
expect((await post({ action: 'preview', settings })).status).toBe(400);
});
it('pauses evaluations and prunes expired observations without deleting current history', async () => {
const scaling = await import('../../src/lib/server/autoscaling/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('DELETE FROM server_keys').run();
scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
scaling.saveScaling(settings);
await scaling.runAutoscaling();
scaling.setScalingGlobalPause(true); await scaling.runAutoscaling();
expect(scaling.scalingReport().policies[0].decision.reason).toContain('globally paused');
@@ -67,7 +88,7 @@ it('uses fleet authorization for page, reads, and mutations', () => {
it.each(['dry-run', 'create-only'])('rejects removed %s mode through the API', async mode => {
const { POST } = await import('../../src/routes/api/autoscaling/+server.js');
for (const action of ['preview', 'save']) {
for (const action of ['save']) {
const response = await POST({ request: new Request('http://localhost/api/autoscaling', {
method: 'POST', body: JSON.stringify({ action, settings: { ...settings, mode } })
}) });
@@ -79,9 +100,9 @@ it.each(['dry-run', 'create-only'])('rejects removed %s mode through the API', a
it.each(['dry-run', 'create-only'])('turns saved %s policies off once on upgrade', async mode => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
const saved = scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
const saved = scaling.saveScaling(settings);
const otherSettings = { ...settings, mapSlug: 'farm' };
const other = scaling.saveScaling(otherSettings, undefined, undefined, scaling.previewScaling(otherSettings).token);
const other = scaling.saveScaling(otherSettings);
db().prepare('UPDATE autoscaling_policies SET mode=?,settings=json_set(settings,\'$.mode\',?) WHERE id=?').run(mode, mode, saved.id);
closeDb();
const migrated = scaling.getScalingPolicy(saved.id)!;
@@ -92,15 +113,14 @@ it.each(['dry-run', 'create-only'])('turns saved %s policies off once on upgrade
expect(scaling.getScalingPolicy(other.id)).toEqual(other);
closeDb();
expect(scaling.getScalingPolicy(saved.id)).toEqual(migrated);
const resumed = scaling.saveScaling(settings, migrated.id, migrated.revision,
scaling.previewScaling(settings, migrated.id, migrated.revision).token);
const resumed = scaling.saveScaling(settings, migrated.id, migrated.revision);
expect(resumed).toMatchObject({ mode: 'create-destroy', pausedReason: null });
});
it.each([false, true])('reconciles removed-mode creation while preserving submitted work: %s', async submitted => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
const saved = scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
const saved = scaling.saveScaling(settings);
await scaling.runAutoscaling();
const { operation_id: id } = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string };
db().prepare("UPDATE autoscaling_policies SET mode='create-only',settings=json_set(settings,'$.mode','create-only') WHERE id=?").run(saved.id);
@@ -119,7 +139,7 @@ it('removes saved cooldowns on upgrade without changing the empty hold or pausin
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);
const saved = scaling.saveScaling(input);
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);
+2 -2
View File
@@ -33,7 +33,7 @@ async function setup(gameWide = false) {
db().prepare('UPDATE ec_hosts SET last_heartbeat=?').run(at);
};
fresh(0);
const saved = scaling.saveScaling(policySettings, undefined, undefined, scaling.previewScaling(policySettings).token);
const saved = scaling.saveScaling(policySettings);
for (const offset of [0, 30_000, 60_000]) { fresh(offset); await scaling.runAutoscaling(); }
const intent = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string };
expect(intent?.operation_id).toBeTruthy();
@@ -84,7 +84,7 @@ it('revokes a delivered removal on policy pause without claiming it was undone',
const s = await setup(); const command = await s.dispatch();
s.db().prepare('UPDATE ec_host_operations SET last_delivered_at=? WHERE id=?').run(new Date().toISOString(), command.id);
const off = { ...settings, mode: 'off' };
s.scaling.saveScaling(off, s.policy.id, s.policy.revision, s.scaling.previewScaling(off, s.policy.id, s.policy.revision).token);
s.scaling.saveScaling(off, s.policy.id, s.policy.revision);
expect(s.authorization.claimRemovalAuthorization(s.hostId, command.id, command.payload).authorized).toBe(false);
expect(s.db().prepare('SELECT owns_map FROM autoscaling_policies').get()).toEqual({ owns_map: 1 });
expect(s.db().prepare('SELECT state FROM server_deployments WHERE id=?').get(s.operationId)).toEqual({ state: 'running' });