Skip to content

Commit b208062

Browse files
authored
fix(run-store): stop run-create failing on a brief write stall (#4514)
## Summary On the run-ops store, creating a run could intermittently fail with a "Transaction already closed" error, and the run would never be created. Single-write run creates no longer run inside an interactive transaction, so a brief database write stall can't blow the transaction budget and drop the run. ## Fix The dedicated run-ops `createRun` / `createFailedRun` wrapped a single nested `taskRun.create` in an interactive `$transaction`. Its default 5s budget is wall-clock from `BEGIN`, so when a write briefly stalls the transaction expires before the create completes and throws, even though the statement itself is fast at the database. A single-write create does not need an interactive transaction: Prisma's implicit nested create is already atomic and holds no app-side budget, so it now runs directly. Only the `triggerAndWait` path (run plus its associated waitpoint, two writes that must commit together) keeps an interactive transaction, now with headroom over the default. Verified with a red/green test against the real split topology (reproduces the exact expiry on the unchanged code, green after) and an end-to-end run created and completed through the dedicated store.
1 parent 58bf4e2 commit b208062

3 files changed

Lines changed: 156 additions & 17 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Triggering a task no longer intermittently fails to create the run when a database write briefly stalls.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
2+
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
3+
import { describe, expect } from "vitest";
4+
import { PostgresRunStore } from "./PostgresRunStore.js";
5+
import type { CreateRunInput } from "./types.js";
6+
7+
const NEW_ID_26 = "k".repeat(24) + "01";
8+
9+
function makeDedicatedStore(prisma17: RunOpsPrismaClient) {
10+
return new PostgresRunStore({
11+
prisma: prisma17 as never,
12+
readOnlyPrisma: prisma17 as never,
13+
schemaVariant: "dedicated",
14+
});
15+
}
16+
17+
function trackInteractiveTx(prisma17: RunOpsPrismaClient) {
18+
const original = prisma17.$transaction.bind(prisma17);
19+
const state = { interactiveCalls: 0 };
20+
(prisma17 as { $transaction: unknown }).$transaction = (
21+
arg: unknown,
22+
options?: { timeout?: number; maxWait?: number }
23+
) => {
24+
if (typeof arg === "function") {
25+
state.interactiveCalls += 1;
26+
return (original as (fn: unknown, o?: unknown) => unknown)(arg, { ...options, timeout: 1 });
27+
}
28+
return (original as (a: unknown, o?: unknown) => unknown)(arg, options);
29+
};
30+
return state;
31+
}
32+
33+
function buildCreateRunInput(params: {
34+
runId: string;
35+
friendlyId: string;
36+
suffix: string;
37+
}): CreateRunInput {
38+
return {
39+
data: {
40+
id: params.runId,
41+
engine: "V2",
42+
status: "PENDING",
43+
friendlyId: params.friendlyId,
44+
runtimeEnvironmentId: `env_${params.suffix}`,
45+
environmentType: "DEVELOPMENT",
46+
organizationId: `org_${params.suffix}`,
47+
projectId: `proj_${params.suffix}`,
48+
taskIdentifier: "my-task",
49+
payload: '{"hello":"world"}',
50+
payloadType: "application/json",
51+
traceContext: { trace: "ctx" },
52+
traceId: `trace_${params.runId}`,
53+
spanId: `span_${params.runId}`,
54+
runTags: [],
55+
queue: "task/my-task",
56+
isTest: false,
57+
taskEventStore: "taskEvent",
58+
depth: 0,
59+
createdAt: new Date("2024-01-01T00:00:00.000Z"),
60+
},
61+
snapshot: {
62+
engine: "V2",
63+
executionStatus: "RUN_CREATED",
64+
description: "Run was created",
65+
runStatus: "PENDING",
66+
environmentId: `env_${params.suffix}`,
67+
environmentType: "DEVELOPMENT",
68+
projectId: `proj_${params.suffix}`,
69+
organizationId: `org_${params.suffix}`,
70+
},
71+
};
72+
}
73+
74+
describe("createRun on the dedicated store does not wrap a single-write create in an interactive transaction", () => {
75+
heteroRunOpsPostgresTest(
76+
"a create with no associated waitpoint survives an interactive-tx budget of 1ms (run + snapshot persist)",
77+
async ({ prisma17 }) => {
78+
const tx = trackInteractiveTx(prisma17);
79+
const store = makeDedicatedStore(prisma17);
80+
const runId = `run_${NEW_ID_26}`;
81+
82+
await store.createRun(
83+
buildCreateRunInput({ runId, friendlyId: "run_no_tx", suffix: "no_tx" })
84+
);
85+
86+
expect(tx.interactiveCalls).toBe(0);
87+
88+
const run = await prisma17.taskRun.findFirstOrThrow({ where: { id: runId } });
89+
expect(run.status).toBe("PENDING");
90+
const snap = await prisma17.taskRunExecutionSnapshot.findFirst({
91+
where: { runId, executionStatus: "RUN_CREATED" },
92+
});
93+
expect(snap).not.toBeNull();
94+
}
95+
);
96+
});

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

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ export interface RunOpsCapableClient {
8484
* per-call `tx` so they share one transaction (see `runInTransaction`).
8585
*/
8686
export interface RunOpsTransactionalClient extends RunOpsCapableClient {
87-
$transaction: <R>(fn: (tx: RunOpsCapableClient) => Promise<R>) => Promise<R>;
87+
$transaction: <R>(
88+
fn: (tx: RunOpsCapableClient) => Promise<R>,
89+
options?: { timeout?: number; maxWait?: number; isolationLevel?: unknown }
90+
) => Promise<R>;
8891
}
8992

9093
/**
@@ -99,6 +102,8 @@ export type RunStoreSchemaVariant = "legacy" | "dedicated";
99102
// (apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts) — keep the values in sync.
100103
export const CONNECTED_RUNS_LIMIT = 5;
101104

105+
export const RUN_OPS_WRITE_TX_TIMEOUT_MS = 15_000;
106+
102107
export type PostgresRunStoreOptions = {
103108
prisma: RunOpsCapableClient;
104109
readOnlyPrisma: RunOpsCapableClient;
@@ -661,18 +666,28 @@ export class PostgresRunStore implements RunStore {
661666
// (snapshot + completed-waitpoints, run + associated-waitpoint) which must commit together.
662667
#withOptionalTransaction<R>(
663668
tx: PrismaClientOrTransaction | undefined,
664-
fn: (client: PrismaClientOrTransaction) => Promise<R>
669+
fn: (client: PrismaClientOrTransaction) => Promise<R>,
670+
options?: { timeout?: number; maxWait?: number }
665671
): Promise<R> {
666672
const alreadyInTransaction =
667673
tx !== undefined && typeof (tx as { $transaction?: unknown }).$transaction !== "function";
668674
if (alreadyInTransaction) {
669675
return fn(tx);
670676
}
671-
return (this.prisma as RunOpsTransactionalClient).$transaction((t) =>
672-
fn(t as unknown as PrismaClientOrTransaction)
677+
return (this.prisma as RunOpsTransactionalClient).$transaction(
678+
(t) => fn(t as unknown as PrismaClientOrTransaction),
679+
options
673680
);
674681
}
675682

683+
#writeClientWithoutTransaction(
684+
tx: PrismaClientOrTransaction | undefined
685+
): PrismaClientOrTransaction {
686+
const alreadyInTransaction =
687+
tx !== undefined && typeof (tx as { $transaction?: unknown }).$transaction !== "function";
688+
return (alreadyInTransaction ? tx : this.prisma) as PrismaClientOrTransaction;
689+
}
690+
676691
async createRun(
677692
params: CreateRunInput,
678693
tx?: PrismaClientOrTransaction
@@ -694,19 +709,31 @@ export class PostgresRunStore implements RunStore {
694709
};
695710

696711
if (this.schemaVariant === "dedicated") {
697-
// The run + its associated RUN-type waitpoint are two writes here (the legacy branch below nests
698-
// them). Commit them together so a crash / lagging read never leaves a run without its waitpoint.
699-
return this.#withOptionalTransaction(tx, async (c) => {
700-
const run = (await c.taskRun.create({
712+
if (!params.associatedWaitpoint) {
713+
const run = (await this.#writeClientWithoutTransaction(tx).taskRun.create({
701714
data: {
702715
...params.data,
703716
executionSnapshots: { create: snapshotCreate },
704717
},
705718
})) as TaskRun;
719+
return { ...run, associatedWaitpoint: null };
720+
}
706721

707-
const associatedWaitpoint = await this.#createAssociatedWaitpoint(c, run.id, params);
708-
return { ...run, associatedWaitpoint };
709-
});
722+
return this.#withOptionalTransaction(
723+
tx,
724+
async (c) => {
725+
const run = (await c.taskRun.create({
726+
data: {
727+
...params.data,
728+
executionSnapshots: { create: snapshotCreate },
729+
},
730+
})) as TaskRun;
731+
732+
const associatedWaitpoint = await this.#createAssociatedWaitpoint(c, run.id, params);
733+
return { ...run, associatedWaitpoint };
734+
},
735+
{ timeout: RUN_OPS_WRITE_TX_TIMEOUT_MS }
736+
);
710737
}
711738

712739
return client.taskRun.create({
@@ -784,15 +811,25 @@ export class PostgresRunStore implements RunStore {
784811
const client = tx ?? this.prisma;
785812

786813
if (this.schemaVariant === "dedicated") {
787-
// Run + associated RUN-type waitpoint are two writes here; commit them together (see createRun).
788-
return this.#withOptionalTransaction(tx, async (c) => {
789-
const run = (await c.taskRun.create({
814+
if (!params.associatedWaitpoint) {
815+
const run = (await this.#writeClientWithoutTransaction(tx).taskRun.create({
790816
data: { ...params.data },
791817
})) as TaskRun;
818+
return { ...run, associatedWaitpoint: null };
819+
}
792820

793-
const associatedWaitpoint = await this.#createAssociatedWaitpoint(c, run.id, params);
794-
return { ...run, associatedWaitpoint };
795-
});
821+
return this.#withOptionalTransaction(
822+
tx,
823+
async (c) => {
824+
const run = (await c.taskRun.create({
825+
data: { ...params.data },
826+
})) as TaskRun;
827+
828+
const associatedWaitpoint = await this.#createAssociatedWaitpoint(c, run.id, params);
829+
return { ...run, associatedWaitpoint };
830+
},
831+
{ timeout: RUN_OPS_WRITE_TX_TIMEOUT_MS }
832+
);
796833
}
797834

798835
return client.taskRun.create({

0 commit comments

Comments
 (0)