Skip to content

Commit 339ed71

Browse files
committed
fix(run-engine): repair completed-waitpoints from the primary on a stale replica read
getExecutionSnapshotsSince serves /snapshots/since from the read replica. When a snapshot's completedWaitpointOrder lists more (distinct) waitpoints than the join read returns, the join rows have not replicated yet; re-read them from the primary so the runner resumes instead of hanging. Distinct ids matter because a batched run can list the same waitpoint more than once while the join is deduped.
1 parent a8c6323 commit 339ed71

3 files changed

Lines changed: 183 additions & 7 deletions

File tree

internal-packages/run-engine/src/engine/index.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2097,13 +2097,24 @@ export class RunEngine {
20972097
this.readOnlyPrisma !== this.prisma;
20982098
const prisma = tx ?? (useReplica ? this.readOnlyPrisma : this.prisma);
20992099

2100-
const query = async (client: PrismaClientOrTransaction) => {
2101-
const snapshots = await getExecutionSnapshotsSince(client, runId, snapshotId, this.runStore);
2100+
const query = async (
2101+
client: PrismaClientOrTransaction,
2102+
repairClient?: PrismaClientOrTransaction
2103+
) => {
2104+
const snapshots = await getExecutionSnapshotsSince(
2105+
client,
2106+
runId,
2107+
snapshotId,
2108+
this.runStore,
2109+
repairClient
2110+
);
21022111
return snapshots.map(executionDataFromSnapshot);
21032112
};
21042113

21052114
try {
2106-
return await query(prisma);
2115+
// When reading the replica, pass the primary so a snapshot whose completed-waitpoint join rows
2116+
// have not replicated yet is repaired from the primary instead of resuming the run waitpoint-less.
2117+
return await query(prisma, useReplica ? this.prisma : undefined);
21072118
} catch (e) {
21082119
if (useReplica && e instanceof ExecutionSnapshotNotFoundError) {
21092120
// Replica lag: the runner learned this snapshot id from the writer before the
@@ -2115,7 +2126,7 @@ export class RunEngine {
21152126
if (maxMs > 0) {
21162127
await setTimeout(minMs + Math.random() * Math.max(0, maxMs - minMs));
21172128
try {
2118-
const result = await query(this.readOnlyPrisma);
2129+
const result = await query(this.readOnlyPrisma, this.prisma);
21192130
this.snapshotsSinceReplicaMissCounter.add(1, { outcome: "replica_retry" });
21202131
return result;
21212132
} catch (replicaRetryError) {

internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,10 @@ export async function getExecutionSnapshotsSince(
260260
prisma: PrismaClientOrTransaction,
261261
runId: string,
262262
sinceSnapshotId: string,
263-
runStore?: RunStore
263+
runStore?: RunStore,
264+
// The primary, for read-repair when `prisma` is a lagging read replica (see Step 3). Omit when
265+
// `prisma` is already the primary.
266+
repairClient?: PrismaClientOrTransaction
264267
): Promise<EnhancedExecutionSnapshot[]> {
265268
// Step 1: Find the createdAt of the sinceSnapshotId
266269
const sinceSnapshot = runStore
@@ -316,10 +319,27 @@ export async function getExecutionSnapshotsSince(
316319

317320
// Step 3: Get waitpoint IDs for the LATEST snapshot only (first in desc order)
318321
const latestSnapshot = snapshots[0];
319-
const waitpointIds = await getSnapshotWaitpointIds(prisma, latestSnapshot.id, runStore);
322+
let readClient = prisma;
323+
let waitpointIds = await getSnapshotWaitpointIds(prisma, latestSnapshot.id, runStore);
324+
325+
// Read-repair: completedWaitpointOrder is written on the snapshot row in the same statement as the
326+
// snapshot, so it is authoritative. If it lists more waitpoints than the join read returned, the
327+
// _completedWaitpoints rows are stale on this client (a lagging read replica) - re-read them from the
328+
// primary so the runner is not handed a waitpoint-less continue, which it drops and the run hangs.
329+
// Distinct: completedWaitpointOrder can list the same id twice (a run batched under one idempotency
330+
// key) while the _completedWaitpoints join is deduped, so compare unique ids or the repair fires every
331+
// poll even against a caught-up replica.
332+
const expectedCount = new Set(latestSnapshot.completedWaitpointOrder ?? []).size;
333+
if (repairClient && repairClient !== prisma && waitpointIds.length < expectedCount) {
334+
const repaired = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore);
335+
if (repaired.length > waitpointIds.length) {
336+
waitpointIds = repaired;
337+
readClient = repairClient;
338+
}
339+
}
320340

321341
// Step 4: Fetch waitpoints in chunks to avoid NAPI string conversion limits
322-
const waitpoints = await fetchWaitpointsInChunks(prisma, waitpointIds, runStore);
342+
const waitpoints = await fetchWaitpointsInChunks(readClient, waitpointIds, runStore);
323343

324344
// Step 5: Build enhanced snapshots - only latest gets waitpoints, others get empty arrays
325345
// The runner only uses completedWaitpoints from the latest snapshot anyway

internal-packages/run-engine/src/engine/tests/getSnapshotsSince.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { trace } from "@internal/tracing";
33
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
44
import { setTimeout } from "node:timers/promises";
55
import { describe, expect } from "vitest";
6+
import type { PrismaClient } from "@trigger.dev/database";
67
import { RunEngine } from "../index.js";
8+
import { getExecutionSnapshotsSince } from "../systems/executionSnapshotSystem.js";
79
import { copySnapshotsToReplica, createTestMetricsMeter } from "./helpers/replicaTestHelpers.js";
810
import { setupTestScenario } from "./helpers/snapshotTestHelpers.js";
911
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js";
@@ -1398,4 +1400,147 @@ describe("RunEngine getSnapshotsSince", () => {
13981400
}
13991401
}
14001402
);
1403+
1404+
// This models a BATCH resume: production only populates completedWaitpointOrder for waitpoints that
1405+
// carry a batch index, so the read-repair only ever engages for batch resumes. Single-waitpoint
1406+
// continues have an empty order (repair is a no-op) and are covered instead by the atomic write
1407+
// (snapshot + join commit in one tx, which a replica applies atomically).
1408+
containerTest(
1409+
"repairs a batch resume's completed-waitpoints from the primary when the replica lags the join rows",
1410+
async ({ prisma, schemaOnlyPrisma, redisOptions }) => {
1411+
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
1412+
1413+
const engine = new RunEngine({
1414+
prisma,
1415+
// Replica has the snapshot rows but lags on the _completedWaitpoints join (see below).
1416+
readOnlyPrisma: schemaOnlyPrisma,
1417+
readReplicaSnapshotsSinceEnabled: true,
1418+
readReplicaSnapshotsSinceRetryDelay: { minMs: 1, maxMs: 2 },
1419+
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
1420+
queue: { redis: redisOptions },
1421+
runLock: { redis: redisOptions },
1422+
machines: {
1423+
defaultMachine: "small-1x",
1424+
machines: {
1425+
"small-1x": {
1426+
name: "small-1x" as const,
1427+
cpu: 0.5,
1428+
memory: 0.5,
1429+
centsPerMs: 0.0001,
1430+
},
1431+
},
1432+
baseCostInCents: 0.0001,
1433+
},
1434+
tracer: trace.getTracer("test", "0.0.0"),
1435+
});
1436+
1437+
try {
1438+
// Primary: a run whose latest snapshot is a warm-continue carrying one completed waitpoint -
1439+
// completedWaitpointOrder is set on the row AND the _completedWaitpoints join + waitpoint exist.
1440+
const scenario = await setupTestScenario(prisma, authenticatedEnvironment, {
1441+
totalWaitpoints: 1,
1442+
outputSizeKB: 1,
1443+
snapshotConfigs: [
1444+
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
1445+
{ status: "EXECUTING", completedWaitpointCount: 0 },
1446+
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
1447+
{ status: "EXECUTING", completedWaitpointCount: 1 },
1448+
],
1449+
});
1450+
const waitpointId = scenario.waitpoints[0].id;
1451+
1452+
// Replica lag: it receives the snapshot ROWS (incl. completedWaitpointOrder) but NOT the
1453+
// _completedWaitpoints join rows or the waitpoint - the exact state that drops the resume.
1454+
await copySnapshotsToReplica(prisma, schemaOnlyPrisma, scenario.run.id);
1455+
1456+
const result = await engine.getSnapshotsSince({
1457+
runId: scenario.run.id,
1458+
snapshotId: scenario.snapshots[0].id,
1459+
});
1460+
1461+
expect(result).not.toBeNull();
1462+
const latest = result![result!.length - 1];
1463+
// The runner must receive the completed waitpoint (repaired from the primary), not an empty set.
1464+
expect(latest.completedWaitpoints.map((w) => w.id)).toEqual([waitpointId]);
1465+
} finally {
1466+
await engine.quit();
1467+
}
1468+
}
1469+
);
1470+
1471+
// completedWaitpointOrder can contain the same waitpoint id twice (a run batched under one idempotency
1472+
// key), while the _completedWaitpoints join is deduped. The read-repair must compare DISTINCT ids, or it
1473+
// fires on every poll for such a snapshot even against a caught-up replica - silently defeating offload.
1474+
containerTest(
1475+
"does not repair from the primary when completedWaitpointOrder has duplicates but the join is complete",
1476+
async ({ prisma, redisOptions }) => {
1477+
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
1478+
const engine = new RunEngine({
1479+
prisma,
1480+
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
1481+
queue: { redis: redisOptions },
1482+
runLock: { redis: redisOptions },
1483+
machines: {
1484+
defaultMachine: "small-1x",
1485+
machines: {
1486+
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
1487+
},
1488+
baseCostInCents: 0.0001,
1489+
},
1490+
tracer: trace.getTracer("test", "0.0.0"),
1491+
});
1492+
1493+
try {
1494+
const scenario = await setupTestScenario(prisma, authenticatedEnvironment, {
1495+
totalWaitpoints: 1,
1496+
outputSizeKB: 1,
1497+
snapshotConfigs: [
1498+
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
1499+
{ status: "EXECUTING", completedWaitpointCount: 1 },
1500+
],
1501+
});
1502+
const waitpointId = scenario.waitpoints[0].id;
1503+
const latestSnapshot = scenario.snapshots[scenario.snapshots.length - 1];
1504+
// The join stays a single row; the order column lists the same id twice (the batch-dup case).
1505+
await prisma.taskRunExecutionSnapshot.update({
1506+
where: { id: latestSnapshot.id },
1507+
data: { completedWaitpointOrder: [waitpointId, waitpointId] },
1508+
});
1509+
1510+
// Count join reads on the repair client; the replica read (`prisma`) is already complete, so a
1511+
// correct repair must NEVER touch the repair client for this snapshot.
1512+
const repairCalls = { queryRaw: 0 };
1513+
const repairClient = new Proxy(prisma, {
1514+
get(target, prop) {
1515+
if (prop === "$queryRaw") {
1516+
return (...args: unknown[]) => {
1517+
repairCalls.queryRaw++;
1518+
return (target as unknown as { $queryRaw: (...a: unknown[]) => unknown }).$queryRaw(
1519+
...args
1520+
);
1521+
};
1522+
}
1523+
const value = (target as Record<string | symbol, unknown>)[prop];
1524+
return typeof value === "function" ? value.bind(target) : value;
1525+
},
1526+
}) as unknown as PrismaClient;
1527+
1528+
const result = await getExecutionSnapshotsSince(
1529+
prisma,
1530+
scenario.run.id,
1531+
scenario.snapshots[0].id,
1532+
undefined,
1533+
repairClient
1534+
);
1535+
1536+
const latest = result[result.length - 1];
1537+
// The duplicate is intentional (batch dup) - enhance expands by completedWaitpointOrder.
1538+
expect(latest.completedWaitpoints.map((w) => w.id)).toEqual([waitpointId, waitpointId]);
1539+
// No spurious primary read: distinct(order) === join count, so the repair must not fire.
1540+
expect(repairCalls.queryRaw).toBe(0);
1541+
} finally {
1542+
await engine.quit();
1543+
}
1544+
}
1545+
);
14011546
});

0 commit comments

Comments
 (0)