Add exclusive map ownership and capacity-aware autoscaling creation
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { pauseScalingForOperation } from '../autoscaling/ownership.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
import { queueHostOperation, releaseHostPorts } from '../hosts/index.js';
|
||||
@@ -21,11 +22,12 @@ export function canCancelCreation(id: string): boolean {
|
||||
}
|
||||
|
||||
/** Stops observation immediately; remote cleanup remains a durable, separately visible job. */
|
||||
export function cancelCreation(id: string, triggeredBy = 'ui'): string | null {
|
||||
export function cancelCreation(id: string, triggeredBy = 'ui', pausePolicy = true): string | null {
|
||||
return db().transaction(() => {
|
||||
const prior = db().prepare('SELECT cleanup_id FROM native_creation_cancellations WHERE creation_id=?').get(id) as { cleanup_id: string } | undefined;
|
||||
if (prior) return prior.cleanup_id;
|
||||
if (!canCancelCreation(id)) return null;
|
||||
if (pausePolicy) pauseScalingForOperation(id);
|
||||
const job = db().prepare(`SELECT d.server,n.operation_id,n.stage_operation_id FROM server_deployments d
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id WHERE d.id=?`).get(id) as
|
||||
{ server: string; operation_id: string | null; stage_operation_id: string | null };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pauseScalingForOperation } from '../autoscaling/ownership.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { ensureHostSchema } from '../hosts/schema.js';
|
||||
import { recordProgress } from './progress.js';
|
||||
@@ -21,6 +22,7 @@ export function cancelProvisioningJob(id: string, reason = 'Cancelled by operato
|
||||
return db().transaction(() => {
|
||||
if (provisioningCancelled(id)) return true;
|
||||
if (!canCancelProvisioningJob(id)) return false;
|
||||
pauseScalingForOperation(id);
|
||||
const at = new Date().toISOString();
|
||||
// Only commands never delivered can be withdrawn. Delivered commands retain
|
||||
// their immutable IDs and receipts; replacements wait for their resolution.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pauseScalingForOperation } from '../autoscaling/ownership.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { recipeSummaryForDeployment } from '../fleet/launch-recipes.js';
|
||||
@@ -59,6 +60,7 @@ function warningMessages(raw: string): string[] {
|
||||
|
||||
function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): ProvisioningSource {
|
||||
const id = source.source_id;
|
||||
if (source.kind === 'autoscaling') return { ...source, label: 'Autoscaling policy', href: `/autoscaling#${encodeURIComponent(id)}` };
|
||||
if (source.kind === 'campaign') {
|
||||
const row = db().prepare(`SELECT d.action, d.beta_id, s.name FROM deployments d
|
||||
LEFT JOIN scripts s ON s.id=COALESCE(d.script_id, (SELECT script_id FROM deployment_scripts WHERE deployment_id=d.id ORDER BY script_id LIMIT 1))
|
||||
@@ -152,6 +154,7 @@ export function cancelRemoval(id: string): boolean {
|
||||
const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL
|
||||
WHERE id = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(finishedAt, id, REMOVING_SERVER);
|
||||
if (!result.changes) return false;
|
||||
pauseScalingForOperation(id);
|
||||
recordProgress(id, { blocker: null, checks: [], event: { kind: 'outcome',
|
||||
message: 'Queued removal cancelled. The server and its files were kept.',
|
||||
details: { state: 'cancelled', finishedAt } } });
|
||||
@@ -306,7 +309,7 @@ function deploymentListRows(query: ReturnType<typeof deploymentListQuery>, activ
|
||||
const cache = new Map<string, ProvisioningSource>();
|
||||
for (const row of rows) {
|
||||
const sources = byOperation.get(row.id) ?? [];
|
||||
const fallback = row.triggered_by.match(/^(campaign|beta|maintenance|watchdog):(.+)$/);
|
||||
const fallback = row.triggered_by.match(/^(campaign|beta|maintenance|watchdog|autoscaling):(.+)$/);
|
||||
if (fallback && !sources.some(source => source.kind === fallback[1] && source.source_id === fallback[2])) sources.unshift({ kind: fallback[1] as ProvisioningSource['kind'], source_id: fallback[2] });
|
||||
row.sources = sources.map(source => {
|
||||
const key = `${source.kind}:${source.source_id}`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { scalingOwnershipBlocker } from '../autoscaling/ownership.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
||||
import type { GameMode, ZombieGame } from '../fleet/map-aliases.js';
|
||||
@@ -68,6 +69,8 @@ function hasActiveCreation(name: string): boolean {
|
||||
}
|
||||
|
||||
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
|
||||
const owned = scalingOwnershipBlocker(name);
|
||||
if (owned) return { ok: false, kind: 'conflict', message: owned, warnings: [] };
|
||||
const cancelled = cancelQueuedNativeCreation(name, triggeredBy);
|
||||
if (cancelled) return { ok: true, warnings: [], deploymentId: cancelled };
|
||||
const blocker = activeBetaRemovalBlocker(name);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertScalingOwnership } from '../autoscaling/ownership.js';
|
||||
import { putFileBlob, getFileBlob, blobSize } from '../db/file-blobs.js';
|
||||
import { concurrencyBlocker, preparingJobs } from './concurrency.js';
|
||||
import { isIP } from 'node:net';
|
||||
@@ -152,11 +153,14 @@ export function queueNativeCreation(input: CreateServerInput): CreateServerResul
|
||||
|
||||
/** Persist the reviewed logical identity; all host/port/resource decisions belong to provisioning. */
|
||||
export function queueNativeCreationIntent(input: { server: string; game: 't5' | 't6'; mapSlug: string; selection: NativeSelection;
|
||||
plutoKey?: string; createdBy?: string; blueprintName?: string | null; recipe?: LaunchRecipePlan }): CreateServerResult {
|
||||
plutoKey?: string; createdBy?: string; blueprintName?: string | null; recipe?: LaunchRecipePlan; autoscalingDecisionId?: string }): CreateServerResult {
|
||||
try {
|
||||
return db().transaction(() => {
|
||||
assertScalingOwnership(input.game, input.mapSlug, input.autoscalingDecisionId);
|
||||
const expected = `${input.game}_zombie_${input.mapSlug}_${nextLobbyNumber(`${input.game}_zombie_${input.mapSlug}`)}`;
|
||||
if (input.server !== expected || nativeInstance(input.server)) throw new Error('Available lobby changed. Review a fresh preview.');
|
||||
const reserved = db().prepare(`SELECT 1 FROM server_creation_jobs j JOIN server_deployments d ON d.id=j.deployment_id WHERE j.server=?
|
||||
AND d.server_deleted_at IS NULL AND (d.state IN ('queued','waiting','running','awaiting-health','interrupted') OR (d.state='cancelled' AND d.submitted_at IS NOT NULL))`).get(input.server);
|
||||
if ((!input.autoscalingDecisionId && input.server !== expected) || nativeInstance(input.server) || getServer(input.server) || reserved) throw new Error('Available lobby changed. Review a fresh preview.');
|
||||
const key = input.plutoKey?.trim() || getServerKey(input.server);
|
||||
if (!key) throw new Error(`Save a Plutonium server key for ${input.server}.`);
|
||||
saveServerKey(input.server, key);
|
||||
@@ -402,7 +406,7 @@ function sameLaunchFiles(first: LaunchRecipePlan, second: LaunchRecipePlan): boo
|
||||
}
|
||||
|
||||
/** Capacity history and host ranking run in the background reader, one job at a time. */
|
||||
async function placeQueuedCreation(job: Job, instance: NativeInstance | null): Promise<NativeInstance | null> {
|
||||
async function placeQueuedCreation(job: Job, instance: NativeInstance | null, eligibility: (job: Job) => string | null): Promise<NativeInstance | null> {
|
||||
const recipe = recipeForDeployment(job.id)!.recipe as NativeRecipe;
|
||||
const prior: NativeSelection = job.selection ? JSON.parse(job.selection) : recipe.native!;
|
||||
const network = prior.automatic ? prior.network ?? {} : prior;
|
||||
@@ -413,6 +417,7 @@ async function placeQueuedCreation(job: Job, instance: NativeInstance | null): P
|
||||
return db().transaction(() => {
|
||||
const state = db().prepare('SELECT state FROM server_deployments WHERE id=? AND server_deleted_at IS NULL').get(job.id) as { state: string } | undefined;
|
||||
if (state?.state !== 'queued') return null;
|
||||
if (eligibility(job)) return null;
|
||||
const live = nativeInstance(job.server);
|
||||
if (instance ? live?.id !== instance.id || live.generation !== instance.generation : !!live || !!getServer(job.server)) return null;
|
||||
const active = (db().prepare(`SELECT count(*) AS n FROM server_deployments WHERE id!=? AND server_deleted_at IS NULL
|
||||
@@ -467,7 +472,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
const capacityBlocker = concurrencyBlocker(job.id, job.action);
|
||||
if (capacityBlocker) { hold(job, capacityBlocker); return; }
|
||||
if (eligibility(job)) return;
|
||||
instance = await placeQueuedCreation(job, instance);
|
||||
instance = await placeQueuedCreation(job, instance, eligibility);
|
||||
if (!instance) return;
|
||||
job.instance_id = instance.id; job.generation = instance.generation;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { scalingOwnershipBlocker } from '../autoscaling/ownership.js';
|
||||
import { maintenanceAllowed } from '../maintenance/policies.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
||||
@@ -120,6 +121,8 @@ export function releaseRestartRequest(server: string, source: RestartSource): vo
|
||||
|
||||
export function requestLifecycle(server: string, action: RuntimeAction) {
|
||||
if (!runtimeActions.includes(action)) return { ok: false as const, status: 400, error: 'Choose start, restart, rebuild, or stop.' };
|
||||
const owned = action !== 'restart' ? scalingOwnershipBlocker(server) : null;
|
||||
if (owned) return { ok: false as const, status: 409, error: owned };
|
||||
if (!nativeInstance(server) && !unplacedNativeCreation(server)) return { ok: false as const, status: 503, error: 'Server has no native host instance.' };
|
||||
return db().transaction(() => {
|
||||
const blocker = action === 'rebuild' ? activeBetaRemovalBlocker(server) : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { scalingAdmission } from '../autoscaling/admission.js';
|
||||
import { maintenanceAllowed } from '../maintenance/policies.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { getServer } from '../fleet/servers.js';
|
||||
@@ -93,6 +94,8 @@ export const processLifecycle = processProvisioning;
|
||||
|
||||
function nativePass(options: { wait?: boolean }): Promise<void> {
|
||||
return processNativeProvisioning(operation => {
|
||||
const scaling = scalingAdmission(operation.id);
|
||||
if (scaling) return scaling;
|
||||
if (operation.action === 'create') return null;
|
||||
if (operation.action === 'destroy') {
|
||||
const other = db().prepare(`SELECT id FROM server_deployments WHERE server=? AND id!=? AND server_deleted_at IS NULL
|
||||
|
||||
Reference in New Issue
Block a user