Files
triggerdotdev--trigger.dev/apps/webapp/test/createCheckpoint.batchReplicaLag.test.ts
Daniel Sutton e4ae8cbcd4 fix(run-engine,run-store,webapp): stop split-mode waits hanging on resume (#4164)
## Summary

On the run-ops database split, a run that waits (`triggerAndWait`,
`batchTriggerAndWait`, `wait.forToken`) could hang forever after its
wait had already completed. The runner reads a resume from
`/snapshots/since` exactly once: if that read returned the resume
snapshot without its completed-waitpoints, the runner logged "executing
without completed waitpoints", advanced its cursor, and never re-read
it, so the awaiting run never continued.

## Root cause

The resume snapshot and its completed-waitpoint rows were written as two
separate commits. This regressed when the split replaced Prisma's atomic
nested `connect` with an FK-free insert (in
[#4163](https://github.com/triggerdotdev/trigger.dev/pull/4163)), and
`/snapshots/since` is served from a read replica. A fetch landing in the
sub-millisecond gap between the two commits, or a multi-reader replica
serving the snapshot from a different point in time than its join rows,
delivered an empty resume. Because the runner consumes each snapshot
once and treats an empty resume as terminal, a single stale read was
fatal and produced a permanent, nondeterministic hang.

## Fixes

- Commit a snapshot and its completed-waitpoint links in one
transaction, restoring the atomicity the split removed.
- Repair the completed-waitpoints from the owning primary when a
multi-reader replica serves the snapshot without its join rows. This
covers single-waitpoint resumes, which carry no
`completedWaitpointOrder` and so were missed by the count-based repair.
- Read the primary in the checkpoint `WAIT_FOR_BATCH` pre-check, so a
batch that already resumed is not re-suspended into a stall.
- Fall back to the primary when a waitpoint token misses both read
replicas, so a token completed immediately after it was minted no longer
returns a spurious 404.
- Route batch-item creation by `batchTaskRunId`, consistent with the
batch-completion count and the row's foreign key.
- Reject control-plane-only relation selects on the dedicated schema
with a clear error instead of an opaque Prisma failure, and stop
`createDateTimeWaitpoint` bypassing residency routing through a caller
transaction.

Verified against the deployed split topology: a resume snapshot and its
completed-waitpoints are now always delivered together, so the runner
can no longer drop a resume.
2026-07-06 10:55:02 +00:00

66 lines
2.8 KiB
TypeScript

// Unit red-green for the checkpoint WAIT_FOR_BATCH replica-lag fix (createCheckpoint.server.ts).
// The service decides whether to suspend a run on `batchRun.resumedAt`; reading it from a lagging
// replica makes a just-resumed batch look unresumed -> it suspends an already-resumed run -> stall.
// The fix threads the primary (`this._prisma`) into `runStore.findBatchTaskRunByFriendlyId`. Here a
// spy runStore records which client the service passed and simulates the lag (only the primary read
// sees the fresh resumedAt): RED = no client -> stale null -> no early return; GREEN = primary -> kept alive.
import { describe, expect, it, vi } from "vitest";
vi.mock("~/services/logger.server", () => ({
logger: { debug: vi.fn(), info: vi.fn(), log: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
vi.mock("~/v3/marqs/index.server", () => ({
marqs: { replaceMessage: vi.fn(), cancelHeartbeat: vi.fn() },
}));
import { CreateCheckpointService } from "~/v3/services/createCheckpoint.server";
describe("checkpoint WAIT_FOR_BATCH reads the primary, not a lagging replica", () => {
it("threads the primary so an already-resumed batch keeps the run alive", async () => {
// A freezable attempt so control reaches the WAIT_FOR_BATCH arm. This object IS the primary the
// fix must thread into the batch read.
const prisma = {
taskRunAttempt: {
findFirst: async () => ({
id: "attempt_1",
status: "EXECUTING",
taskRunId: "run_1",
taskRun: { id: "run_1", status: "EXECUTING", runtimeEnvironmentId: "env_1" },
backgroundWorker: { id: "bw_1", deployment: { imageReference: "img:1" } },
}),
},
};
let seenClient: unknown = "NOT_CALLED";
const runStore = {
findBatchTaskRunByFriendlyId: async (
_friendlyId: string,
_environmentId: string,
_args: unknown,
client?: unknown
) => {
seenClient = client;
// Lagging replica: only a read handed the primary sees the just-committed resumedAt.
return { resumedAt: client === prisma ? new Date() : null };
},
};
const service = new CreateCheckpointService(prisma as never, {} as never, runStore as never);
let result: unknown;
try {
result = await service.call({
attemptFriendlyId: "attempt_1",
reason: { type: "WAIT_FOR_BATCH", batchFriendlyId: "batch_1" },
} as never);
} catch {
// Buggy path falls through the pre-check into checkpoint creation (unstubbed) and throws; the
// recorded client below is what distinguishes RED from GREEN.
}
expect(seenClient).toBe(prisma); // the fix: primary threaded into the batch read
expect(result).toEqual({ success: false, keepRunAlive: true }); // early-return, run kept alive
});
});