Add cancellation for collection launches

This commit is contained in:
2026-10-07 15:32:02 -04:00
parent 062ade8655
commit db92c89b05
10 changed files with 501 additions and 28 deletions
+7 -1
View File
@@ -68,9 +68,13 @@ export interface CollectionSummary {
id: string;
name: string;
created_at: string;
cancelled_at: string | null;
total: number;
done: number;
failed: number;
cancelled: number;
cleanupActive: number;
cleanupFailed: number;
active: number;
queued: number;
provisioning: number;
@@ -80,11 +84,13 @@ export interface CollectionSummary {
export interface CollectionDetail {
collection: CollectionSummary;
canCancel: boolean;
retainedServers: string[];
retainedServerDeletedAt?: Record<string, string>;
queue: { concurrency: number; active: { id: string; server: string; state: string; stage: string }[]; queued: number };
servers: { id: string; server: string; action: 'create' | 'destroy'; port: number | null; map_slug: string | null; state: string; stage: string;
started_at: string; finished_at: string | null; error: string | null; server_exists: number; server_deleted_at?: string | null; queuePosition: number | null }[];
started_at: string; finished_at: string | null; error: string | null; server_exists: number; server_deleted_at?: string | null; queuePosition: number | null;
cancellation: { cleanupId: string; state: string; stage: string; error: string | null } | null }[];
}
export interface LaunchPreview {
+1
View File
@@ -368,6 +368,7 @@ export function db(): Database.Database {
// Older databases gain rolled_back_by above, after schema.sql runs.
handle.exec('CREATE INDEX IF NOT EXISTS idx_deployments_rolled_back_by ON deployments(rolled_back_by)');
migrateTable(handle, 'server_launch_collections', [
{ name: 'cancelled_at', ddl: 'ALTER TABLE server_launch_collections ADD COLUMN cancelled_at TEXT' },
{ name: 'mode', ddl: "ALTER TABLE server_launch_collections ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
{ name: 'retained_servers', ddl: "ALTER TABLE server_launch_collections ADD COLUMN retained_servers TEXT NOT NULL DEFAULT '[]'" }
]);
+1
View File
@@ -581,6 +581,7 @@ CREATE TABLE IF NOT EXISTS server_launch_collections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
cancelled_at TEXT,
mode TEXT NOT NULL DEFAULT 'fill',
retained_servers TEXT NOT NULL DEFAULT '[]',
retained_server_deletions TEXT NOT NULL DEFAULT '{}'
+59 -5
View File
@@ -14,6 +14,8 @@ import { nativeLaunchSelection, queueNativeCreationIntent } from '../provisionin
import { nativeDeployment, nativeInstance } from '../backends/native.js';
import { launchAvailability } from '../hosts/availability.js';
import { availableServerKeyCount } from './server-keys.js';
import { canCancelCreation, cancelCreation } from '../provisioning/cancel-creation.js';
import { canCancelRemoval, cancelRemoval } from '../provisioning/history.js';
function coverage(entries: BlueprintEntry[], includeHealth = true) {
type Server = { name: string; game: string | null; map_slug: string | null; map: string | null };
@@ -240,16 +242,61 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
const membershipSql = `SELECT collection_id, deployment_id, position FROM server_collection_members
UNION ALL SELECT collection_id, deployment_id, position FROM server_collection_removals`;
const summarySql = `SELECT c.id, c.name, c.created_at, c.mode, COUNT(d.id) AS total,
function collectionCancellationCandidates(id: string) {
const creations = (db().prepare(`SELECT d.id FROM server_collection_members m
JOIN server_deployments d ON d.id=m.deployment_id
WHERE m.collection_id=?
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations cc WHERE cc.creation_id=d.id)`)
.all(id) as { id: string }[]).filter(job => canCancelCreation(job.id));
// Exact launches can reuse manual removals or creation cleanup jobs. Membership
// alone must never give a launch permission to withdraw those operations.
const removals = (db().prepare(`SELECT d.id FROM server_collection_removals m
JOIN server_deployments d ON d.id=m.deployment_id
WHERE m.collection_id=? AND d.triggered_by='blueprint-exact'
AND NOT EXISTS (SELECT 1 FROM native_provisioning n WHERE n.deployment_id=d.id AND n.operation_id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations cc WHERE cc.cleanup_id=d.id)`)
.all(id) as { id: string }[]).filter(job => canCancelRemoval(job.id));
return { creations, removals };
}
/** Withdraw the remaining launch atomically; partial instances keep durable cleanup jobs. */
export function cancelCollection(id: string): boolean {
return db().transaction(() => {
const collection = db().prepare('SELECT cancelled_at FROM server_launch_collections WHERE id=?').get(id) as { cancelled_at: string | null } | undefined;
if (!collection) return false;
if (collection.cancelled_at) return true;
const { creations, removals } = collectionCancellationCandidates(id);
if (!creations.length && !removals.length) return false;
db().prepare('UPDATE server_launch_collections SET cancelled_at=? WHERE id=?').run(new Date().toISOString(), id);
for (const creation of creations) {
if (!cancelCreation(creation.id, 'collection-cancel')) throw new Error('Could not cancel a collection creation. Refresh the launch and try again.');
}
for (const removal of removals) {
// A shared removal remains requested until every owning launch withdraws.
const shared = db().prepare(`SELECT 1 FROM server_collection_removals m
JOIN server_launch_collections c ON c.id=m.collection_id
WHERE m.deployment_id=? AND c.id!=? AND c.cancelled_at IS NULL LIMIT 1`).get(removal.id, id);
if (!shared) cancelRemoval(removal.id);
}
return true;
})();
}
const summarySql = `SELECT c.id, c.name, c.created_at, c.cancelled_at, c.mode, COUNT(d.id) AS total,
COALESCE(SUM(d.state IN ('done', 'warning')), 0) AS done,
COALESCE(SUM(d.state IN ('failed', 'interrupted', 'cancelled')), 0) AS failed,
COALESCE(SUM(d.state IN ('failed', 'interrupted')), 0) AS failed,
COALESCE(SUM(d.state = 'cancelled'), 0) AS cancelled,
COALESCE(SUM(cleanup.state IN ('queued', 'running', 'awaiting-health', 'waiting')), 0) AS cleanupActive,
COALESCE(SUM(cleanup.state IN ('failed', 'interrupted', 'warning', 'cancelled')), 0) AS cleanupFailed,
COALESCE(SUM(d.state IN ('queued', 'running', 'awaiting-health', 'waiting')), 0) AS active,
COALESCE(SUM(d.state IN ('queued', 'waiting')), 0) AS queued,
COALESCE(SUM(d.state = 'running'), 0) AS provisioning,
COALESCE(SUM(d.state = 'awaiting-health'), 0) AS verifying
FROM server_launch_collections c
LEFT JOIN (${membershipSql}) m ON m.collection_id = c.id
LEFT JOIN server_deployments d ON d.id = m.deployment_id`;
LEFT JOIN server_deployments d ON d.id = m.deployment_id
LEFT JOIN native_creation_cancellations cc ON cc.creation_id = d.id
LEFT JOIN server_deployments cleanup ON cleanup.id = cc.cleanup_id`;
export function listCollections(): CollectionSummary[] {
return db().prepare(summarySql + ' GROUP BY c.id ORDER BY c.created_at DESC, c.rowid DESC').all() as CollectionSummary[];
@@ -265,6 +312,13 @@ 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, retained_server_deletions FROM server_launch_collections WHERE id = ?').get(id) as { retained_servers: string; retained_server_deletions: string };
return { collection, retainedServers: JSON.parse(stored.retained_servers), retainedServerDeletedAt: JSON.parse(stored.retained_server_deletions), queue: { concurrency: fleetSettings().createConcurrency, active: queue.active, queued: queue.queued.length },
servers: servers.map(server => ({ ...server, queuePosition: positions.get(server.id) ?? null })) };
const cancellations = db().prepare(`SELECT cc.creation_id, cc.cleanup_id AS cleanupId, d.state, d.stage, d.error
FROM server_collection_members m JOIN native_creation_cancellations cc ON cc.creation_id=m.deployment_id
JOIN server_deployments d ON d.id=cc.cleanup_id WHERE m.collection_id=?`).all(id) as
(NonNullable<CollectionDetail['servers'][number]['cancellation']> & { creation_id: string })[];
const cleanupByCreation = new Map(cancellations.map(({ creation_id, ...cleanup }) => [creation_id, cleanup]));
const candidates = collection.cancelled_at ? null : collectionCancellationCandidates(id);
return { collection, canCancel: !!candidates && (candidates.creations.length > 0 || candidates.removals.length > 0),
retainedServers: JSON.parse(stored.retained_servers), retainedServerDeletedAt: JSON.parse(stored.retained_server_deletions), queue: { concurrency: fleetSettings().createConcurrency, active: queue.active, queued: queue.queued.length },
servers: servers.map(server => ({ ...server, queuePosition: positions.get(server.id) ?? null, cancellation: cleanupByCreation.get(server.id) ?? null })) };
}