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
@@ -2,7 +2,7 @@
Open **Fleet → Autoscaling** to keep empty lobbies available for new groups. Policies apply to one map variant or every map in a game. A game-wide policy applies the same minimum, maximum, and spare targets independently to each map, including all variants. A partly occupied Zombies lobby is not an empty spare, even when it still has player slots.
Autoscaling is opt-in. Installing the feature creates no policies and changes no lobby counts. Review the activation preview before turning autoscaling On.
Autoscaling is opt-in. Installing the feature creates no policies and changes no lobby counts. Choose On and save a policy to enable autoscaling.
T5 game-wide policies cover the ten standard Zombies maps. Dead Ops Arcade is not supported.
@@ -10,14 +10,14 @@ T5 game-wide policies cover the ten standard Zombies maps. Dead Ops Arcade is no
Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Off, with a minimum of one running lobby, one empty spare, and a 15-minute empty interval. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum.
Choose **Preview changes** to inspect the existing lobbies, game key availability, target, proposed action, and blockers. **Save reviewed policy** checks the policy revision and current fleet again. If the preview changed, review a new preview before saving.
Choose **Save policy** to apply changes directly. Saving checks the current fleet, policy revision, ownership conflicts, and host readiness. If saving is blocked, the form keeps your settings and shows the reason. Saved policies show their lobbies, key availability, current decision, and blockers.
| Autoscaling | Behavior |
| --- | --- |
| Off | No new actions. Unsubmitted work is withdrawn and delivered operations remain tracked until settled. |
| On | Adds lobbies and removes surplus lobbies after continuous empty evidence and all removal checks pass. |
Plutonium keys belong to a game pool shared by every map, manual launch, collection, beta, and autoscaling policy for that game. An eligible creation uses the lowest available lobby number from 1–20 and reserves any free game key atomically. Key labels do not select a map or lobby. The preview shows saved and available game keys; policy maxima can exceed pool size, and a policy can be saved before any keys are added. An empty or exhausted pool holds new growth with a clear reason; it does not prevent surplus removal. Queued launches reserve their key, stopped instances retain it, and confirmed cleanup returns it to the pool. Failed or uncertain launches keep their reservation until safely reconciled. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host.
Plutonium keys belong to a game pool shared by every map, manual launch, collection, beta, and autoscaling policy for that game. An eligible creation uses the lowest available lobby number from 1–20 and reserves any free game key atomically. Key labels do not select a map or lobby. The policy shows saved and available game keys; policy maxima can exceed pool size, and a policy can be saved before any keys are added. An empty or exhausted pool holds new growth with a clear reason; it does not prevent surplus removal. Queued launches reserve their key, stopped instances retain it, and confirmed cleanup returns it to the pool. Failed or uncertain launches keep their reservation until safely reconciled. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host.
The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. The controller evaluates policies every 15 seconds and requests at most one unresolved operation per policy. Game-wide policies allow one outstanding operation across their maps, prioritizing additions before removals. Once an operation settles, the next evaluation can act immediately on another eligible lobby. Surplus lobbies must still satisfy the configured continuous empty duration before removal. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins.
@@ -54,7 +54,7 @@ Deletion requires the current policy revision and no unsettled autoscaling opera
The production observation period and live pilot have not been run as part of implementation. Begin them on the operator-selected maps after deploying the controller and compatible agents.
1. Select one or two representative maps and record the baseline lineup, keys, versions, host CPU and memory, and player-hours.
2. Preview On after resolving blockers and checking agent capability. Review the affected lobbies, proposed action, minimum, maximum, and spare target before saving.
2. Select On after resolving blockers and checking agent capability. Set the minimum, maximum, and spare target, then choose Save policy.
3. Monitor request-to-ready time from Provisioning, browser visibility, successful external joins, empty lobby-hours, spare deficits, and removals. Increase the spare target if creation latency leaves new groups without a suitable lobby.
4. Expand one map at a time when spare availability and gameplay meet your requirements. Pause if they regress.
@@ -66,6 +66,6 @@ Measurement totals exclude collection gaps. Request-to-verified-readiness summar
`GET /api/autoscaling` returns policies, current evidence, recent decisions, measurements, and global pause state. Viewers may read it; mutations require an operator or administrator, using the existing authentication and origin checks.
`POST /api/autoscaling` accepts `preview`, `save`, `delete`, or `global-pause`. Delete requires `id` and the current `revision`. Preview/save requests contain `settings`, plus `id` and `revision` when editing. Use `mapSlug: '*'` for every map in the selected game; existing map-specific requests are unchanged. Scope cannot change when editing. Save also requires the preview `token`. Global pause uses a boolean `paused` field. Agent removal claims use a separate host-authenticated endpoint and are bound to the exact command, host, instance generation, policy revision, and expiry.
`POST /api/autoscaling` accepts `save`, `delete`, or `global-pause`. Delete requires `id` and the current `revision`. Save requests contain `settings`, plus `id` and `revision` when editing. Use `mapSlug: '*'` for every map in the selected game; existing map-specific requests are unchanged. Scope cannot change when editing. Save validates the current fleet atomically with the policy update; no preview or token is required. Global pause uses a boolean `paused` field. Agent removal claims use a separate host-authenticated endpoint and are bound to the exact command, host, instance generation, policy revision, and expiry.
Shared contracts live in `src/lib/autoscaling.ts`; the evaluator, snapshots, persistence, ownership, and authorization live in `src/lib/server/autoscaling/`. Provisioning remains the only lifecycle executor. See the [approved implementation plan](../plans/lobby-autoscaling-plan.md) for the design and rollout criteria.
+1 -1
View File
@@ -10,7 +10,7 @@ Introduce an opt-in policy for each game and map variant that keeps a small numb
Start with Create/Destroy on existing native hosts. Run the evaluator in dry run for two weeks, then enable creation for a small pilot before enabling removal. Host acquisition, VM shutdown, Stop/Start pooling, scheduled demand prediction, and waking servers from incoming packets are outside the first release.
Update October 7, 2026: Dry run and Create only have been removed. Current policies support Off and On, defaulting to Off. Existing policies in removed modes switch to Off on upgrade. Action cooldowns have also been removed; policies retain the continuous empty duration before removal and one outstanding operation at a time. The original implementation stages below record the initial design; follow the current [usage and pilot procedure](../guides/autoscaling.md) for operation.
Update October 7, 2026: Dry run and Create only have been removed. Current policies support Off and On, defaulting to Off. Existing policies in removed modes switch to Off on upgrade. Action cooldowns have also been removed; policies retain the continuous empty duration before removal and one outstanding operation at a time. Policies now save directly with server-side validation; no preview or confirmation token is required. The original implementation stages below record the initial design; follow the current [usage and pilot procedure](../guides/autoscaling.md) for operation.
## Existing foundations and required extensions
+2 -2
View File
@@ -25,9 +25,9 @@ export interface ScalingDecision {
action: 'create' | 'destroy' | 'wait'; reason: string; server: string | null;
desired: number; deficit: number; occupied: number; empty: number; pending: number; unknown: number;
}
export interface ScalingPreview { policy: ScalingPolicy; snapshot: ScalingSnapshot; decision: ScalingDecision; token: string; blockers: string[] }
export interface ScalingEvaluation { policy: ScalingPolicy; snapshot: ScalingSnapshot; decision: ScalingDecision; blockers: string[] }
export interface ScalingHistory { id: string; at: number; lastSeenAt: number; decision: ScalingDecision; operationId: string | null; operationState: string | null }
export interface ScalingView extends ScalingPreview {
export interface ScalingView extends ScalingEvaluation {
history: ScalingHistory[]; lastEvaluatedAt: number | null;
totals: { observedMinutes: number; emptyLobbyHours: number; deficitMinutes: number; unknownMinutes: number };
operations: { creates: number; removals: number; cancelled: number; failed: number; readinessSamples: number; readinessMeanSeconds: number | null; readinessMaxSeconds: number | null };
+10 -13
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
import { liveScaling, scalingSettings, type ScalingPolicy, type ScalingPreview, type ScalingReport, type ScalingView } from '../../autoscaling.js';
import { liveScaling, scalingSettings, type ScalingPolicy, type ScalingEvaluation, type ScalingReport, type ScalingView } from '../../autoscaling.js';
import { emptyObservation, planScaling } from './planner.js';
import { parseServerName } from '../../map-aliases.js';
import { scalingSnapshot } from './snapshot.js';
@@ -21,12 +21,12 @@ export const listScalingPolicies = (): ScalingPolicy[] => (db().prepare('SELECT
export function getScalingPolicy(id: string): ScalingPolicy | undefined { const row = db().prepare('SELECT * FROM autoscaling_policies WHERE id=?').get(id) as PolicyRow | undefined; return row && present(row); }
export const scalingGloballyPaused = (): boolean => (db().prepare("SELECT value FROM settings WHERE key='autoscaling_paused'").get() as { value: string } | undefined)?.value === '1';
export function previewScaling(input: unknown, id?: string, revision?: number): ScalingPreview {
export function evaluateScaling(input: unknown, id?: string, revision?: number): ScalingEvaluation {
const settings = scalingSettings(input), existing = id ? getScalingPolicy(id) : undefined;
if (id && !existing) throw new ScalingError('Policy not found.', 404);
if (existing && existing.revision !== revision) throw new ScalingError('Policy changed. Refresh before editing.', 409);
if (existing && (existing.game !== settings.game || existing.mapSlug !== settings.mapSlug)) throw new ScalingError('A policy cannot change its game or map.');
const policy: ScalingPolicy = { ...settings, id: existing?.id ?? 'preview', revision: existing?.revision ?? 0,
const policy: ScalingPolicy = { ...settings, id: existing?.id ?? 'new-policy', revision: existing?.revision ?? 0,
pausedReason: null, updatedAt: existing?.updatedAt ?? '' };
const snapshot = scalingSnapshot(policy);
const blockers: string[] = [];
@@ -43,17 +43,14 @@ export function previewScaling(input: unknown, id?: string, revision?: number):
try { nativeLaunchSelection({ automatic: true }, policy.game); } catch (error) { blockers.push((error as Error).message); }
}
const decision = planScaling(policy, snapshot, Date.now());
const token = createHash('sha256').update(JSON.stringify({ settings, revision: policy.revision, globalPaused: scalingGloballyPaused(),
members: snapshot.members.map(m => [m.name, m.identity, m.state]), keyPool: snapshot.keyPool, reservations: snapshot.reservedNames.sort(), blockers: snapshot.blockers, decision })).digest('hex');
return { policy, snapshot, decision, token, blockers };
return { policy, snapshot, decision, blockers };
}
export function saveScaling(input: unknown, id?: string, revision?: number, token?: string): ScalingPolicy {
export function saveScaling(input: unknown, id?: string, revision?: number): ScalingPolicy {
return db().transaction(() => {
const preview = previewScaling(input, id, revision);
if (!token || preview.token !== token) throw new ScalingError('The policy preview changed. Review it again before saving.', 409);
if (preview.blockers.length) throw new ScalingError(preview.blockers[0]);
const policy = preview.policy, settings = scalingSettings(policy), nextId = id ?? randomUUID(), at = new Date().toISOString();
const evaluation = evaluateScaling(input, id, revision);
if (evaluation.blockers.length) throw new ScalingError(evaluation.blockers[0]);
const policy = evaluation.policy, settings = scalingSettings(policy), nextId = id ?? randomUUID(), at = new Date().toISOString();
if (id) revokePolicyRemovals(id);
if (!id && db().prepare('SELECT 1 FROM autoscaling_policies WHERE game=? AND map_slug=?').get(policy.game, policy.mapSlug)) throw new ScalingError('A policy already exists for this scope.', 409);
db().prepare(`INSERT INTO autoscaling_policies(id,game,map_slug,settings,mode,updated_at) VALUES(?,?,?,?,?,?)
@@ -61,7 +58,7 @@ export function saveScaling(input: unknown, id?: string, revision?: number, toke
// An edit starts a new observation interval; earlier inactivity is not authorization.
db().prepare('DELETE FROM autoscaling_members WHERE policy_id=?').run(nextId);
db().prepare('UPDATE autoscaling_policies SET owns_map=CASE WHEN ? THEN 1 ELSE owns_map END WHERE id=?').run(liveScaling(policy.mode) ? 1 : 0, nextId);
if (liveScaling(policy.mode)) for (const member of preview.snapshot.members) db().prepare('INSERT INTO autoscaling_members(policy_id,server,identity,observation,adopted) VALUES(?,?,?,?,1)')
if (liveScaling(policy.mode)) for (const member of evaluation.snapshot.members) db().prepare('INSERT INTO autoscaling_members(policy_id,server,identity,observation,adopted) VALUES(?,?,?,?,1)')
.run(nextId, member.name, member.identity, JSON.stringify({ identity: member.identity, observedAt: member.observedAt, emptySince: null, evaluatedAt: Date.now() }));
reconcileScaling(getScalingPolicy(nextId)!);
return getScalingPolicy(nextId)!;
@@ -213,7 +210,7 @@ export function scalingReport(): ScalingReport {
MAX(CASE WHEN d.action='create' AND d.state IN ('done','warning') THEN (julianday(d.finished_at)-julianday(d.started_at))*86400 END) AS readinessMaxSeconds
FROM server_deployments d JOIN provisioning_sources s ON s.operation_id=d.id
WHERE s.kind='autoscaling' AND s.source_id=? AND d.started_at>=?`).get(policy.id, new Date(Date.now() - 31 * 86400_000).toISOString()) as ScalingView['operations'];
return { policy, snapshot, decision, token: '', blockers: [], history, lastEvaluatedAt: history[0]?.lastSeenAt ?? null, totals, operations, unsettled, ownsMap };
return { policy, snapshot, decision, blockers: [], history, lastEvaluatedAt: history[0]?.lastSeenAt ?? null, totals, operations, unsettled, ownsMap };
});
return { policies, globalPaused: scalingGloballyPaused(), liveAvailable: true, removalAvailable: true };
}
+2 -3
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { previewScaling, saveScaling, deleteScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js';
import { saveScaling, deleteScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js';
export function GET() { return json(scalingReport()); }
export async function POST({ request }: { request: Request }) {
@@ -7,8 +7,7 @@ export async function POST({ request }: { request: Request }) {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ScalingError('Provide a policy action.');
if (body.id !== undefined && typeof body.id !== 'string') throw new ScalingError('Invalid policy identity.');
if (body.action === 'preview') return json(previewScaling(body.settings, body.id, body.revision));
if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision, body.token) });
if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision) });
if (body.action === 'delete') {
if (typeof body.id !== 'string' || !body.id || !Number.isInteger(body.revision) || body.revision < 1) throw new ScalingError('Provide the policy identity and current revision.');
deleteScaling(body.id, body.revision);
+11 -24
View File
@@ -7,7 +7,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js';
import type { ScalingSettings, ScalingPolicy, ScalingPreview, ScalingReport } from '$lib/autoscaling.js';
import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js';
let { data }: { data: import('./$types').PageData } = $props();
let snapshot = $state<ScalingReport | null>(null);
const report = $derived(snapshot ?? data.autoscaling);
@@ -16,7 +16,6 @@
let settings = $state<ScalingSettings>(defaults());
let editing = $state<ScalingPolicy | null>(null);
let editorOpen = $state(false);
let preview = $state<ScalingPreview | null>(null);
let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state('');
let generation = 0;
const modeLabel = (mode: string) => ({ off: 'Off', 'create-destroy': 'On' })[mode] ?? mode;
@@ -32,7 +31,7 @@
onMount(() => pollVisible(refresh, 15_000, false));
function edit(policy: ScalingPolicy | null) {
editing = policy; settings = policy ? { ...policy } : defaults();
preview = null; actionError = ''; notice = ''; editorOpen = true;
actionError = ''; notice = ''; editorOpen = true;
}
async function post(body: object) {
const response = await fetch('/api/autoscaling', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
@@ -40,13 +39,12 @@
if (!response.ok) throw new Error(result.error ?? 'Policy action failed.');
return result;
}
async function submit(action: 'preview' | 'save') {
async function submit() {
busy = true; actionError = ''; notice = ''; ++generation;
try {
const result = await post({ action, settings, id: editing?.id, revision: editing?.revision, token: preview?.token });
if (action === 'preview') preview = result;
else { notice = 'Policy saved. Evaluation runs every 15 seconds.'; editorOpen = false; preview = null; await refresh(); }
} catch (error) { actionError = (error as Error).message; preview = null; }
await post({ action: 'save', settings, id: editing?.id, revision: editing?.revision });
notice = 'Policy saved. Evaluation runs every 15 seconds.'; editorOpen = false; await refresh();
} catch (error) { actionError = (error as Error).message; }
finally { busy = false; }
}
async function remove(policy: ScalingPolicy) {
@@ -55,7 +53,7 @@
busy = true; actionError = ''; notice = ''; ++generation;
try {
await post({ action: 'delete', id: policy.id, revision: policy.revision });
if (editing?.id === policy.id) { editing = null; editorOpen = false; preview = null; }
if (editing?.id === policy.id) { editing = null; editorOpen = false; }
snapshot = { ...report, policies: report.policies.filter(view => view.policy.id !== policy.id) };
notice = 'Policy deleted.';
await refresh();
@@ -82,7 +80,7 @@
{#if editorOpen && canOperate}
<section class="panel" aria-labelledby="policy-editor">
<h2 id="policy-editor">{editing ? 'Edit policy' : 'New policy'}</h2>
<form onsubmit={event => { event.preventDefault(); void submit('preview'); }} oninput={() => preview = null} onchange={() => preview = null}>
<form onsubmit={event => { event.preventDefault(); void submit(); }}>
<fieldset disabled={busy}>
<div class="fields">
<label>Game<select bind:value={settings.game} disabled={!!editing} onchange={() => { if (settings.mapSlug !== '*') settings.mapSlug = zombieMaps(settings.game)[0].slug; }}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
@@ -95,20 +93,9 @@
</div>
{#if settings.mapSlug === '*'}<p>Targets apply separately to every map, including all variants. All maps share this game’s key pool. Actions run one at a time across the game.</p>{/if}
<p>Each new lobby needs an available key from the game pool. Creation waits when the pool is exhausted; keys return after confirmed removal.</p>
<div class="actions"><Button type="submit" disabled={busy}>Preview changes</Button><Button onclick={() => editorOpen = false}>Close editor</Button></div>
<div class="actions"><Button type="submit" variant="primary" disabled={busy}>Save policy</Button><Button onclick={() => editorOpen = false}>Close editor</Button></div>
</fieldset>
</form>
{#if preview}
<div class="preview" aria-live="polite">
<h3>Preview · {modeLabel(preview.policy.mode)}</h3>
<p>Target: {preview.decision.desired} running lobbies{preview.policy.mapSlug === '*' ? ' across all maps' : ''}. {preview.snapshot.keyPool.available} of {preview.snapshot.keyPool.total} game keys available. {preview.snapshot.members.length} existing or pending lobbies.</p>
<p>{preview.decision.action === 'wait' ? 'Wait' : `${preview.decision.action === 'create' ? 'Create' : 'Remove'} ${preview.decision.server}`}: {preview.decision.reason}</p>
{#if preview.policy.mode === 'create-destroy'}<p>Saving gives this policy exclusive control of the selected maps' lobby counts. Pause it before launching a competing collection or manual lifecycle action.</p>{/if}
{#if preview.snapshot.members.length}<ul>{#each preview.snapshot.members as member}<li>{member.name} · {member.state}{member.blocker ? ` · ${member.blocker}` : ''}</li>{/each}</ul>{/if}
{#each [...preview.blockers, ...preview.snapshot.blockers] as blocker}<p>{blocker}</p>{/each}
<Button variant="primary" disabled={busy || preview.blockers.length > 0} onclick={() => submit('save')}>Save reviewed policy</Button>
</div>
{/if}
</section>
{/if}
{#if !report.policies.length}<p class="panel">No autoscaling policies. Add a policy to manage empty lobbies for a map or a whole game.</p>{/if}
@@ -136,13 +123,13 @@
.page { max-width: 1200px; margin: 0 auto; padding: 24px; }
.panel { margin: 20px 0; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow-wrap: anywhere; }
.heading, .actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; } h3 { font-size: var(--text-md); }
h2 { font-size: var(--text-lg); margin: 0; }
.heading h2 { flex: 1 1 200px; } p, li { line-height: 1.6; } .muted { color: var(--text-secondary); font-size: var(--text-sm); }
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 16px; margin-bottom: 20px; }
label { display: flex; flex-direction: column; gap: 8px; font-size: var(--text-sm); }
input, select { padding: 10px; min-width: 0; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); font: inherit; }
fieldset { border: 0; padding: 0; margin: 16px 0; min-width: 0; }
.preview, .banner { padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.banner { padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
details { margin-top: 16px; } summary { cursor: pointer; } li { margin: 10px 0; }
@media (max-width: 500px) { .page { padding: 16px; } .panel { padding: 16px; } }
</style>
+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' });