Skip to content

Commit a8c6323

Browse files
committed
fix(run-store): commit run snapshots and their completed-waitpoint links atomically
createExecutionSnapshot and lockRunToWorker wrote the execution snapshot and its completed-waitpoint join rows as two separate statements, and createRun and createFailedRun (dedicated schema) wrote the run and its associated waitpoint separately. Served back from a read replica that applied the snapshot but not yet the join, the runner gets a waitpoint-less continue and never resumes, so the run hangs (or partial state persists on a crash between the writes). Wrap each primary write and its dependent write in one transaction, reusing the caller's transaction when it already has a real one, so a replica can never observe the partial state.
1 parent f101983 commit a8c6323

3 files changed

Lines changed: 315 additions & 16 deletions

File tree

internal-packages/run-store/src/PostgresRunStore.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,124 @@ describe("PostgresRunStore", () => {
888888
}
889889
);
890890

891+
// lockRunToWorker creates a PENDING_EXECUTING snapshot (which can inherit completed waitpoints) and
892+
// then connects the _completedWaitpoints join as a separate statement. These must commit together, or
893+
// a replica-served /snapshots/since read can return the snapshot waitpoint-less and drop the resume.
894+
postgresTest(
895+
"lockRunToWorker writes the snapshot and its completed-waitpoint links atomically",
896+
async ({ prisma }) => {
897+
const { organization, project, environment } = await seedEnvironment(prisma);
898+
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
899+
const runId = "run_lock_atomic";
900+
901+
await store.createRun(
902+
buildCreateRunInput({
903+
runId,
904+
organizationId: organization.id,
905+
projectId: project.id,
906+
runtimeEnvironmentId: environment.id,
907+
})
908+
);
909+
910+
const backgroundWorker = await prisma.backgroundWorker.create({
911+
data: {
912+
friendlyId: "worker_atomic",
913+
version: "20260601.1",
914+
runtimeEnvironmentId: environment.id,
915+
projectId: project.id,
916+
contentHash: "abc",
917+
sdkVersion: "3.0.0",
918+
cliVersion: "3.0.0",
919+
metadata: {},
920+
},
921+
});
922+
const workerTask = await prisma.backgroundWorkerTask.create({
923+
data: {
924+
friendlyId: "task_atomic",
925+
slug: "my-task",
926+
filePath: "src/my-task.ts",
927+
exportName: "myTask",
928+
workerId: backgroundWorker.id,
929+
runtimeEnvironmentId: environment.id,
930+
projectId: project.id,
931+
},
932+
});
933+
const queue = await prisma.taskQueue.create({
934+
data: {
935+
friendlyId: "queue_atomic",
936+
name: "task/my-task",
937+
runtimeEnvironmentId: environment.id,
938+
projectId: project.id,
939+
},
940+
});
941+
const priorSnapshot = await prisma.taskRunExecutionSnapshot.create({
942+
data: {
943+
engine: "V2",
944+
executionStatus: "RUN_CREATED",
945+
description: "prior",
946+
runStatus: "PENDING",
947+
environmentId: environment.id,
948+
environmentType: "DEVELOPMENT",
949+
projectId: project.id,
950+
organizationId: organization.id,
951+
runId,
952+
},
953+
});
954+
const waitpoint = await prisma.waitpoint.create({
955+
data: {
956+
friendlyId: "wp_lock_atomic",
957+
type: "MANUAL",
958+
status: "COMPLETED",
959+
idempotencyKey: "idem-lock-atomic",
960+
userProvidedIdempotencyKey: false,
961+
projectId: project.id,
962+
environmentId: environment.id,
963+
},
964+
});
965+
966+
// Force the completed-waitpoint join insert to fail mid-write.
967+
await prisma.$executeRawUnsafe('DROP TABLE "_completedWaitpoints"');
968+
969+
const snapshotId = "snap_lock_atomic";
970+
await expect(
971+
// Base client as `tx` = how the dequeue path calls it; the store must still open its own tx.
972+
store.lockRunToWorker(
973+
runId,
974+
{
975+
lockedAt: new Date(),
976+
lockedById: workerTask.id,
977+
lockedToVersionId: backgroundWorker.id,
978+
lockedQueueId: queue.id,
979+
startedAt: new Date(),
980+
baseCostInCents: 5,
981+
machinePreset: "small-1x",
982+
taskVersion: "20260601.1",
983+
sdkVersion: "3.0.0",
984+
cliVersion: "3.0.0",
985+
maxDurationInSeconds: null,
986+
snapshot: {
987+
id: snapshotId,
988+
previousSnapshotId: priorSnapshot.id,
989+
environmentId: environment.id,
990+
environmentType: "DEVELOPMENT",
991+
projectId: project.id,
992+
organizationId: organization.id,
993+
completedWaitpointIds: [waitpoint.id],
994+
completedWaitpointOrder: [waitpoint.id],
995+
},
996+
},
997+
prisma
998+
)
999+
).rejects.toThrow();
1000+
1001+
// Atomic: neither the snapshot nor the run lock persists without the links.
1002+
const snap = await prisma.taskRunExecutionSnapshot.findUnique({ where: { id: snapshotId } });
1003+
expect(snap).toBeNull();
1004+
const run = await prisma.taskRun.findUniqueOrThrow({ where: { id: runId } });
1005+
expect(run.status).not.toBe("DEQUEUED");
1006+
}
1007+
);
1008+
8911009
postgresTest(
8921010
"parkPendingVersion sets status to PENDING_VERSION and stores statusReason",
8931011
async ({ prisma }) => {

internal-packages/run-store/src/PostgresRunStore.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,25 @@ export class PostgresRunStore implements RunStore {
527527
);
528528
}
529529

530+
// Run `fn` atomically: reuse the caller's interactive transaction if it gave us a real one, else open
531+
// our own on this store's writer. A real interactive tx has no `$transaction` method; a base client
532+
// (which callers, e.g. the engine's dequeue/resume paths, thread through for routing) DOES - so a base
533+
// client still gets a fresh transaction. Used by write methods that create a row plus dependent rows
534+
// (snapshot + completed-waitpoints, run + associated-waitpoint) which must commit together.
535+
#withOptionalTransaction<R>(
536+
tx: PrismaClientOrTransaction | undefined,
537+
fn: (client: PrismaClientOrTransaction) => Promise<R>
538+
): Promise<R> {
539+
const alreadyInTransaction =
540+
tx !== undefined && typeof (tx as { $transaction?: unknown }).$transaction !== "function";
541+
if (alreadyInTransaction) {
542+
return fn(tx);
543+
}
544+
return (this.prisma as RunOpsTransactionalClient).$transaction((t) =>
545+
fn(t as unknown as PrismaClientOrTransaction)
546+
);
547+
}
548+
530549
async createRun(
531550
params: CreateRunInput,
532551
tx?: PrismaClientOrTransaction
@@ -547,15 +566,19 @@ export class PostgresRunStore implements RunStore {
547566
};
548567

549568
if (this.schemaVariant === "dedicated") {
550-
const run = (await client.taskRun.create({
551-
data: {
552-
...params.data,
553-
executionSnapshots: { create: snapshotCreate },
554-
},
555-
})) as TaskRun;
569+
// The run + its associated RUN-type waitpoint are two writes here (the legacy branch below nests
570+
// them). Commit them together so a crash / lagging read never leaves a run without its waitpoint.
571+
return this.#withOptionalTransaction(tx, async (c) => {
572+
const run = (await c.taskRun.create({
573+
data: {
574+
...params.data,
575+
executionSnapshots: { create: snapshotCreate },
576+
},
577+
})) as TaskRun;
556578

557-
const associatedWaitpoint = await this.#createAssociatedWaitpoint(client, run.id, params);
558-
return { ...run, associatedWaitpoint };
579+
const associatedWaitpoint = await this.#createAssociatedWaitpoint(c, run.id, params);
580+
return { ...run, associatedWaitpoint };
581+
});
559582
}
560583

