Defer server file changes until guarded restart activation
Persist beta, promotion, drift, and rollback payloads until occupancy and restart-capacity checks pass. Preserve rollback snapshots and campaign ordering, and allow held rollback campaigns to resume safely. Fixes #28 Validation: 805 tests passed, Svelte checks clean, production build passed.
This commit is contained in:
@@ -51,11 +51,17 @@ export function requestRestart(server: string, source: RestartSource): Lifecycle
|
||||
}
|
||||
// Only not-yet-submitted campaign changes can share an activation. A later
|
||||
// file deployment must never be credited to an already running restart.
|
||||
if (source.kind === 'campaign' && !row.restarted_at) {
|
||||
// Restorations need their own activation. An older campaign can have
|
||||
// stale file preconditions after a beta; its failure must not strand
|
||||
// the rollback that would restore the server.
|
||||
const rollback = source.kind === 'campaign' && !!db().prepare("SELECT 1 FROM deployments WHERE id = ? AND action = 'rollback'").get(source.id);
|
||||
if (source.kind === 'campaign' && !row.restarted_at && !rollback) {
|
||||
id = (db().prepare(`SELECT d.id FROM server_deployments d WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.action = 'restart'
|
||||
AND d.state = 'waiting' AND d.submitted_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'campaign')
|
||||
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind != 'campaign')
|
||||
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN deployments c ON c.id = s.source_id
|
||||
WHERE s.operation_id = d.id AND s.kind = 'campaign' AND c.action = 'rollback')
|
||||
ORDER BY d.rowid LIMIT 1`).get(server) as { id: string } | undefined)?.id;
|
||||
}
|
||||
if (!id) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '../db/index.js';
|
||||
import { komodoInspectHealthy, komodoRestart, komodoRuntimeAction } from '../integrations/komodo.js';
|
||||
import { probeServerStatus } from '../fleet/status-evidence.js';
|
||||
import { deploymentStage, finishDeployment } from './history.js';
|
||||
import { applyStagedFilesForOperation } from '../fleet/staged-files.js';
|
||||
|
||||
// Komodo queues restart work. Don't mistake the still-running old container
|
||||
// for a completed restart when an API kick immediately follows the request.
|
||||
@@ -64,16 +65,26 @@ export async function restartInRollout(server: string, options: { operationId: s
|
||||
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL').get(operationId)) return 'Original server was deleted';
|
||||
const reason = rolloutWaitReason(server, operationId);
|
||||
if (reason) return reason;
|
||||
db().prepare('UPDATE server_deployments SET submitted_at = ? WHERE id = ?').run(new Date().toISOString(), operationId);
|
||||
db().prepare(`INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(server) DO UPDATE SET requested_at = excluded.requested_at,
|
||||
checked_at = NULL, healthy_at = NULL, players = NULL, error = NULL, operation_id = excluded.operation_id`)
|
||||
.run(server, new Date().toISOString(), operationId);
|
||||
deploymentStage(operationId, options.action === 'start' ? 'Starting container' : 'Restarting container');
|
||||
return null;
|
||||
}).immediate();
|
||||
if (waitReason) return { kind: 'queued', message: waitReason };
|
||||
|
||||
// Every gate, including rollout capacity, has cleared. No async work may
|
||||
// intervene between applying these files and submitting the restart.
|
||||
try {
|
||||
applyStagedFilesForOperation(operationId, server);
|
||||
} catch (err) {
|
||||
const message = `File activation failed: ${(err as Error).message}`;
|
||||
db().prepare('DELETE FROM restart_rollout WHERE server = ? AND operation_id = ?').run(server, operationId);
|
||||
finishDeployment(operationId, { ok: false, message });
|
||||
return { kind: 'failed', message };
|
||||
}
|
||||
db().prepare('UPDATE server_deployments SET submitted_at = ? WHERE id = ?').run(new Date().toISOString(), operationId);
|
||||
deploymentStage(operationId, options.action === 'start' ? 'Starting container' : 'Restarting container');
|
||||
requestsInFlight.set(server, operationId);
|
||||
db().prepare(`UPDATE servers SET container_state = 'restarting', last_error = NULL, last_reply_snippet = NULL,
|
||||
game_started_at = NULL, game_ended_at = NULL WHERE name = ?`).run(server);
|
||||
|
||||
@@ -29,12 +29,13 @@ function sourceEligibility(operation: Operation): 'ready' | 'held' | 'cancelled'
|
||||
if (!maintenanceAllowed(incident.kind)) return 'held';
|
||||
}
|
||||
let held = false;
|
||||
let ready = false;
|
||||
for (const source of sources) {
|
||||
if (source.kind === 'campaign') {
|
||||
const row = db().prepare('SELECT restart_state, files_changed, provisioning_id FROM deployment_servers WHERE deployment_id = ? AND server = ? AND server_deleted_at IS NULL')
|
||||
.get(source.source_id, operation.server) as { restart_state: string; files_changed: number; provisioning_id: string } | undefined;
|
||||
if (row?.provisioning_id !== operation.id || !row.files_changed) continue;
|
||||
if (['pending','failed'].includes(row.restart_state)) return 'ready';
|
||||
if (['pending','failed'].includes(row.restart_state)) ready = true;
|
||||
if (['held','manual'].includes(row.restart_state)) held = true;
|
||||
} else if (source.kind === 'beta') {
|
||||
const row = db().prepare(`SELECT b.state, bs.stage, bs.provisioning_id, p.outcome FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
||||
@@ -42,18 +43,18 @@ function sourceEligibility(operation: Operation): 'ready' | 'held' | 'cancelled'
|
||||
.get(source.source_id, operation.server) as { state: string; stage: string; provisioning_id: string; outcome: string | null } | undefined;
|
||||
if (row?.provisioning_id !== operation.id || !['running','ready-promote'].includes(row.state)) continue;
|
||||
if (!['awaiting-empty','restarting','awaiting-health'].includes(row.stage)) continue;
|
||||
if (!row.outcome || ['active','passed'].includes(row.outcome)) return 'ready';
|
||||
if (!row.outcome || ['active','passed'].includes(row.outcome)) ready = true;
|
||||
if (row.outcome === 'paused') held = true;
|
||||
} else if (source.kind === 'maintenance') {
|
||||
const row = db().prepare('SELECT state, provisioning_id, kind, origin FROM maintenance_jobs WHERE id = ? AND server_deleted_at IS NULL').get(source.source_id) as { state: string; provisioning_id: string; kind: string; origin: string } | undefined;
|
||||
if (row?.provisioning_id === operation.id && ['queued','restarting'].includes(row.state)) {
|
||||
if (getServer(operation.server)?.desired_state === 'stopped') return 'cancelled';
|
||||
if (!maintenanceAllowed() || (row.kind === 'update' && row.origin === 'automatic' && !maintenanceAllowed('updates'))) return 'held';
|
||||
return 'ready';
|
||||
ready = true;
|
||||
}
|
||||
} else return 'ready';
|
||||
} else ready = true;
|
||||
}
|
||||
return held ? 'held' : 'cancelled';
|
||||
return held ? 'held' : ready ? 'ready' : 'cancelled';
|
||||
}
|
||||
|
||||
function blocker(server: string, id: string): string | null {
|
||||
|
||||
Reference in New Issue
Block a user