Move fleet retention and concurrency controls into Settings

Persist settings in SQLite, import legacy environment values once, and apply changes to cleanup and deployment queues without restarting.

Closes #51
This commit is contained in:
2026-09-27 16:11:45 -04:00
parent fc46cb9da4
commit 4fe8613678
20 changed files with 334 additions and 54 deletions
+34
View File
@@ -0,0 +1,34 @@
export interface FleetSettings {
deployKeepDays: number;
restartConcurrency: number;
createConcurrency: number;
}
export const defaultFleetSettings: FleetSettings = {
deployKeepDays: 7,
restartConcurrency: 1,
createConcurrency: 1
};
export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
deployKeepDays: 'Campaign history retention',
restartConcurrency: 'Concurrent restarts',
createConcurrency: 'Concurrent server startups'
};
export function validateFleetSettings(input: unknown): FleetSettings {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Provide fleet settings.');
const values = input as Record<string, unknown>;
for (const key of Object.keys(values)) {
if (!Object.hasOwn(defaultFleetSettings, key)) throw new Error(`Unknown setting: ${key}`);
}
for (const key of Object.keys(defaultFleetSettings) as (keyof FleetSettings)[]) {
const value = values[key];
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new Error(`${fleetSettingLabels[key]} must be a positive whole number.`);
}
if (key === 'deployKeepDays' && value > 36500) throw new Error('Campaign history retention must be at most 36500 days.');
}
return { deployKeepDays: values.deployKeepDays as number, restartConcurrency: values.restartConcurrency as number,
createConcurrency: values.createConcurrency as number };
}
-9
View File
@@ -15,12 +15,6 @@ function num(name: string, fallback: number): number {
return n;
}
function positiveInt(name: string, fallback: number): number {
const value = num(name, fallback);
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`env ${name} must be a positive integer`);
return value;
}
export const config = {
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
t6ServersRoot: env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
@@ -38,9 +32,6 @@ export const config = {
// Cleared per-beta on promote/rollback.
betaArchiveDir: req('BETA_ARCHIVE_DIR', '/root/zombies/beta-archive'),
dbPath: req('DB_PATH', './data/cherry.db'),
deployKeepDays: num('DEPLOY_KEEP_DAYS', 7),
restartConcurrency: positiveInt('DEPLOYMENT_RESTART_CONCURRENCY', 1),
createConcurrency: positiveInt('DEPLOYMENT_CREATE_CONCURRENCY', 1),
httpTimeoutMs: num('HTTP_TIMEOUT_MS', 10_000),
komodo: {
address: env.KOMODO_ADDRESS ?? '',
+20
View File
@@ -0,0 +1,20 @@
import type Database from 'better-sqlite3';
import { defaultFleetSettings, validateFleetSettings, type FleetSettings } from '../../fleet-settings.js';
export const FLEET_SETTINGS_KEY = 'fleet_settings';
/** Import legacy environment values once; persisted settings win on every later boot. */
export function initializeFleetSettings(handle: Database.Database): void {
if (handle.prepare('SELECT 1 FROM settings WHERE key = ?').get(FLEET_SETTINGS_KEY)) return;
const values = { ...defaultFleetSettings };
const legacy: Record<keyof FleetSettings, string> = {
deployKeepDays: 'DEPLOY_KEEP_DAYS', restartConcurrency: 'DEPLOYMENT_RESTART_CONCURRENCY',
createConcurrency: 'DEPLOYMENT_CREATE_CONCURRENCY'
};
for (const key of Object.keys(legacy) as (keyof FleetSettings)[]) {
const raw = process.env[legacy[key]];
if (raw) values[key] = Number(raw);
}
handle.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)')
.run(FLEET_SETTINGS_KEY, JSON.stringify(validateFleetSettings(values)));
}
+2
View File
@@ -6,6 +6,7 @@ import schemaSql from './schema.sql?raw';
import { requirement, type BetaPlan, type BetaRequirement } from '../../beta-plan.js';
import { emptyMetrics } from '../fleet/beta-evaluation.js';
import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
import { initializeFleetSettings } from './fleet-settings.js';
let _db: Database.Database | null = null;
@@ -345,6 +346,7 @@ export function db(): Database.Database {
migrateGameTelemetry(handle);
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]);
backfillLeaderboard(handle);
initializeFleetSettings(handle);
} catch (err) {
handle.close();
throw err;
+3 -2
View File
@@ -1,3 +1,4 @@
import { fleetSettings } from '../settings.js';
import { randomUUID } from 'node:crypto';
import type { CollectionDetail, CollectionMode, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.js';
import { db } from '../db/index.js';
@@ -106,7 +107,7 @@ export function previewCollection(blueprintId: unknown, mode: unknown = 'fill'):
db().prepare('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString());
db().prepare('INSERT INTO server_collection_previews (id, expires_at, blueprint_name, entries, servers, mode, fleet_snapshot) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, expiresAt, blueprintName, JSON.stringify(entries), JSON.stringify(servers), mode, mode === 'exact' ? JSON.stringify(fleetSnapshot()) : null);
return { id, expiresAt, blueprintName, mode, retainedServers, removals, launchConcurrency: config.createConcurrency, servers, blockers, ready: blockers.length === 0,
return { id, expiresAt, blueprintName, mode, retainedServers, removals, launchConcurrency: fleetSettings().createConcurrency, servers, blockers, ready: blockers.length === 0,
entries: entries.map((entry, index) => ({ count: entry.count, ...counts[index], recipe: summarizeRecipe(entry.recipe) })) };
})();
}
@@ -216,6 +217,6 @@ export function getCollection(id: string): CollectionDetail | null {
const queue = creationQueue();
const positions = new Map(queue.queued.map((job, index) => [job.id, index + 1]));
const stored = db().prepare('SELECT retained_servers FROM server_launch_collections WHERE id = ?').get(id) as { retained_servers: string };
return { collection, retainedServers: JSON.parse(stored.retained_servers), queue: { concurrency: config.createConcurrency, active: queue.active, queued: queue.queued.length },
return { collection, retainedServers: JSON.parse(stored.retained_servers), queue: { concurrency: fleetSettings().createConcurrency, active: queue.active, queued: queue.queued.length },
servers: servers.map(server => ({ ...server, queuePosition: positions.get(server.id) ?? null })) };
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { config } from '../config.js';
import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
import { emit } from './bus.js';
import { requestRestart } from '../provisioning/index.js';
@@ -44,7 +44,7 @@ async function runSweep(): Promise<SweepResult> {
db().prepare(`DELETE FROM deployments WHERE started_at < ?
AND NOT EXISTS (SELECT 1 FROM watchdog_actions WHERE campaign_id = deployments.id) AND NOT EXISTS (
SELECT 1 FROM deployment_servers WHERE deployment_id = deployments.id AND restart_state IN ('pending','failed','held'))`)
.run(new Date(Date.now() - config.deployKeepDays * 86_400_000).toISOString());
.run(new Date(Date.now() - fleetSettings().deployKeepDays * 86_400_000).toISOString());
emit({ type: 'sweep-tick', frozen: false });
return result;
}
+4 -3
View File
@@ -1,3 +1,4 @@
import { fleetSettings } from '../settings.js';
import { probePlayerCount, probeServerStatus } from '../fleet/status-evidence.js';
import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification, claimRemoval } from './history.js';
import { randomUUID } from 'node:crypto';
@@ -258,7 +259,7 @@ export async function rebuildServer(id: string): Promise<void> {
const job = db().prepare(`SELECT j.*, d.stage FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE d.id = ? AND d.action = 'rebuild' AND d.state = 'waiting'`).get(id) as (CreationJob & { stage: string }) | undefined;
if (!job) return;
if (creating || destroying.size || creationQueue().active.length >= config.createConcurrency) {
if (creating || destroying.size || creationQueue().active.length >= fleetSettings().createConcurrency) {
// The cleanup checkpoint must survive capacity waits and app restarts.
if (job.stage !== REMOVING_SERVER) deploymentStage(id, 'Waiting for a server creation slot before rebuilding');
return;
@@ -775,7 +776,7 @@ async function runCreations(): Promise<CreationOutcomes> {
// Snapshot the budget once. Fast success/failure must not turn a single
// worker pass into an unbounded stream of expensive container startups.
const queue = creationQueue();
const available = Math.max(0, config.createConcurrency - queue.active.length);
const available = Math.max(0, fleetSettings().createConcurrency - queue.active.length);
const eligible = queue.queued.filter(({ id }) => {
const blocker = creationBlocker(id);
if (blocker) deploymentStage(id, blocker);
@@ -785,7 +786,7 @@ async function runCreations(): Promise<CreationOutcomes> {
if (creating || destroying.size > 0) break;
// API calls can cancel a selected job or resume an older startup while
// a previous provision awaits Komodo. Recheck without expanding this pass.
if (creationQueue().active.length >= config.createConcurrency) break;
if (creationQueue().active.length >= fleetSettings().createConcurrency) break;
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND action IN ('create', 'rebuild') AND state = 'queued'").get(id)) continue;
const blocker = creationBlocker(id);
if (blocker) {
+2 -2
View File
@@ -1,4 +1,4 @@
import { config } from '../config.js';
import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
import { komodoInspectHealthy, komodoRestart, komodoRuntimeAction } from '../integrations/komodo.js';
import { probeServerStatus } from '../fleet/status-evidence.js';
@@ -49,7 +49,7 @@ export function rolloutWaitReason(server: string, operationId = ''): string | nu
return `Waiting for the previous restart of this server to recover${slot.error ? `: ${slot.error}` : ''}`;
}
const { n } = db().prepare('SELECT COUNT(*) AS n FROM restart_rollout WHERE healthy_at IS NULL').get() as { n: number };
return n - (ownHandoff && slot?.healthy_at === null ? 1 : 0) >= config.restartConcurrency ? 'Queued for a rolling restart; waiting for another server to recover' : null;
return n - (ownHandoff && slot?.healthy_at === null ? 1 : 0) >= fleetSettings().restartConcurrency ? 'Queued for a rolling restart; waiting for another server to recover' : null;
}
export type RolloutRestartResult =
+14
View File
@@ -0,0 +1,14 @@
import { validateFleetSettings, type FleetSettings } from '../fleet-settings.js';
import { db } from './db/index.js';
import { FLEET_SETTINGS_KEY } from './db/fleet-settings.js';
export function fleetSettings(): FleetSettings {
const row = db().prepare('SELECT value FROM settings WHERE key = ?').get(FLEET_SETTINGS_KEY) as { value: string };
return JSON.parse(row.value);
}
export function saveFleetSettings(input: unknown): FleetSettings {
const settings = validateFleetSettings(input);
db().prepare('UPDATE settings SET value = ? WHERE key = ?').run(JSON.stringify(settings), FLEET_SETTINGS_KEY);
return settings;
}
+12
View File
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { validateFleetSettings } from '$lib/fleet-settings.js';
import { fleetSettings, saveFleetSettings } from '$lib/server/settings.js';
export function GET() { return json(fleetSettings()); }
export async function PUT({ request }: { request: Request }) {
let settings;
try { settings = validateFleetSettings(await request.json()); }
catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
return json(saveFleetSettings(settings));
}
+62 -2
View File
@@ -3,14 +3,71 @@
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
import { onMount } from 'svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import type { FleetSettings } from '$lib/fleet-settings.js';
let exampleDate = $state<Date | null>(null);
let settings = $state<FleetSettings | null>(null);
let loading = $state(true);
let saving = $state(false);
let error = $state('');
let notice = $state('');
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort());
onMount(() => { exampleDate = new Date(); });
onMount(() => { exampleDate = new Date(); void loadSettings(); });
async function loadSettings() {
loading = true; error = '';
try {
const response = await fetch('/api/settings');
if (!response.ok) throw new Error('Could not load fleet settings.');
settings = await response.json();
} catch (cause) { error = (cause as Error).message; }
finally { loading = false; }
}
async function saveSettings(event: SubmitEvent) {
event.preventDefault();
saving = true; error = ''; notice = '';
try {
const response = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Could not save fleet settings.');
settings = result;
notice = 'Fleet settings saved. Changes apply to the next queue or cleanup check.';
} catch (cause) { error = (cause as Error).message; }
finally { saving = false; }
}
</script>
<svelte:head><title>Settings · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader title="Settings" subtitle="Choose how dates and times appear across Electric Cherry." />
<SectionHeader title="Settings" subtitle="Manage fleet limits, history retention, and display preferences." />
<section class="guide" aria-labelledby="fleet-heading">
<h2 id="fleet-heading">Fleet settings</h2>
<p>Saved for everyone using this Electric Cherry instance. Changes apply without restarting the app.</p>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if loading}
<p role="status">Loading fleet settings…</p>
{:else if settings}
<form onsubmit={saveSettings}>
<fieldset disabled={saving}>
<label for="retention">Campaign history retention (days)</label>
<input id="retention" type="number" min="1" max="36500" step="1" required bind:value={settings.deployKeepDays} aria-describedby="retention-help" />
<p id="retention-help">Default: 7 days. Removes eligible old campaign records and file diffs on the next cleanup check. Pending, failed, held, and health-recovery-linked campaigns are kept. Provisioning history is retained.</p>
<label for="restarts">Concurrent restarts</label>
<input id="restarts" type="number" min="1" step="1" required bind:value={settings.restartConcurrency} aria-describedby="restarts-help" />
<p id="restarts-help">Default: 1 across the fleet. Campaigns, betas, maintenance, and lifecycle actions share recovery slots until container and game health are verified.</p>
<label for="startups">Concurrent server startups</label>
<input id="startups" type="number" min="1" step="1" required bind:value={settings.createConcurrency} aria-describedby="startups-help" />
<p id="startups-help">Default: 1 across collections and individual launches. Each startup holds its slot through container, game health, and selected-map verification.</p>
<p>Lowering a limit lets active operations finish; queued work waits for capacity. Player safety checks still apply.</p>
<Button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save fleet settings'}</Button>
</fieldset>
</form>
{:else}
<Button onclick={loadSettings}>Retry loading</Button>
{/if}
</section>
<section class="guide" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2>
<label class="timezone">Timezone
@@ -32,4 +89,7 @@
p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.65; }
.timezone { display: grid; gap: 8px; margin-top: 16px; font-size: var(--text-sm); max-width: 420px; }
.timezone select { width: 100%; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
fieldset { border: 0; padding: 0; margin: 20px 0 0; min-width: 0; }
fieldset label { display: block; margin: 20px 0 8px; font-size: var(--text-sm); }
fieldset input { width: 160px; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
</style>