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
This commit is contained in:
chick
2026-09-07 05:57:35 +08:00
parent 70e598208a
commit 61a5aee48d
@@ -328,14 +328,6 @@ export class DeleteInstanceOperation {
} }
try { try {
await this.instances.delete(input.instanceId, input.revision, { allowJobHistory: true }); 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(); const finished = this.clock().toISOString();
this.db.transaction(() => { this.db.transaction(() => {
this.db this.db
@@ -356,6 +348,16 @@ export class DeleteInstanceOperation {
) )
.run(finished, finished, ids.job); .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) { } catch (error) {
const finished = this.clock().toISOString(); const finished = this.clock().toISOString();
const state = error instanceof InstanceServiceError ? 'failed' : 'unknown-result'; 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=?') .prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?')
.run(state, finished, finished, ids.job); .run(state, finished, finished, ids.job);
})(); })();
this.#emitJob(ids.job, input.requestId);
} }
return this.job(ids.job); return this.job(ids.job);
} }
@@ -449,9 +452,10 @@ export class DeleteInstanceOperation {
const finished = this.clock().toISOString(); const finished = this.clock().toISOString();
return this.db.transaction(() => { return this.db.transaction(() => {
const jobs = this.db const jobs = this.db
.prepare("SELECT id FROM jobs WHERE operation_id=? AND status='running'") .prepare("SELECT id, request_id FROM jobs WHERE operation_id=? AND status='running'")
.all(OPERATION_ID) as Array<{ id: string }>; .all(OPERATION_ID) as Array<{ id: string; request_id: string }>;
for (const { id } of jobs) { for (const { id, request_id: requestId } of jobs) {
this.#emitJob(id, requestId);
this.db this.db
.prepare( .prepare(
"UPDATE job_items SET status='unknown-result',result_code='INTERRUPTED',finished_at=?,updated_at=? WHERE job_id=? AND status='running'", "UPDATE job_items SET status='unknown-result',result_code='INTERRUPTED',finished_at=?,updated_at=? WHERE job_id=? AND status='running'",