Skip to content

Commit b969f8e

Browse files
committed
fix(webapp): route a gen-2 batch's completion write to its own shard
Minting gen-2 batch ids broke batch waits. The batch-completion writer was resolved by a binary probe: look for the row on the new store, otherwise assume legacy. A gen-2 batch lives on neither, so the probe fell through to legacy, the update found no row and threw, the callback died before tryCompleteBatch, the batch waitpoint stayed pending, and the parent run waited forever with nothing logged as a hang. Found by running it: a gen-2 batchTriggerAndWait parent never resumed, while the same task on a gen-1 batch completed in twenty seconds. A gen-2 batch id names its own shard, so it now routes by that and never probes. An id naming an unconfigured shard throws rather than guessing a store, because guessing is precisely what strands the run. Both new tests fail without this change, the first on a fake client that throws if the new store is probed at all.
1 parent f9ad14c commit b969f8e

3 files changed

Lines changed: 58 additions & 0 deletions

File tree

apps/webapp/app/v3/runEngineHandlers.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
runOpsNewPrismaClient,
1212
runOpsNewReplicaClient,
1313
runOpsLegacyPrismaClient,
14+
runOpsShardHandles,
1415
} from "~/db.server";
1516
import { env } from "~/env.server";
1617
import { findEnvironmentById, findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
@@ -1060,6 +1061,7 @@ export function setupBatchQueueCallbacks() {
10601061
newReplica: runOpsNewReplicaClient,
10611062
newWriter: runOpsNewPrismaClient,
10621063
legacyWriter: runOpsLegacyPrismaClient,
1064+
shards: runOpsShardHandles,
10631065
tryCompleteBatch: (batchId) => engine.tryCompleteBatch({ batchId }),
10641066
});
10651067
});

apps/webapp/app/v3/runEngineHandlersShared.server.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* whole webapp service graph). The handlers wire the production defaults; tests
55
* inject per-container stores/replicas, so these helpers never import db.server.
66
*/
7+
import { resolveShard } from "@trigger.dev/core/v3/isomorphic";
78
import type { CompleteBatchResult } from "@internal/run-engine";
89
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
910
import type { RunStore } from "@internal/run-store";
@@ -82,8 +83,25 @@ export async function resolveBatchRunOpsWriter(
8283
newReplica: RunOpsPrismaClient;
8384
newWriter: RunOpsPrismaClient;
8485
legacyWriter: RunOpsPrismaClient;
86+
shards?: ReadonlyArray<{ key: string; writer: RunOpsPrismaClient }>;
8587
}
8688
): Promise<RunOpsPrismaClient> {
89+
// A gen-2 batch names its own shard in its id, so route by that and never probe. The
90+
// probe below is binary — NEW, else assume LEGACY — so a gen-2 batch would fall through
91+
// to a store that holds no such row, and the completion update would throw before the
92+
// batch waitpoint could complete, leaving the parent run blocked with nothing logged.
93+
const shardKey = resolveShard(batchId);
94+
if (shardKey !== "new" && shardKey !== "legacy") {
95+
const shard = deps.shards?.find((s) => s.key === shardKey);
96+
if (!shard) {
97+
// Writing to a guessed store is what strands a run. Fail loud instead.
98+
throw new Error(
99+
`resolveBatchRunOpsWriter: batch "${batchId}" names shard "${shardKey}", which is not configured`
100+
);
101+
}
102+
return shard.writer;
103+
}
104+
87105
const onNew = await deps.newReplica.batchTaskRun.findFirst({
88106
where: { id: batchId },
89107
select: { id: true },
@@ -105,6 +123,7 @@ export type BatchCompletionDeps = {
105123
newReplica: RunOpsPrismaClient;
106124
newWriter: RunOpsPrismaClient;
107125
legacyWriter: RunOpsPrismaClient;
126+
shards?: ReadonlyArray<{ key: string; writer: RunOpsPrismaClient }>;
108127
tryCompleteBatch: (batchId: string) => Promise<unknown>;
109128
};
110129

@@ -135,6 +154,7 @@ export async function handleBatchCompletion(
135154
newReplica: deps.newReplica,
136155
newWriter: deps.newWriter,
137156
legacyWriter: deps.legacyWriter,
157+
shards: deps.shards,
138158
});
139159

140160
try {

apps/webapp/test/runEngineHandlers.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,42 @@ describe("runEngineHandlers batch completion", () => {
490490
});
491491

492492
describe("runEngineHandlers batch residency routing", () => {
493+
// A gen-2 batch lives on its own shard. The binary probe below it looks only on the
494+
// NEW store and then assumes LEGACY, so without a shard arm the completion update runs
495+
// on a database that has no such row: Prisma throws "no record was found for an
496+
// update", the callback dies before tryCompleteBatch, the BATCH waitpoint stays
497+
// PENDING and the parent run waits forever with nothing logged as a hang.
498+
it("a gen-2 batch resolves to its own shard writer", async () => {
499+
const shardWriter = {} as never; // identity is the whole assertion; no database is touched
500+
const gen2BatchId = `${"a".repeat(24)}a2`;
501+
502+
const writer = await resolveBatchRunOpsWriter(gen2BatchId, {
503+
newReplica: {
504+
batchTaskRun: {
505+
findFirst: async () => {
506+
throw new Error("a gen-2 batch id must never probe the NEW store");
507+
},
508+
},
509+
} as never,
510+
newWriter: {} as never,
511+
legacyWriter: {} as never,
512+
shards: [{ key: "a", writer: shardWriter as never }],
513+
});
514+
515+
expect(writer).toBe(shardWriter);
516+
});
517+
518+
it("an unconfigured shard key fails loud rather than writing elsewhere", async () => {
519+
await expect(
520+
resolveBatchRunOpsWriter(`${"a".repeat(24)}z2`, {
521+
newReplica: {} as never,
522+
newWriter: {} as never,
523+
legacyWriter: {} as never,
524+
shards: [{ key: "a", writer: {} as never }],
525+
})
526+
).rejects.toThrow(/shard/i);
527+
});
528+
493529
// True single-DB invariant: the topology's cpFallback makes newReplica and
494530
// legacyWriter the SAME control-plane client, so the probe always resolves to
495531
// that one client regardless of where length-classification would guess.

0 commit comments

Comments
 (0)