Skip to content

Commit dfbf5a1

Browse files
committed
fix(workflow): journal terminal transitions atomically
1 parent 09a7fc4 commit dfbf5a1

4 files changed

Lines changed: 163 additions & 100 deletions

File tree

src/workflow-cli.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -457,12 +457,7 @@ export async function runWorkflowWorker(
457457
}
458458
}
459459

460-
store.completeRun(runId, { resultJson });
461-
store.appendEvent({
462-
runId,
463-
type: "run_completed",
464-
data: { callCount },
465-
});
460+
store.completeRun(runId, { resultJson, callCount });
466461
} catch (error) {
467462
if (store.isCancelRequested(runId) || abort.signal.aborted) {
468463
try {
@@ -476,11 +471,6 @@ export async function runWorkflowWorker(
476471
const errorKind = mapEngineErrorKind(error);
477472
try {
478473
store.failRun(runId, { error: message, errorKind });
479-
store.appendEvent({
480-
runId,
481-
type: "run_failed",
482-
data: { error: message, errorKind },
483-
});
484474
} catch {
485475
// terminal race
486476
}

src/workflow-store.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from "node:assert/strict";
2+
import { spawnSync } from "node:child_process";
23
import { mkdtempSync, rmSync } from "node:fs";
34
import { tmpdir } from "node:os";
45
import { join } from "node:path";
@@ -56,12 +57,14 @@ try {
5657
const page1 = store.drainEvents(run.id, 0, 2);
5758
assert.equal(page1.events.length, 2);
5859
assert.equal(page1.nextSeq, 2);
60+
assert.equal(page1.hasMore, true);
5961
assert.equal(page1.terminal, false);
6062

6163
const page2 = store.drainEvents(run.id, 2, 10);
6264
assert.equal(page2.events.length, 1);
6365
assert.equal(page2.events[0]?.seq, 3);
6466
assert.equal(page2.nextSeq, 3);
67+
assert.equal(page2.hasMore, false);
6568

6669
store.beginAgentCall({
6770
runId: run.id,
@@ -120,7 +123,12 @@ try {
120123
assert.equal(terminal.errorKind, "cancelled");
121124
assert.equal(store.cancelRun(run.id).status, "cancelled");
122125

126+
const terminalPage1 = store.drainEvents(run.id, 0, 2);
127+
assert.equal(terminalPage1.hasMore, true);
128+
assert.equal(terminalPage1.terminal, false);
123129
const drainDone = store.drainEvents(run.id, 0, 100);
130+
assert.equal(drainDone.events.at(-1)?.type, "run_cancelled");
131+
assert.equal(drainDone.hasMore, false);
124132
assert.equal(drainDone.terminal, true);
125133

126134
const run2 = store.createRun({
@@ -159,7 +167,7 @@ try {
159167
store.listRunsForWorkspace(join(root, "other-project"))[0]?.id,
160168
otherProjectRun.id,
161169
);
162-
assert.deepEqual(store.listEvents(run.id, 2).map((event) => event.seq), [2, 3]);
170+
assert.deepEqual(store.listEvents(run.id, 2).map((event) => event.seq), [3, 4]);
163171

164172
// Reap: stale heartbeat + dead pid (force heartbeat via shared sqlite handle)
165173
const run3 = store.createRun({
@@ -169,7 +177,9 @@ try {
169177
scriptHash: "h3",
170178
workspaceRoot: join(root, "project"),
171179
});
172-
store.claimRun(run3.id, 2_147_483_646);
180+
const dead = spawnSync(process.execPath, ["-e", ""]);
181+
assert.ok(dead.pid);
182+
store.claimRun(run3.id, dead.pid);
173183
const db = openDatabase(root);
174184
try {
175185
db.sqlite
@@ -181,6 +191,7 @@ try {
181191
const reaped = store.reapStale(60_000);
182192
assert.ok(reaped.some((r) => r.id === run3.id && r.status === "failed"));
183193
assert.equal(store.getRun(run3.id)?.errorKind, "heartbeat");
194+
assert.equal(store.listEvents(run3.id).at(-1)?.type, "run_failed");
184195

185196
const run4 = store.createRun({
186197
name: "seq",

src/workflow-store.ts

Lines changed: 142 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export interface FailAgentCallInput {
8787

8888
export interface CompleteRunInput {
8989
resultJson?: string;
90+
callCount?: number;
9091
}
9192

9293
export interface FailRunInput {
@@ -97,6 +98,7 @@ export interface FailRunInput {
9798
export interface DrainEventsResult {
9899
events: WorkflowEventRecord[];
99100
nextSeq: number;
101+
hasMore: boolean;
100102
terminal: boolean;
101103
run: WorkflowRunRecord;
102104
}
@@ -409,11 +411,14 @@ export class WorkflowStore {
409411
const updated = Result.try({
410412
try: () => {
411413
const now = isoNow();
412-
this.database.sqlite
414+
const update = this.database.sqlite
413415
.prepare(
414-
`update workflow_runs set cancel_requested = 'true', updated_at = ? where id = ?`,
416+
`update workflow_runs
417+
set cancel_requested = 'true', updated_at = ?
418+
where id = ? and status in ('starting', 'running')`,
415419
)
416420
.run(now, id);
421+
if (update.changes === 0) return this.getRun(id);
417422
return this.getRun(id);
418423
},
419424
catch: (cause) => new WorkflowStoreError("request_cancel", cause),
@@ -439,18 +444,31 @@ export class WorkflowStore {
439444
return this.transitionRunResult(id, "complete", () => {
440445
if (input.resultJson !== undefined) assertResultSize(input.resultJson);
441446
const now = isoNow();
442-
return this.database.sqlite
443-
.prepare(
444-
`update workflow_runs set
445-
status = 'completed',
446-
result_json = ?,
447-
completed_at = ?,
448-
updated_at = ?,
449-
error = null,
450-
error_kind = null
451-
where id = ? and status in ('starting', 'running')`,
452-
)
453-
.run(input.resultJson ?? null, now, now, id).changes;
447+
const transaction = this.database.sqlite.transaction(() => {
448+
const changes = this.database.sqlite
449+
.prepare(
450+
`update workflow_runs set
451+
status = 'completed',
452+
result_json = ?,
453+
completed_at = ?,
454+
updated_at = ?,
455+
error = null,
456+
error_kind = null
457+
where id = ? and status in ('starting', 'running')`,
458+
)
459+
.run(input.resultJson ?? null, now, now, id).changes;
460+
if (changes === 0) return 0;
461+
this.insertEventRow(
462+
{
463+
runId: id,
464+
type: "run_completed",
465+
data: { callCount: input.callCount ?? 0 },
466+
},
467+
now,
468+
);
469+
return changes;
470+
});
471+
return transaction.immediate();
454472
});
455473
}
456474

@@ -464,17 +482,31 @@ export class WorkflowStore {
464482
): BetterResult<WorkflowRunRecord, WorkflowRunTransitionError> {
465483
return this.transitionRunResult(id, "fail", () => {
466484
const now = isoNow();
467-
return this.database.sqlite
468-
.prepare(
469-
`update workflow_runs set
470-
status = 'failed',
471-
error = ?,
472-
error_kind = ?,
473-
completed_at = ?,
474-
updated_at = ?
475-
where id = ? and status in ('starting', 'running')`,
476-
)
477-
.run(input.error, input.errorKind ?? "internal", now, now, id).changes;
485+
const errorKind = input.errorKind ?? "internal";
486+
const transaction = this.database.sqlite.transaction(() => {
487+
const changes = this.database.sqlite
488+
.prepare(
489+
`update workflow_runs set
490+
status = 'failed',
491+
error = ?,
492+
error_kind = ?,
493+
completed_at = ?,
494+
updated_at = ?
495+
where id = ? and status in ('starting', 'running')`,
496+
)
497+
.run(input.error, errorKind, now, now, id).changes;
498+
if (changes === 0) return 0;
499+
this.insertEventRow(
500+
{
501+
runId: id,
502+
type: "run_failed",
503+
data: { error: input.error, errorKind },
504+
},
505+
now,
506+
);
507+
return changes;
508+
});
509+
return transaction.immediate();
478510
});
479511
}
480512

@@ -488,18 +520,31 @@ export class WorkflowStore {
488520
): BetterResult<WorkflowRunRecord, WorkflowRunTransitionError> {
489521
return this.transitionRunResult(id, "cancel", () => {
490522
const now = isoNow();
491-
return this.database.sqlite
492-
.prepare(
493-
`update workflow_runs set
494-
status = 'cancelled',
495-
error = ?,
496-
error_kind = 'cancelled',
497-
cancel_requested = 'true',
498-
completed_at = ?,
499-
updated_at = ?
500-
where id = ? and status in ('starting', 'running')`,
501-
)
502-
.run(error, now, now, id).changes;
523+
const transaction = this.database.sqlite.transaction(() => {
524+
const changes = this.database.sqlite
525+
.prepare(
526+
`update workflow_runs set
527+
status = 'cancelled',
528+
error = ?,
529+
error_kind = 'cancelled',
530+
cancel_requested = 'true',
531+
completed_at = ?,
532+
updated_at = ?
533+
where id = ? and status in ('starting', 'running')`,
534+
)
535+
.run(error, now, now, id).changes;
536+
if (changes === 0) return 0;
537+
this.insertEventRow(
538+
{
539+
runId: id,
540+
type: "run_cancelled",
541+
data: { reason: error },
542+
},
543+
now,
544+
);
545+
return changes;
546+
});
547+
return transaction.immediate();
503548
});
504549
}
505550

@@ -540,47 +585,11 @@ export class WorkflowStore {
540585
}
541586

542587
appendEvent(input: AppendWorkflowEventInput): WorkflowEventRecord {
543-
const payload = parseWorkflowEventPayload(input.type, input.data);
544-
const dataJson = truncateJson(payload, WORKFLOW_LIMITS.eventDataJsonBytes);
545588
const createdAt = isoNow();
546-
547-
const insert = this.database.sqlite.transaction(() => {
548-
const next = this.database.sqlite
549-
.prepare(
550-
`select coalesce(max(seq), 0) + 1 as next_seq from workflow_events where run_id = ?`,
551-
)
552-
.get(input.runId) as { next_seq: number };
553-
const seq = next.next_seq;
554-
this.database.sqlite
555-
.prepare(
556-
`insert into workflow_events (run_id, seq, type, phase, label, data_json, created_at)
557-
values (?, ?, ?, ?, ?, ?, ?)`,
558-
)
559-
.run(
560-
input.runId,
561-
seq,
562-
input.type,
563-
input.phase ?? null,
564-
input.label ?? null,
565-
dataJson,
566-
createdAt,
567-
);
568-
this.database.sqlite
569-
.prepare(`update workflow_runs set updated_at = ? where id = ?`)
570-
.run(createdAt, input.runId);
571-
return seq;
572-
});
573-
574-
const seq = insert();
575-
return {
576-
runId: input.runId,
577-
seq,
578-
type: input.type,
579-
phase: input.phase,
580-
label: input.label,
581-
dataJson,
582-
createdAt,
583-
};
589+
const transaction = this.database.sqlite.transaction(() =>
590+
this.insertEventRow(input, createdAt),
591+
);
592+
return transaction.immediate();
584593
}
585594

586595
drainEvents(runId: string, sinceSeq = 0, limit: number = WORKFLOW_LIMITS.eventDrainDefault): DrainEventsResult {
@@ -593,13 +602,15 @@ export class WorkflowStore {
593602
order by seq asc
594603
limit ?`,
595604
)
596-
.all(runId, sinceSeq, capped) as WorkflowEventRow[];
597-
const events = rows.map(rowToEvent);
605+
.all(runId, sinceSeq, capped + 1) as WorkflowEventRow[];
606+
const hasMore = rows.length > capped;
607+
const events = rows.slice(0, capped).map(rowToEvent);
598608
const nextSeq = events.length > 0 ? events[events.length - 1]!.seq : sinceSeq;
599609
return {
600610
events,
601611
nextSeq,
602-
terminal: TERMINAL_STATUSES.has(run.status),
612+
hasMore,
613+
terminal: TERMINAL_STATUSES.has(run.status) && !hasMore,
603614
run,
604615
};
605616
}
@@ -755,16 +766,59 @@ export class WorkflowStore {
755766
const reaped: WorkflowRunRecord[] = [];
756767
for (const row of candidates) {
757768
if (row.pid !== null && isPidAlive(row.pid)) continue;
758-
reaped.push(
759-
this.failRun(row.id, {
760-
error: "worker heartbeat lost",
761-
errorKind: "heartbeat",
762-
}),
763-
);
769+
const latest = this.getRun(row.id);
770+
if (!latest || latest.status !== "running") continue;
771+
const failed = this.failRun(row.id, {
772+
error: "worker heartbeat lost",
773+
errorKind: "heartbeat",
774+
});
775+
if (failed.status === "failed" && failed.errorKind === "heartbeat") {
776+
reaped.push(failed);
777+
}
764778
}
765779
return reaped;
766780
}
767781

782+
private insertEventRow(
783+
input: AppendWorkflowEventInput,
784+
createdAt: string,
785+
): WorkflowEventRecord {
786+
const payload = parseWorkflowEventPayload(input.type, input.data);
787+
const dataJson = truncateJson(payload, WORKFLOW_LIMITS.eventDataJsonBytes);
788+
const next = this.database.sqlite
789+
.prepare(
790+
`select coalesce(max(seq), 0) + 1 as next_seq from workflow_events where run_id = ?`,
791+
)
792+
.get(input.runId) as { next_seq: number };
793+
const seq = next.next_seq;
794+
this.database.sqlite
795+
.prepare(
796+
`insert into workflow_events (run_id, seq, type, phase, label, data_json, created_at)
797+
values (?, ?, ?, ?, ?, ?, ?)`,
798+
)
799+
.run(
800+
input.runId,
801+
seq,
802+
input.type,
803+
input.phase ?? null,
804+
input.label ?? null,
805+
dataJson,
806+
createdAt,
807+
);
808+
this.database.sqlite
809+
.prepare(`update workflow_runs set updated_at = ? where id = ?`)
810+
.run(createdAt, input.runId);
811+
return {
812+
runId: input.runId,
813+
seq,
814+
type: input.type,
815+
phase: input.phase,
816+
label: input.label,
817+
dataJson,
818+
createdAt,
819+
};
820+
}
821+
768822
close(): void {
769823
this.database.close();
770824
}
@@ -868,7 +922,8 @@ function isPidAlive(pid: number): boolean {
868922
try {
869923
process.kill(pid, 0);
870924
return true;
871-
} catch {
925+
} catch (error) {
926+
if ((error as NodeJS.ErrnoException).code === "EPERM") return true;
872927
return false;
873928
}
874929
}

0 commit comments

Comments
 (0)