561584
return client.taskRun.create({
@@ -633,12 +656,15 @@ export class PostgresRunStore implements RunStore {
633656
const client = tx ?? this.prisma;
634657

635658
if (this.schemaVariant === "dedicated") {
636-
const run = (await client.taskRun.create({
637-
data: { ...params.data },
638-
})) as TaskRun;
639-
640-
const associatedWaitpoint = await this.#createAssociatedWaitpoint(client, run.id, params);
641-
return { ...run, associatedWaitpoint };
659+
// Run + associated RUN-type waitpoint are two writes here; commit them together (see createRun).
660+
return this.#withOptionalTransaction(tx, async (c) => {
661+
const run = (await c.taskRun.create({
662+
data: { ...params.data },
663+
})) as TaskRun;
664+
665+
const associatedWaitpoint = await this.#createAssociatedWaitpoint(c, run.id, params);
666+
return { ...run, associatedWaitpoint };
667+
});
642668
}
643669

644670
return client.taskRun.create({
@@ -954,8 +980,17 @@ export class PostgresRunStore implements RunStore {
954980
data: LockRunData,
955981
tx?: PrismaClientOrTransaction
956982
): Promise<Prisma.TaskRunGetPayload<{}>> {
957-
const prisma = tx ?? this.prisma;
983+
// The run-lock update (with its nested PENDING_EXECUTING snapshot) and the completed-waitpoint
984+
// connect must commit together, or a replica-served resume read can see the snapshot without its
985+
// links and drop the resume.
986+
return this.#withOptionalTransaction(tx, (c) => this.#lockRunToWorker(runId, data, c));
987+
}
958988

989+
async #lockRunToWorker(
990+
runId: string,
991+
data: LockRunData,
992+
prisma: PrismaClientOrTransaction
993+
): Promise<Prisma.TaskRunGetPayload<{}>> {
959994
const dedicated = this.schemaVariant === "dedicated";
960995

961996
const result = await prisma.taskRun.update({
@@ -1533,8 +1568,17 @@ export class PostgresRunStore implements RunStore {
15331568
input: CreateExecutionSnapshotInput,
15341569
tx?: PrismaClientOrTransaction
15351570
): Promise<Prisma.TaskRunExecutionSnapshotGetPayload<{ include: { checkpoint: true } }>> {
1536-
const prisma = tx ?? this.prisma;
1571+
// The snapshot row and its completed-waitpoint join rows MUST commit together. `/snapshots/since`
1572+
// can be served from a lagging read replica, so a snapshot that commits before its links can be
1573+
// read back waitpoint-less and the runner's resume is lost (the run hangs). This is the warm-continue
1574+
// path: the engine threads its base prisma through as `tx`, which is not a real transaction.
1575+
return this.#withOptionalTransaction(tx, (c) => this.#createExecutionSnapshot(input, c));
1576+
}
15371577

1578+
async #createExecutionSnapshot(
1579+
input: CreateExecutionSnapshotInput,
1580+
prisma: PrismaClientOrTransaction
1581+
): Promise<Prisma.TaskRunExecutionSnapshotGetPayload<{ include: { checkpoint: true } }>> {
15381582
const {
15391583
run,
15401584
snapshot,

internal-packages/run-store/src/PostgresRunStore.writeAtomicity.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,71 @@ describe("fan-out deleteManyTaskRunWaitpoints honors the caller's tx on the #leg
396396
);
397397
});
398398

399+
// createExecutionSnapshot writes the snapshot row and its completed-waitpoint join rows. These MUST
400+
// commit together: with the flag off, `/snapshots/since` is served from a lagging read replica, so a
401+
// snapshot that commits before its `_completedWaitpoints` rows can be read waitpoint-less, and the
402+
// runner's EXECUTING branch no-ops on an empty completedWaitpoints -> the resume is lost -> hang.
403+
describe("createExecutionSnapshot writes the snapshot and its completed-waitpoint links atomically", () => {
404+
heteroRunOpsPostgresTest(
405+
"rolls the snapshot back if the completed-waitpoint insert fails (no waitpoint-less snapshot persists)",
406+
async ({ prisma14 }) => {
407+
const legacy = makeLegacyStore(prisma14);
408+
const env = await seedEnvironment(prisma14, "legacy", "ces_atomic");
409+
const runId = `run_${CUID_25}`;
410+
await legacy.createRun(
411+
buildCreateRunInput({
412+
runId,
413+
friendlyId: "run_ces_atomic",
414+
organizationId: env.organization.id,
415+
projectId: env.project.id,
416+
runtimeEnvironmentId: env.environment.id,
417+
})
418+
);
419+
const waitpoint = await prisma14.waitpoint.create({
420+
data: {
421+
friendlyId: "wp_ces_atomic",
422+
type: "MANUAL",
423+
status: "COMPLETED",
424+
idempotencyKey: "idem-ces_atomic",
425+
userProvidedIdempotencyKey: false,
426+
projectId: env.project.id,
427+
environmentId: env.environment.id,
428+
},
429+
});
430+
431+
// Force the completed-waitpoint join insert to fail mid-write.
432+
await prisma14.$executeRawUnsafe('DROP TABLE "_completedWaitpoints"');
433+
434+
await expect(
435+
// Pass the base client as `tx` - exactly how the engine threads its base prisma through
436+
// (continueRunIfUnblocked -> executionSnapshotSystem.createExecutionSnapshot(prisma, ...)).
437+
// It is NOT an interactive transaction, so the store must still open its own to stay atomic.
438+
legacy.createExecutionSnapshot(
439+
{
440+
run: { id: runId, status: "EXECUTING", attemptNumber: 1 },
441+
snapshot: {
442+
executionStatus: "EXECUTING_WITH_WAITPOINTS",
443+
description: "Run was blocked by a waitpoint.",
444+
},
445+
environmentId: env.environment.id,
446+
environmentType: "DEVELOPMENT",
447+
projectId: env.project.id,
448+
organizationId: env.project.id,
449+
completedWaitpoints: [{ id: waitpoint.id, index: 0 }],
450+
},
451+
prisma14
452+
)
453+
).rejects.toThrow();
454+
455+
// The snapshot must NOT persist without its links, or a replica can serve it waitpoint-less.
456+
const snap = await prisma14.taskRunExecutionSnapshot.findFirst({
457+
where: { runId, executionStatus: "EXECUTING_WITH_WAITPOINTS" },
458+
});
459+
expect(snap).toBeNull();
460+
}
461+
);
462+
});
463+
399464
// RoutingRunStore.createExecutionSnapshot accepts a caller tx but must forward it to the OWNING store
400465
// only when that store is #legacy: a control-plane tx can't wrap a #new (cross-DB) write, but it can
401466
// (and should) wrap a legacy-resident snapshot so it stays atomic with the caller's operation.
@@ -457,3 +522,75 @@ describe("createExecutionSnapshot honors the caller's tx on the #legacy owning s
457522
}
458523
);
459524
});
525+
526+
// On the dedicated subset schema the associated (RUN-type) waitpoint is created as a SEPARATE
527+
// waitpoint.create after taskRun.create (the legacy schema nests it atomically). The pair must commit
528+
// together, or a crash / lagging read leaves a run with no completion waitpoint and its parent never resumes.
529+
function assocWaitpoint(
530+
env: { project: { id: string }; environment: { id: string } },
531+
suffix: string
532+
) {
533+
return {
534+
id: `wp_${suffix}`,
535+
friendlyId: `waitpoint_${suffix}`,
536+
type: "RUN" as const,
537+
status: "PENDING" as const,
538+
idempotencyKey: `idem_${suffix}`,
539+
userProvidedIdempotencyKey: false,
540+
projectId: env.project.id,
541+
environmentId: env.environment.id,
542+
};
543+
}
544+
545+
describe("createRun / createFailedRun write the run and its associated waitpoint atomically (dedicated)", () => {
546+
heteroRunOpsPostgresTest(
547+
"createRun rolls the run back if the associated-waitpoint create fails",
548+
async ({ prisma17 }) => {
549+
const newStore = makeDedicatedStore(prisma17);
550+
const env = await seedEnvironment(prisma17, "dedicated", "cr_atomic");
551+
const runId = `run_${NEW_ID_26}`;
552+
const input = {
553+
...buildCreateRunInput({
554+
runId,
555+
friendlyId: "run_cr_atomic",
556+
organizationId: env.organization.id,
557+
projectId: env.project.id,
558+
runtimeEnvironmentId: env.environment.id,
559+
}),
560+
associatedWaitpoint: assocWaitpoint(env, "cr_atomic"),
561+
};
562+
563+
// Force #createAssociatedWaitpoint (waitpoint.create) to fail after taskRun.create.
564+
await prisma17.$executeRawUnsafe('DROP TABLE "Waitpoint"');
565+
566+
await expect(newStore.createRun(input)).rejects.toThrow();
567+
568+
const run = await prisma17.taskRun.findFirst({ where: { id: runId } });
569+
expect(run).toBeNull();
570+
}
571+
);
572+
573+
heteroRunOpsPostgresTest(
574+
"createFailedRun rolls the run back if the associated-waitpoint create fails",
575+
async ({ prisma17 }) => {
576+
const newStore = makeDedicatedStore(prisma17);
577+
const env = await seedEnvironment(prisma17, "dedicated", "cf_atomic");
578+
const runId = `run_${NEW_ID_26}`;
579+
const base = buildCreateRunInput({
580+
runId,
581+
friendlyId: "run_cf_atomic",
582+
organizationId: env.organization.id,
583+
projectId: env.project.id,
584+
runtimeEnvironmentId: env.environment.id,
585+
});
586+
const input = { data: base.data, associatedWaitpoint: assocWaitpoint(env, "cf_atomic") };
587+
588+
await prisma17.$executeRawUnsafe('DROP TABLE "Waitpoint"');
589+
590+
await expect(newStore.createFailedRun(input)).rejects.toThrow();
591+
592+
const run = await prisma17.taskRun.findFirst({ where: { id: runId } });
593+
expect(run).toBeNull();
594+
}
595+
);
596+
});

0 commit comments

Comments
 (0)