Add autoscaling policy model and deterministic planner
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Lobby autoscaling implementation plan
|
||||
|
||||
Status: Proposed. October 6, 2026. No autoscaling behavior is implemented by this plan.
|
||||
Status: Approved October 6, 2026. Implementation is underway; live rollout remains gated on the dry run and pilot below.
|
||||
|
||||
Introduce an opt-in policy for each game and map variant that keeps a small number of empty, ready lobbies available. When the last spare fills, request another lobby through Provisioning. Remove surplus lobbies only after sustained, confirmed inactivity. This reduces idle hosting while preserving room for new groups, including when existing Zombies sessions have free player slots but are already well into a game.
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { zombieMapBySlug, type ZombieGame } from './map-aliases.js';
|
||||
|
||||
export const autoscalingModes = ['off', 'dry-run', 'create-only', 'create-destroy'] as const;
|
||||
export type AutoscalingMode = typeof autoscalingModes[number];
|
||||
export interface ScalingSettings {
|
||||
game: ZombieGame; mapSlug: string; mode: AutoscalingMode;
|
||||
minimum: number; maximum: number; spares: number; emptyMinutes: number; cooldownMinutes: number;
|
||||
}
|
||||
export interface ScalingPolicy extends ScalingSettings {
|
||||
id: string; revision: number; nextActionAt: number; pausedReason: string | null; updatedAt: string;
|
||||
}
|
||||
export interface ScalingMember {
|
||||
name: string; identity: string; state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected';
|
||||
players: number | null; observedAt: number | null; source: string | null;
|
||||
emptySince: number | null; blocker: string | null; operationId: string | null;
|
||||
}
|
||||
export interface ScalingSnapshot {
|
||||
members: ScalingMember[]; reservedNames: string[]; keyedNames: string[]; blockers: string[];
|
||||
}
|
||||
export interface ScalingDecision {
|
||||
action: 'create' | 'destroy' | 'wait'; reason: string; server: string | null;
|
||||
desired: number; occupied: number; empty: number; pending: number; unknown: number;
|
||||
}
|
||||
export const liveScaling = (mode: AutoscalingMode): boolean => mode === 'create-only' || mode === 'create-destroy';
|
||||
|
||||
export function scalingSettings(input: unknown): ScalingSettings {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Provide an autoscaling policy.');
|
||||
const value = input as Record<string, unknown>;
|
||||
const { game, mapSlug } = value;
|
||||
if ((game !== 't5' && game !== 't6') || typeof mapSlug !== 'string' || zombieMapBySlug(game, mapSlug)?.slug !== mapSlug) throw new Error('Choose a supported game and map variant.');
|
||||
const mode = value.mode ?? 'off';
|
||||
if (!autoscalingModes.includes(mode as AutoscalingMode)) throw new Error('Choose a valid autoscaling mode.');
|
||||
const integer = (key: string, fallback: number | undefined, max: number) => {
|
||||
const n = value[key] ?? fallback;
|
||||
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > max) throw new Error(`${key} must be an integer from 1 to ${max}.`);
|
||||
return n;
|
||||
};
|
||||
const minimum = integer('minimum', 1, 20), maximum = integer('maximum', undefined, 20), spares = integer('spares', 1, 20);
|
||||
if (minimum > maximum || spares > maximum) throw new Error('Minimum and spare target cannot exceed the maximum.');
|
||||
return { game, mapSlug, mode: mode as AutoscalingMode, minimum, maximum, spares,
|
||||
emptyMinutes: integer('emptyMinutes', 20, 1440), cooldownMinutes: integer('cooldownMinutes', 5, 1440) };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ScalingDecision, ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
|
||||
export interface EmptyObservation { identity: string; observedAt: number | null; emptySince: number | null; evaluatedAt: number }
|
||||
/** Only distinct, continuous evidence can advance an empty interval. */
|
||||
export function emptyObservation(member: ScalingMember, previous: EmptyObservation | undefined, now: number): EmptyObservation {
|
||||
const stamp = member.observedAt;
|
||||
const empty = member.state === 'empty' && stamp !== null && stamp <= now && now - stamp <= ONLINE_MAX_MS;
|
||||
const continuous = empty && previous?.identity === member.identity && previous.emptySince !== null
|
||||
&& previous.observedAt !== null && stamp >= previous.observedAt && stamp - previous.observedAt <= ONLINE_MAX_MS
|
||||
&& now >= previous.evaluatedAt && now - previous.evaluatedAt <= ONLINE_MAX_MS;
|
||||
return { identity: member.identity, observedAt: stamp, emptySince: empty ? continuous ? previous!.emptySince : stamp : null, evaluatedAt: now };
|
||||
}
|
||||
|
||||
export function planScaling(policy: ScalingPolicy, snapshot: ScalingSnapshot, now: number): ScalingDecision {
|
||||
const { members } = snapshot;
|
||||
const occupied = members.filter(m => m.state === 'occupied').length, empty = members.filter(m => m.state === 'empty').length;
|
||||
const pending = members.filter(m => m.state === 'creating' || m.state === 'removing').length;
|
||||
const unknown = members.filter(m => m.state === 'unknown' || m.state === 'stopped').length;
|
||||
const desired = Math.min(policy.maximum, Math.max(policy.minimum, occupied + policy.spares));
|
||||
const result = (action: ScalingDecision['action'], reason: string, server: string | null = null): ScalingDecision =>
|
||||
({ action, reason, server, desired, occupied, empty, pending, unknown });
|
||||
if (policy.mode === 'off') return result('wait', policy.pausedReason ?? 'Policy is off.');
|
||||
if (policy.pausedReason) return result('wait', policy.pausedReason);
|
||||
if (snapshot.blockers.length) return result('wait', snapshot.blockers[0]);
|
||||
const blocked = members.find(m => !['occupied', 'empty'].includes(m.state));
|
||||
if (blocked) return result('wait', blocked.blocker ?? `Waiting for ${blocked.name}: ${blocked.state}.`);
|
||||
if (members.some(m => m.observedAt === null || m.observedAt > now || now - m.observedAt > ONLINE_MAX_MS)) return result('wait', 'Waiting for fresh occupancy evidence.');
|
||||
if (policy.nextActionAt > now) return result('wait', 'Action cooldown is active.');
|
||||
if (occupied + empty < desired) {
|
||||
if (snapshot.reservedNames.length >= policy.maximum) return result('wait', 'Maximum lobby reservations reached.');
|
||||
const available = snapshot.keyedNames.filter(name => !snapshot.reservedNames.includes(name)).sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
||||
return available.length ? result('create', 'Replenish empty lobby capacity.', available[0]) : result('wait', 'No unused lobby identity has a saved key.');
|
||||
}
|
||||
if (occupied + empty > desired && empty > policy.spares && occupied + empty > policy.minimum) {
|
||||
const candidates = members.filter(m => m.state === 'empty' && m.emptySince !== null && m.observedAt! - m.emptySince >= policy.emptyMinutes * 60_000)
|
||||
.sort((a, b) => a.emptySince! - b.emptySince! || a.name.localeCompare(b.name, 'en', { numeric: true }));
|
||||
if (candidates.length) return result('destroy', 'Surplus lobby has remained empty.', candidates[0].name);
|
||||
return result('wait', 'Waiting for a continuous empty interval.');
|
||||
}
|
||||
return result('wait', empty < policy.spares ? 'Maximum reached; the empty spare target cannot be met.' : 'Empty spare target is satisfied.');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export function ensureAutoscalingSchema(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS autoscaling_policies (
|
||||
id TEXT PRIMARY KEY, game TEXT NOT NULL, map_slug TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1, settings TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'off', next_action_at INTEGER NOT NULL DEFAULT 0,
|
||||
paused_reason TEXT, owns_map INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL,
|
||||
UNIQUE(game,map_slug)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS autoscaling_members (
|
||||
policy_id TEXT NOT NULL REFERENCES autoscaling_policies(id), server TEXT NOT NULL,
|
||||
identity TEXT NOT NULL, observation TEXT NOT NULL, adopted INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(policy_id,server)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS autoscaling_decisions (
|
||||
id TEXT PRIMARY KEY, policy_id TEXT NOT NULL REFERENCES autoscaling_policies(id),
|
||||
revision INTEGER NOT NULL, evaluated_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL,
|
||||
fingerprint TEXT NOT NULL, decision TEXT NOT NULL, evidence TEXT NOT NULL,
|
||||
operation_id TEXT UNIQUE REFERENCES server_deployments(id), resolved INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS autoscaling_one_intent ON autoscaling_decisions(policy_id) WHERE resolved=0;
|
||||
CREATE INDEX IF NOT EXISTS autoscaling_history ON autoscaling_decisions(policy_id,evaluated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS autoscaling_samples (
|
||||
policy_id TEXT NOT NULL REFERENCES autoscaling_policies(id), sampled_at INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL, occupied INTEGER NOT NULL, empty INTEGER NOT NULL, unknown INTEGER NOT NULL,
|
||||
deficit INTEGER NOT NULL, PRIMARY KEY(policy_id,sampled_at)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS autoscaling_authorizations (
|
||||
operation_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL, claimed_at INTEGER, revoked_at INTEGER
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { listServers, type ServerRow } from '../fleet/servers.js';
|
||||
import { observationReader } from '../fleet/status.js';
|
||||
import { parseServerName, zombieMaps } from '../../map-aliases.js';
|
||||
import { freshStatusTimestamp } from '../../server-freshness.js';
|
||||
import type { ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js';
|
||||
import { emptyObservation, type EmptyObservation } from './planner.js';
|
||||
|
||||
export function mapIdentity(server: { name: string; game?: string | null; map_slug?: string | null; map?: string | null }): { game: string; mapSlug: string } | null {
|
||||
const parsed = parseServerName(server.name), game = server.game ?? parsed?.game;
|
||||
if (!game) return null;
|
||||
if (server.map_slug && zombieMaps(game).some(m => m.slug === server.map_slug)) return { game, mapSlug: server.map_slug };
|
||||
if (server.map_slug) return null;
|
||||
if (parsed?.game === game && parsed.mapCanonical && (!server.map || parsed.mapCanonical === server.map)) return { game, mapSlug: parsed.mapSlug };
|
||||
const matches = zombieMaps(game).filter(m => m.canonical === server.map);
|
||||
return matches.length === 1 ? { game, mapSlug: matches[0].slug } : null;
|
||||
}
|
||||
|
||||
export function protectedWork(server: string, exceptOperation = ''): string | null {
|
||||
const handle = db();
|
||||
if (handle.prepare(`SELECT 1 FROM beta_servers s JOIN betas b ON b.id=s.beta_id WHERE s.server=? AND s.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')`).get(server)) return 'Active beta';
|
||||
if (handle.prepare(`SELECT 1 FROM deployment_servers WHERE server=? AND server_deleted_at IS NULL AND restart_state IN ('pending','failed','held','manual')`).get(server)) return 'Campaign work';
|
||||
if (handle.prepare(`SELECT 1 FROM maintenance_jobs WHERE server=? AND server_deleted_at IS NULL AND state IN ('queued','restarting')`).get(server)) return 'Maintenance work';
|
||||
if (handle.prepare(`SELECT 1 FROM restart_rollout WHERE server=? AND healthy_at IS NULL AND (operation_id IS NULL OR operation_id!=?)`).get(server, exceptOperation)) return 'Unresolved recovery';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function scalingSnapshot(policy: ScalingPolicy, now = Date.now(), exceptOperation = ''): ScalingSnapshot {
|
||||
const handle = db(), observe = observationReader();
|
||||
const rows = listServers();
|
||||
const matches = (value: Parameters<typeof mapIdentity>[0]) => { const identity = mapIdentity(value); return identity?.game === policy.game && identity.mapSlug === policy.mapSlug; };
|
||||
const blockers: string[] = [];
|
||||
for (const row of rows) if (!mapIdentity(row) && (row.game ?? parseServerName(row.name)?.game) === policy.game) blockers.push(`Resolve ambiguous map identity for ${row.name}.`);
|
||||
const operations = handle.prepare(`SELECT id,server,action,state FROM server_deployments WHERE server_deleted_at IS NULL
|
||||
AND state IN ('queued','waiting','running','awaiting-health','interrupted') AND id!=? ORDER BY rowid DESC`).all(exceptOperation) as { id: string; server: string; action: string; state: string }[];
|
||||
const reservations = handle.prepare(`SELECT j.server AS name,substr(j.server,1,2) AS game,j.map_slug,j.expected_map AS map FROM server_creation_jobs j
|
||||
JOIN server_deployments d ON d.id=j.deployment_id WHERE d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') AND d.id!=?`).all(exceptOperation) as { name: string; game: string; map_slug: string; map: string }[];
|
||||
const members: ScalingMember[] = rows.filter(matches).map((row: ServerRow) => {
|
||||
const observation = observe(row), operation = operations.find(o => o.server === row.name);
|
||||
const protection = protectedWork(row.name, exceptOperation);
|
||||
const host = row.host_id ? handle.prepare('SELECT mode FROM ec_hosts WHERE id=?').get(row.host_id) as { mode: string } | undefined : undefined;
|
||||
const identity = `${row.native_instance_id ?? row.created_at}:${row.instance_generation ?? 0}:${observation.telemetry_after ?? ''}`;
|
||||
let state: ScalingMember['state'] = 'unknown', blocker: string | null = null;
|
||||
let players = observation.health === 'online' ? observation.last_players : null;
|
||||
let observedAt = players !== null && observation.last_seen_at ? Date.parse(observation.last_seen_at) : null;
|
||||
let source = observation.status_source;
|
||||
// Display evidence prefers telemetry; scaling additionally honors a current positive RCON veto.
|
||||
if ((row.last_players ?? 0) > 0 && freshStatusTimestamp(row.last_seen_at, now)
|
||||
&& (!observation.telemetry_after || Date.parse(row.last_seen_at!) > Date.parse(observation.telemetry_after))) {
|
||||
players = row.last_players; observedAt = Date.parse(row.last_seen_at!); source = 'rcon';
|
||||
}
|
||||
if (protection || !row.native_instance_id || host?.mode !== 'active') { state = 'protected'; blocker = protection ?? 'Native instance on an active host required'; }
|
||||
else if (operation) { state = operation.action === 'create' ? 'creating' : operation.action === 'destroy' ? 'removing' : 'protected'; blocker = `Provisioning ${operation.id} is ${operation.state}`; }
|
||||
else if (row.desired_state === 'stopped' || ['stopped','exited','created','dead'].includes(row.container_state ?? '')) state = 'stopped';
|
||||
else if (observation.health === 'online' && players !== null && observedAt !== null && observedAt <= now && freshStatusTimestamp(new Date(observedAt).toISOString(), now)) state = players > 0 ? 'occupied' : 'empty';
|
||||
return { name: row.name, identity, state, players, observedAt, source: source ?? null, emptySince: null, blocker, operationId: operation?.id ?? null };
|
||||
});
|
||||
for (const row of reservations.filter(matches)) if (!members.some(m => m.name === row.name)) members.push({ name: row.name, identity: 'reserved', state: 'creating', players: null, observedAt: null, source: null, emptySince: null, blocker: 'Creation is pending.', operationId: operations.find(o => o.server === row.name)?.id ?? null });
|
||||
const retained = handle.prepare('SELECT server,observation FROM autoscaling_members WHERE policy_id=?').all(policy.id) as { server: string; observation: string }[];
|
||||
for (const member of members) {
|
||||
const saved = retained.find(r => r.server === member.name);
|
||||
const previous = saved ? JSON.parse(saved.observation) as EmptyObservation : undefined;
|
||||
member.emptySince = emptyObservation(member, previous, now).emptySince;
|
||||
}
|
||||
const reservedNames = new Set([...rows.filter(matches).map(r => r.name), ...reservations.filter(matches).map(r => r.name)]);
|
||||
for (const row of handle.prepare('SELECT server AS name FROM native_instances WHERE retired_at IS NULL').all() as { name: string }[]) if (matches(row)) {
|
||||
reservedNames.add(row.name);
|
||||
if (!members.some(m => m.name === row.name)) blockers.push(`Unresolved native instance ${row.name}.`);
|
||||
}
|
||||
const keyedNames = (handle.prepare("SELECT server_id AS name FROM server_keys WHERE length(trim(pluto_key))>0").all() as { name: string }[]).filter(matches).map(r => r.name);
|
||||
return { members, reservedNames: [...reservedNames], keyedNames, blockers };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
|
||||
import { initializeFleetSettings } from './fleet-settings.js';
|
||||
import { migrateServerHistory } from './server-history.js';
|
||||
import { migrateNativeInstances } from '../backends/schema.js';
|
||||
import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
|
||||
import { installPerformanceIndexes } from './performance-indexes.js';
|
||||
import { migrateFileBlobs } from './file-blobs.js';
|
||||
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
|
||||
@@ -395,6 +396,7 @@ export function db(): Database.Database {
|
||||
backfillLeaderboard(handle);
|
||||
initializeFleetSettings(handle);
|
||||
migrationStep('native instance schema and file plans', () => migrateNativeInstances(handle));
|
||||
migrationStep('autoscaling schema', () => ensureAutoscalingSchema(handle));
|
||||
// File-blob migration removes the legacy content column altogether.
|
||||
// Reordering it first would unnecessarily copy every artifact twice.
|
||||
migrateFileBlobs(handle);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { scalingSettings, type ScalingMember, type ScalingPolicy, type ScalingSnapshot } from '../../src/lib/autoscaling.js';
|
||||
import { emptyObservation, planScaling } from '../../src/lib/server/autoscaling/planner.js';
|
||||
import { mapIdentity } from '../../src/lib/server/autoscaling/snapshot.js';
|
||||
|
||||
const now = 2_000_000;
|
||||
const policy: ScalingPolicy = { ...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'dry-run' }), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
|
||||
const name = (n: number) => `t6_zombie_town_${n}`;
|
||||
const member = (n: number, state: ScalingMember['state'] = 'empty'): ScalingMember => ({ name: name(n), identity: `instance:${n}`, state, players: state === 'occupied' ? 1 : state === 'empty' ? 0 : null, observedAt: now, source: 'rcon', emptySince: 1, blocker: null, operationId: null });
|
||||
const snapshot = (...members: ScalingMember[]): ScalingSnapshot => ({ members, reservedNames: members.map(m => m.name), keyedNames: [1, 2, 3, 4].map(name), blockers: [] });
|
||||
|
||||
describe('autoscaling policy planning', () => {
|
||||
it('requires explicit bounded maximum and valid variant', () => {
|
||||
for (const input of [{}, { game: 't6', mapSlug: 'town' }, { game: 't5', mapSlug: 'town', maximum: 4 }, { game: 't6', mapSlug: 'town', maximum: 21 }, { ...policy, minimum: 5 }, { ...policy, spares: 0 }, { ...policy, emptyMinutes: 1.5 }]) expect(() => scalingSettings(input)).toThrow();
|
||||
expect(scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4 }).mode).toBe('off');
|
||||
});
|
||||
it('creates a spare even with free player slots and bootstraps an empty map', () => {
|
||||
expect(planScaling(policy, snapshot(member(1, 'occupied')), now)).toMatchObject({ action: 'create', server: name(2), desired: 2 });
|
||||
expect(planScaling(policy, snapshot(), now)).toMatchObject({ action: 'create', desired: 1 });
|
||||
expect(planScaling(policy, snapshot(member(1, 'occupied'), member(2)), now).action).toBe('wait');
|
||||
});
|
||||
it('uses nonconsecutive keyed identities without moving keys', () => {
|
||||
expect(planScaling(policy, { ...snapshot(), keyedNames: [name(8), name(3)] }, now).server).toBe(name(3));
|
||||
expect(planScaling(policy, { ...snapshot(), keyedNames: [] }, now).reason).toContain('saved key');
|
||||
});
|
||||
it('holds for pending creation, unknown, stopped, or protected members', () => {
|
||||
for (const state of ['creating', 'removing', 'unknown', 'stopped', 'protected'] as const) expect(planScaling(policy, snapshot(member(1, state)), now).action).toBe('wait');
|
||||
expect(planScaling(policy, { ...snapshot(), blockers: ['Unresolved instance'] }, now).action).toBe('wait');
|
||||
});
|
||||
it('holds for stale and future samples, cooldown, and disabled policies', () => {
|
||||
for (const stamp of [null, now - 90_001, now + 1]) expect(planScaling(policy, snapshot({ ...member(1, 'occupied'), observedAt: stamp }), now).action).toBe('wait');
|
||||
expect(planScaling({ ...policy, nextActionAt: now + 1 }, snapshot(), now).action).toBe('wait');
|
||||
expect(planScaling({ ...policy, mode: 'off' }, snapshot(), now).action).toBe('wait');
|
||||
});
|
||||
it('never sacrifices occupied lobbies or minimum/spare capacity to a maximum', () => {
|
||||
expect(planScaling(policy, snapshot(...[1, 2, 3, 4].map(n => member(n, 'occupied'))), now)).toMatchObject({ action: 'wait', desired: 4 });
|
||||
expect(planScaling({ ...policy, maximum: 1 }, snapshot(member(1, 'occupied'), member(2)), now).action).toBe('wait');
|
||||
expect(planScaling({ ...policy, minimum: 3 }, snapshot(member(1), member(2), member(3)), now).action).toBe('wait');
|
||||
});
|
||||
it('trims one longest-empty lobby and requires a full observed interval', () => {
|
||||
expect(planScaling(policy, snapshot(member(1, 'occupied'), member(2), member(3), member(4)), now)).toMatchObject({ action: 'destroy', server: name(2), desired: 2 });
|
||||
expect(planScaling(policy, snapshot({ ...member(1), emptySince: now }, { ...member(2), emptySince: now }), now).action).toBe('wait');
|
||||
});
|
||||
it('preserves map variants and refuses ambiguous engine maps', () => {
|
||||
expect(mapIdentity({ name: 'legacy', game: 't6', map: 'zm_transit' })).toBeNull();
|
||||
expect(mapIdentity({ name: name(1), map: 'zm_transit' })?.mapSlug).toBe('town');
|
||||
expect(mapIdentity({ name: 'legacy', game: 't6', map_slug: 'farm', map: 'zm_transit' })?.mapSlug).toBe('farm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('continuous empty evidence', () => {
|
||||
it('does not turn rereads into new evidence', () => {
|
||||
const previous = emptyObservation(member(1), undefined, now);
|
||||
const reread = emptyObservation(member(1), previous, now + 60_000);
|
||||
expect(reread).toMatchObject({ observedAt: now, emptySince: now });
|
||||
expect(emptyObservation({ ...member(1), observedAt: now + 60_000 }, previous, now + 60_000).emptySince).toBe(now);
|
||||
});
|
||||
it('resets on unknown, players, lifecycle replacement, and observation gaps', () => {
|
||||
const previous = emptyObservation(member(1), undefined, now);
|
||||
expect(emptyObservation(member(1, 'occupied'), previous, now).emptySince).toBeNull();
|
||||
expect(emptyObservation(member(1, 'unknown'), previous, now).emptySince).toBeNull();
|
||||
expect(emptyObservation({ ...member(1), identity: 'replacement', observedAt: now + 1 }, previous, now + 1).emptySince).toBe(now + 1);
|
||||
expect(emptyObservation({ ...member(1), observedAt: now + 120_000 }, previous, now + 120_000).emptySince).toBe(now + 120_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { prepareNativeHost } from '../helpers/native-host.js';
|
||||
import { scalingSettings, type ScalingPolicy } from '../../src/lib/autoscaling.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
const town = 't6_zombie_town_1', farm = 't6_zombie_farm_1';
|
||||
const policy: ScalingPolicy = { ...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'dry-run' }), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
|
||||
|
||||
it('separates variants, deduplicates pending identities, and initializes additive schema', async () => {
|
||||
await prepareNativeHost([town, farm]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { scalingSnapshot } = await import('../../src/lib/server/autoscaling/snapshot.js');
|
||||
const { ensureAutoscalingSchema } = await import('../../src/lib/server/autoscaling/schema.js');
|
||||
ensureAutoscalingSchema(db()); ensureAutoscalingSchema(db());
|
||||
const at = new Date().toISOString();
|
||||
for (const server of [town, farm]) db().prepare(`INSERT INTO servers(name,container_state,container_checked_at,last_seen_at,last_checked_at,last_players) VALUES(?,'running',?,?,?,0)`).run(server, at, at, at);
|
||||
expect(scalingSnapshot(policy).members.map(m => [m.name, m.state])).toEqual([[town, 'empty']]);
|
||||
db().prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('create',?,'create','queued','Awaiting placement',?,'test')`).run(town, at);
|
||||
db().prepare(`INSERT INTO server_creation_jobs(deployment_id,server,game_mode,map_slug,expected_map) VALUES('create',?,'zombie','town','zm_transit')`).run(town);
|
||||
const snapshot = scalingSnapshot(policy);
|
||||
expect(snapshot.members).toHaveLength(1);
|
||||
expect(snapshot.members[0].state).toBe('creating');
|
||||
expect(snapshot.reservedNames).toEqual([town]);
|
||||
});
|
||||
|
||||
it('does not create schema mutations or host work when planning an empty map', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { scalingSnapshot } = await import('../../src/lib/server/autoscaling/snapshot.js');
|
||||
const { planScaling } = await import('../../src/lib/server/autoscaling/planner.js');
|
||||
db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run('t6_zombie_town_8', 'private-key');
|
||||
expect(planScaling(policy, scalingSnapshot(policy), Date.now()).server).toBe('t6_zombie_town_8');
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM server_deployments').get()).toEqual({ n: 0 });
|
||||
});
|
||||
Reference in New Issue
Block a user