fix(stacks): serialize concurrent lifecycle operations per stack (#1182)

* fix(stacks): serialize concurrent lifecycle operations per stack

Two simultaneous POSTs to /api/stacks/:name/{deploy,down,restart,stop,
start,update} could race against the same compose project, doubling
notifications, doubling post-deploy scans, and corrupting the
atomic-deploy backup snapshot. Each lifecycle route now acquires a
per-(nodeId, stackName) in-process lock; the second caller gets 409
with {code: 'stack_op_in_progress', inProgress: {action, startedAt,
user}} and the frontend surfaces a "X is already deploying" toast.

The lock is process-local on purpose: it shares a lifetime with the
docker compose child process. A Sencho restart clears all locks, which
matches the truth that an in-flight compose op is gone too.

The existing policy-block 409 is shape-distinguishable (has policy /
violations) and continues to work; the frontend checks the new code
discriminator first before falling through to policy handling.

* chore(stacks): validate action enum in 409 parser; cover start collision

The frontend parseStackOpInProgress used to cast the parsed action
directly to StackOpAction. A backend bug or spoofed payload returning
action='wibble' would slip through. Validate against the known enum
set before returning the parsed info.

Adds an integration test for the deploy-blocks-while-start-in-flight
case so all six lifecycle verbs have collision coverage (the existing
suite covered deploy/down/restart/stop/update; start was indirect).
This commit is contained in:
Anso
2026-05-24 01:12:07 -04:00
committed by GitHub
parent 8ba88755b1
commit 60ecd574b3
5 changed files with 621 additions and 20 deletions
@@ -0,0 +1,70 @@
/**
* Tracks in-flight stack lifecycle operations (deploy, down, restart, stop,
* start, update) per (nodeId, stackName). A second request to the same stack
* while the first is still running returns 409 instead of racing the first.
*
* State is intentionally process-local: a Sencho restart clears all locks,
* which matches the lifecycle of any in-flight `docker compose` child process.
*/
export type StackOpAction = 'deploy' | 'down' | 'restart' | 'stop' | 'start' | 'update';
export interface StackOpLock {
action: StackOpAction;
startedAt: number;
user: string;
}
interface AcquireSuccess {
acquired: true;
}
interface AcquireConflict {
acquired: false;
existing: StackOpLock;
}
export type AcquireResult = AcquireSuccess | AcquireConflict;
export class StackOpLockService {
private static instance: StackOpLockService;
private readonly locks = new Map<string, StackOpLock>();
public static getInstance(): StackOpLockService {
if (!this.instance) this.instance = new StackOpLockService();
return this.instance;
}
public static resetForTests(): void {
this.instance = new StackOpLockService();
}
private key(nodeId: number, stackName: string): string {
return `${nodeId}:${stackName}`;
}
public tryAcquire(
nodeId: number,
stackName: string,
action: StackOpAction,
user: string,
): AcquireResult {
const k = this.key(nodeId, stackName);
const existing = this.locks.get(k);
if (existing) return { acquired: false, existing };
this.locks.set(k, { action, startedAt: Date.now(), user });
return { acquired: true };
}
public release(nodeId: number, stackName: string): void {
this.locks.delete(this.key(nodeId, stackName));
}
public get(nodeId: number, stackName: string): StackOpLock | undefined {
return this.locks.get(this.key(nodeId, stackName));
}
public size(): number {
return this.locks.size;
}
}