Skip to content

Commit 83c42f8

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/run-ops-ulid-run-id
# Conflicts: # internal-packages/run-store/src/runOpsStore.test.ts # internal-packages/run-store/src/runOpsStore.ts
2 parents 2a92b7a + d977691 commit 83c42f8

9 files changed

Lines changed: 742 additions & 127 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+
Fix run store routing dropping the caller's read client, which downgraded read-your-writes reads (execution snapshots, waitpoints, batches) to the read replica and could fail dequeues under replica lag

apps/webapp/app/db.server.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type PrismaTransactionOptions,
99
} from "@trigger.dev/database";
1010
import { RunOpsPrismaClient } from "@internal/run-ops-database";
11+
import { markReadReplicaClient } from "@internal/run-store";
1112
import invariant from "tiny-invariant";
1213
import { z } from "zod";
1314
import { env } from "./env.server";
@@ -170,7 +171,11 @@ export const prisma = singleton("prisma", () =>
170171

171172
export const $replica: PrismaReplicaClient = singleton("replica", () => {
172173
const replica = getReplicaClient();
173-
return replica ? captureInfrastructureErrors(tagDatasource("replica", replica)) : prisma;
174+
// Brand ONLY a real replica so the run-store routing layer keeps replica reads off the primary.
175+
// No replica configured → fall back to the writer `prisma`, which must stay UNBRANDED.
176+
return replica
177+
? markReadReplicaClient(captureInfrastructureErrors(tagDatasource("replica", replica)))
178+
: prisma;
174179
});
175180

176181
export type RunOpsClients = { writer: PrismaClient; replica: PrismaReplicaClient };
@@ -254,9 +259,14 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
254259
captureInfraErrorsRunOps(
255260
tagDatasourceRunOps("writer", buildRunOpsWriterClient({ url, clientType }))
256261
),
262+
// Brand the run-ops replica (only built for a real replica URL) so routed replica reads stay
263+
// off the primary. When no replica URL is set, selectRunOpsTopology reuses the writer here —
264+
// which this callback never touches, so the writer stays unbranded.
257265
buildNewReplica: (url, clientType) =>
258-
captureInfraErrorsRunOps(
259-
tagDatasourceRunOps("replica", buildRunOpsReplicaClient({ url, clientType }))
266+
markReadReplicaClient(
267+
captureInfraErrorsRunOps(
268+
tagDatasourceRunOps("replica", buildRunOpsReplicaClient({ url, clientType }))
269+
)
260270
),
261271
}
262272
);

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,13 @@ export class PostgresRunStore implements RunStore {
507507
this.schemaVariant = options.schemaVariant ?? "legacy";
508508
}
509509

510+
// The writer handle in read-client form, so the routing layer can honor a caller-passed client
511+
// (read-your-writes) with THIS store's own primary instead of leaking the caller's client across
512+
// DBs. Cast mirrors runInTransaction: the generated clients differ only in delegates reads use.
513+
get primaryReadClient(): ReadClient {
514+
return this.prisma as unknown as ReadClient;
515+
}
516+
510517
// Open ONE interactive transaction on this store's OWN writer client and run `fn` against THIS store
511518
// (so subclass overrides survive) with the tx as the client to thread into the inner writes. `runId`
512519
// is ignored here — a single store has one connection — but is in the contract so the router can
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./types.js";
22
export * from "./PostgresRunStore.js";
33
export * from "./runOpsStore.js";
4+
export * from "./readReplicaClient.js";
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// A writer and a read-replica Prisma client are structurally identical at runtime (a replica is a
2+
// `new PrismaClient(...)` too, so it also exposes `$transaction`). The routing layer therefore
3+
// cannot tell a caller-passed replica from a writer by shape, yet it must — a writer/tx read has to
4+
// reach the owning store's PRIMARY (read-your-writes) while a replica read stays on a replica (read
5+
// scaling). So the client builder brands replica handles and the routing store reads the brand.
6+
export const READ_REPLICA_BRAND: unique symbol = Symbol.for("trigger.dev/run-store/read-replica");
7+
8+
// Brand a replica client (returns the same object). MUST only be called on a genuine replica: the
9+
// routing layer trusts the brand to mean "do not escalate this read to the primary". An unbranded
10+
// replica just escalates as before — a scaling miss, never a correctness bug.
11+
export function markReadReplicaClient<T extends object>(client: T): T {
12+
try {
13+
(client as Record<symbol, unknown>)[READ_REPLICA_BRAND] = true;
14+
} catch {
15+
// Frozen/exotic clients may reject the assignment; only costs the optimization, not correctness.
16+
}
17+
return client;
18+
}
19+
20+
export function isReadReplicaClient(client: unknown): boolean {
21+
return (
22+
!!client &&
23+
typeof client === "object" &&
24+
(client as Record<symbol, unknown>)[READ_REPLICA_BRAND] === true
25+
);
26+
}

0 commit comments

Comments
 (0)