From 61a5aee48def8f95d12415d9547f3ae2eabe1216 Mon Sep 17 00:00:00 2001 From: chick Date: Mon, 7 Sep 2026 05:57:35 +0800 Subject: [PATCH] fix(events): close review gaps found in the follow-up sweep - deletion flow emitted job/instance envelopes before the transaction that closed the job rows; subscribers refetching on the notification saw status 'running' with no further event. Emits now happen after commit, including the failure path - interrupted-deletion sweep now emits job envelopes with the job's real request_id (it previously stayed silent while the secure-execution sweep already emitted) - verified during the sweep: backup restore validation re-derives the expected schema via migrateDatabase, so migration 18 is automatically covered; no tables reference audit_events or operation_preparations, so the retention deletes cannot trip FK constraints --- .../operations/delete-instance-operation.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/api/src/application/operations/delete-instance-operation.ts b/apps/api/src/application/operations/delete-instance-operation.ts index b04fcfe..ec3c2ed 100644 --- a/apps/api/src/application/operations/delete-instance-operation.ts +++ b/apps/api/src/application/operations/delete-instance-operation.ts @@ -328,14 +328,6 @@ export class DeleteInstanceOperation { } try { await this.instances.delete(input.instanceId, input.revision, { allowJobHistory: true }); - this.#emit({ - kind: 'instance', - id: this.id(), - occurredAt: this.clock().toISOString(), - requestId: input.requestId, - instanceId: input.instanceId, - }); - this.#emitJob(ids.job, input.requestId); const finished = this.clock().toISOString(); this.db.transaction(() => { this.db @@ -356,6 +348,16 @@ export class DeleteInstanceOperation { ) .run(finished, finished, ids.job); })(); + // Events only after the terminal rows are committed, so a subscriber that + // refetches on the notification sees the finished job, not 'running'. + this.#emit({ + kind: 'instance', + id: this.id(), + occurredAt: this.clock().toISOString(), + requestId: input.requestId, + instanceId: input.instanceId, + }); + this.#emitJob(ids.job, input.requestId); } catch (error) { const finished = this.clock().toISOString(); const state = error instanceof InstanceServiceError ? 'failed' : 'unknown-result'; @@ -375,6 +377,7 @@ export class DeleteInstanceOperation { .prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?') .run(state, finished, finished, ids.job); })(); + this.#emitJob(ids.job, input.requestId); } return this.job(ids.job); } @@ -449,9 +452,10 @@ export class DeleteInstanceOperation { const finished = this.clock().toISOString(); return this.db.transaction(() => { const jobs = this.db - .prepare("SELECT id FROM jobs WHERE operation_id=? AND status='running'") - .all(OPERATION_ID) as Array<{ id: string }>; - for (const { id } of jobs) { + .prepare("SELECT id, request_id FROM jobs WHERE operation_id=? AND status='running'") + .all(OPERATION_ID) as Array<{ id: string; request_id: string }>; + for (const { id, request_id: requestId } of jobs) { + this.#emitJob(id, requestId); this.db .prepare( "UPDATE job_items SET status='unknown-result',result_code='INTERRUPTED',finished_at=?,updated_at=? WHERE job_id=? AND status='running'",