Skip to content

Commit e4c9b5c

Browse files
committed
fix(webapp,run-engine,run-store,redis-worker): read-your-writes + global-scope idempotency correctness under the run-ops split
Production code only — the guarding tests are in the stacked PR. 1) Read-your-writes: run-store reads that gate a mutation or feed a public GET / realtime response were routed to a lagging read replica, so a just-written run/waitpoint/batch could spuriously miss under replica lag. Route those reads to the owning primary (findRun/findWaitpoint/findBatchTaskRunByFriendlyId -> *OnPrimary, a primary re-read on a miss, or a retryable 404 where the SDK polls). Additive: the happy path is unchanged; a primary read happens only on a miss. 2) Global-scope idempotency across the split: a global-scope key carries no per-run salt, so the same (env, task, key) triggered concurrently from parents resident on different run-ops DBs could dedup-miss on each DB and create a duplicate run (the per-DB unique index can't enforce cross-DB uniqueness). Serialize such triggers (global scope, or scope-absent, while split is active) through the existing Redis idempotency claim, resolve the winner by id across both DBs, and reacquire the claim on the expired/failed clear-and-recreate path. run/attempt scope embed the run id in their hash and never contend.
1 parent cecdfd9 commit e4c9b5c

34 files changed

Lines changed: 818 additions & 430 deletions

File tree

apps/webapp/app/models/runtimeEnvironment.server.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -274,19 +274,17 @@ export async function findEnvironmentFromRun(
274274
): Promise<EnvironmentFromRun | null> {
275275
// Run-ops scalars (runTags/batchId/runtimeEnvironmentId) from the run store; the env half is
276276
// resolved via the control-plane resolver so the run-ops DB can split without a cross-DB join.
277-
const taskRun = await runStore.findRun(
278-
{
279-
id: runId,
280-
},
281-
{
282-
select: {
283-
runTags: true,
284-
batchId: true,
285-
runtimeEnvironmentId: true,
286-
},
287-
},
288-
tx ?? $replica
289-
);
277+
const select = {
278+
runTags: true,
279+
batchId: true,
280+
runtimeEnvironmentId: true,
281+
} as const;
282+
let taskRun = await runStore.findRun({ id: runId }, { select }, tx ?? $replica);
283+
if (!taskRun) {
284+
// Read-your-writes: a just-created run may not have replicated. Re-read the owning primary before
285+
// treating it as absent, so runMetadataUpdated doesn't drop a live run's final metadata + publish.
286+
taskRun = await runStore.findRun({ id: runId }, { select }, prisma);
287+
}
290288
if (!taskRun) {
291289
return null;
292290
}

apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,29 +42,36 @@ export class ApiWaitpointPresenter extends BasePresenter {
4242
return this.trace("call", async (span) => {
4343
// The store routes by the waitpointId's residency (id shape) and reads the owning
4444
// store's replica. waitpointId is pre-decoded from the friendlyId via WaitpointId.toId.
45-
const waitpoint = await this.runStore.findWaitpoint({
46-
where: {
47-
id: waitpointId,
48-
environmentId: environment.id,
49-
},
50-
select: {
51-
id: true,
52-
friendlyId: true,
53-
type: true,
54-
status: true,
55-
idempotencyKey: true,
56-
userProvidedIdempotencyKey: true,
57-
idempotencyKeyExpiresAt: true,
58-
inactiveIdempotencyKey: true,
59-
output: true,
60-
outputType: true,
61-
outputIsError: true,
62-
completedAfter: true,
63-
completedAt: true,
64-
createdAt: true,
65-
tags: true,
66-
},
67-
});
45+
const where = {
46+
id: waitpointId,
47+
environmentId: environment.id,
48+
};
49+
const select = {
50+
id: true,
51+
friendlyId: true,
52+
type: true,
53+
status: true,
54+
idempotencyKey: true,
55+
userProvidedIdempotencyKey: true,
56+
idempotencyKeyExpiresAt: true,
57+
inactiveIdempotencyKey: true,
58+
output: true,
59+
outputType: true,
60+
outputIsError: true,
61+
completedAfter: true,
62+
completedAt: true,
63+
createdAt: true,
64+
tags: true,
65+
} as const;
66+
67+
let waitpoint = await this.runStore.findWaitpoint({ where, select });
68+
69+
// Read-your-writes on a public GET: a just-minted token may not be on the owning store's
70+
// replica yet, so a replica miss would 404 a live token. Re-read the owning primary before
71+
// concluding it doesn't exist (mirrors the metadata GET loader + the complete/callback paths).
72+
if (!waitpoint) {
73+
waitpoint = await this.runStore.findWaitpointOnPrimary({ where, select });
74+
}
6875

6976
if (!waitpoint) {
7077
logger.error(`WaitpointPresenter: Waitpoint not found`, {

apps/webapp/app/presenters/v3/BatchPresenter.server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,25 @@ export class BatchPresenter extends BasePresenter {
4747
// The BatchTaskRun (run-ops) is read through the run store, which routes by residency. The
4848
// runtimeEnvironment (control-plane) is resolved separately because the cross-seam FK is
4949
// dropped, so the batch row cannot single-SQL join to control-plane RuntimeEnvironment.
50-
const batch = await this.runStore.findBatchTaskRunByFriendlyId(
50+
let batch = await this.runStore.findBatchTaskRunByFriendlyId(
5151
batchId,
5252
environmentId,
5353
{ include: BATCH_INCLUDE },
5454
this._replica
5555
);
5656

57+
// Read-your-writes: findBatchTaskRunByFriendlyId defaults to (and here reads) the replica, so a
58+
// batch created within the replica's apply window returns null under lag. Re-read from the owning
59+
// primary on a miss so a live batch's detail page never spuriously 404s ("Batch not found").
60+
if (!batch) {
61+
batch = await this.runStore.findBatchTaskRunByFriendlyId(
62+
batchId,
63+
environmentId,
64+
{ include: BATCH_INCLUDE },
65+
this._prisma
66+
);
67+
}
68+
5769
if (!batch) {
5870
throw new Error("Batch not found");
5971
}

apps/webapp/app/routes/api.v1.batches.$batchId.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export const loader = createLoaderApiRoute(
1212
params: ParamsSchema,
1313
allowJWT: true,
1414
corsStrategy: "all",
15+
// A just-created batch may not yet have replicated to the read replica this client-less
16+
// findBatchTaskRunByFriendlyId lookup routes to; return a retryable 404 so the SDK retries through
17+
// replica lag rather than stranding a live batch on a permanent 404 (mirrors the run-get routes,
18+
// e.g. api.v3.runs.$runId).
19+
shouldRetryNotFound: true,
1520
findResource: (params, auth) => {
1621
return runStore.findBatchTaskRunByFriendlyId(params.batchId, auth.environment.id, {
1722
include: { errors: true },

apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,23 @@ const { action, loader } = createActionApiRoute(
3333
},
3434
async ({ authentication, body, params }) => {
3535
try {
36-
const run = await runStore.findRun(
37-
{
38-
friendlyId: params.runFriendlyId,
39-
runtimeEnvironmentId: authentication.environment.id,
36+
const where = {
37+
friendlyId: params.runFriendlyId,
38+
runtimeEnvironmentId: authentication.environment.id,
39+
};
40+
const args = {
41+
select: {
42+
id: true,
43+
friendlyId: true,
44+
realtimeStreamsVersion: true,
45+
streamBasinName: true,
4046
},
41-
{
42-
select: {
43-
id: true,
44-
friendlyId: true,
45-
realtimeStreamsVersion: true,
46-
streamBasinName: true,
47-
},
48-
},
49-
$replica
50-
);
47+
};
48+
// Replica lag can null out a live run; a spurious 404 fails the .wait() registration on a run
49+
// that exists. Re-read the owning primary on a replica miss.
50+
const run =
51+
(await runStore.findRun(where, args, $replica)) ??
52+
(await runStore.findRunOnPrimary(where, args));
5153

5254
if (!run) {
5355
return json({ error: "Run not found" }, { status: 404 });

apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,22 @@ const { action, loader } = createActionApiRoute(
3939
},
4040
async ({ authentication, body, params }) => {
4141
try {
42-
const run = await runStore.findRun(
43-
{
44-
friendlyId: params.runFriendlyId,
45-
runtimeEnvironmentId: authentication.environment.id,
42+
const where = {
43+
friendlyId: params.runFriendlyId,
44+
runtimeEnvironmentId: authentication.environment.id,
45+
};
46+
const args = {
47+
select: {
48+
id: true,
49+
friendlyId: true,
50+
realtimeStreamsVersion: true,
4651
},
47-
{
48-
select: {
49-
id: true,
50-
friendlyId: true,
51-
realtimeStreamsVersion: true,
52-
},
53-
},
54-
$replica
55-
);
52+
};
53+
// Replica lag can null out a live run; a spurious 404 fails the .wait() registration on a run
54+
// that exists. Re-read the owning primary on a replica miss.
55+
const run =
56+
(await runStore.findRun(where, args, $replica)) ??
57+
(await runStore.findRunOnPrimary(where, args));
5658

5759
if (!run) {
5860
return json({ error: "Run not found" }, { status: 404 });

apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
6464
);
6565
}
6666

67+
// Read-your-writes: a run drained from the buffer to the primary but not yet replicated misses
68+
// both the replica read and the buffer. Re-read the owning primary before 404ing.
69+
const primaryRun = await runStore.findRunOnPrimary(
70+
{ friendlyId: parsed.data.runId, runtimeEnvironmentId: env.id },
71+
{ select: { metadata: true, metadataType: true } }
72+
);
73+
if (primaryRun) {
74+
return json(
75+
{ metadata: primaryRun.metadata, metadataType: primaryRun.metadataType },
76+
{ status: 200 }
77+
);
78+
}
79+
6780
return json({ error: "Run not found" }, { status: 404 });
6881
}
6982

apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,27 @@ const { action, loader } = createActionApiRoute(
7575
// SDK exposes via `ctx.run.id`). Internally `Session.currentRunId`
7676
// stores the TaskRun.id cuid, so resolve before handing to the
7777
// optimistic-claim service.
78-
const callingRun = await runStore.findRun(
78+
let callingRun = await runStore.findRun(
7979
{
8080
friendlyId: body.callingRunId,
8181
runtimeEnvironmentId: authentication.environment.id,
8282
},
8383
{ select: { id: true } },
8484
$replica
8585
);
86+
if (!callingRun) {
87+
// Replica lag: `callingRunId` is the agent's own live run (it is executing this request), so it
88+
// exists on the owning primary even when the read replica has not caught up. Re-read the primary
89+
// before 404ing — otherwise a lagging replica turns a legitimate handoff into a spurious
90+
// "callingRunId not found in this environment".
91+
callingRun = await runStore.findRunOnPrimary(
92+
{
93+
friendlyId: body.callingRunId,
94+
runtimeEnvironmentId: authentication.environment.id,
95+
},
96+
{ select: { id: true } }
97+
);
98+
}
8699
if (!callingRun) {
87100
return json({ error: "callingRunId not found in this environment" }, { status: 404 });
88101
}

apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,22 @@ export async function action({ request, params }: ActionFunctionArgs) {
3636
// Resolve wherever the waitpoint resides. The store routes by the waitpoint id's residency
3737
// (id-shape) and probes both run-ops DBs, so a token on either store resolves; the env is
3838
// resolved below from the row via the control-plane resolver.
39-
const waitpoint = await runStore.findWaitpoint({
39+
let waitpoint = await runStore.findWaitpoint({
4040
where: {
4141
id: waitpointId,
4242
},
4343
select: { id: true, status: true, environmentId: true },
4444
});
4545

46+
if (!waitpoint) {
47+
// Read-your-writes: a token whose callback fires right after mint may not have replicated
48+
// yet. Re-read the owning primary before 404ing (mirrors complete.ts's primary fallback).
49+
waitpoint = await runStore.findWaitpointOnPrimary({
50+
where: { id: waitpointId },
51+
select: { id: true, status: true, environmentId: true },
52+
});
53+
}
54+
4655
if (!waitpoint) {
4756
return json({ error: "Waitpoint not found" }, { status: 404 });
4857
}

apps/webapp/app/routes/api.v2.batches.$batchId.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export const loader = createLoaderApiRoute(
1212
params: ParamsSchema,
1313
allowJWT: true,
1414
corsStrategy: "all",
15+
// A just-created batch may not yet have replicated to the read replica this client-less
16+
// findBatchTaskRunByFriendlyId lookup routes to; return a retryable 404 so the SDK retries through
17+
// replica lag rather than stranding a live batch on a permanent 404 (mirrors the run-get routes,
18+
// e.g. api.v3.runs.$runId).
19+
shouldRetryNotFound: true,
1520
findResource: (params, auth) => {
1621
return runStore.findBatchTaskRunByFriendlyId(params.batchId, auth.environment.id, {
1722
include: { errors: true },

0 commit comments

Comments
 (0)