Skip to content

Commit 0d2df4b

Browse files
committed
Merge remote-tracking branch 'origin/main' into samejr/Settings-page-layouts
2 parents 46c3a88 + d390624 commit 0d2df4b

10 files changed

Lines changed: 89 additions & 22 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Realtime run subscriptions can now be configured to read run data straight from the primary database, so a run's latest state is never served from a lagging replica. Off by default; replica reads are unchanged unless you turn it on.

apps/webapp/app/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ const EnvironmentSchema = z
462462
// TTL/size of the per-org realtimeBackend flag cache used to pick the serving backend.
463463
REALTIME_BACKEND_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
464464
REALTIME_BACKEND_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(50_000),
465+
REALTIME_BACKEND_NATIVE_RUN_READS_FROM_PRIMARY: z.string().default("0"),
465466
// "1" enables the read-your-writes gate: wake hydrates wait out the measured replica lag
466467
// (anchored to the change record's updatedAtMs) and stale reads are retried.
467468
REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED: z.string().default("1"),

apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getMeter } from "@internal/tracing";
2-
import { $replica } from "~/db.server";
2+
import { $replica, prisma } from "~/db.server";
33
import { runStore } from "~/v3/runStore.server";
44
import { env } from "~/env.server";
55
import { singleton } from "~/utils/singleton";
@@ -130,9 +130,11 @@ function initializeNativeRealtimeClient(): NativeRealtimeClient {
130130
})
131131
: undefined;
132132

133+
const runReadsFromPrimary = env.REALTIME_BACKEND_NATIVE_RUN_READS_FROM_PRIMARY === "1";
134+
133135
// One RunHydrator shared by the router and the client, so its single-flight + short-TTL cache covers both.
134136
const runReader = new RunHydrator({
135-
replica: $replica,
137+
readClient: runReadsFromPrimary ? prisma : $replica,
136138
runStore,
137139
cacheTtlMs: env.REALTIME_BACKEND_NATIVE_RUN_CACHE_TTL_MS,
138140
maxCacheEntries: env.REALTIME_BACKEND_NATIVE_RUN_CACHE_MAX_ENTRIES,
@@ -142,7 +144,7 @@ function initializeNativeRealtimeClient(): NativeRealtimeClient {
142144
// when idle) and the router delays wake hydrates by it, anchored to each record's
143145
// updatedAtMs — so a publish racing the replica's apply is waited out, not read stale.
144146
const lagEstimator =
145-
env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED === "1"
147+
!runReadsFromPrimary && env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED === "1"
146148
? new ReplicaLagEstimator({
147149
source: createPostgresReplicaLagSource($replica),
148150
sampleIntervalMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_SAMPLE_INTERVAL_MS,

apps/webapp/app/services/realtime/replicaLagEstimator.server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,25 @@ export interface ReplicaLagSource {
2525
/** Aurora: replicas share the storage layer and reject every standard WAL function;
2626
* `aurora_replica_status()` is the only live lag source. Max across readers, since the
2727
* `$replica` pool balances over all of them. No reader rows = `$replica` is the writer =
28-
* no lag. Throws on non-Aurora (the function doesn't exist). */
28+
* no lag. Throws on non-Aurora, but detects that with a `to_regproc` lookup rather than by
29+
* letting the call fail — an unresolvable function is a query error the driver reports to the
30+
* error log (and Sentry) regardless of the app-level catch, once per sample. */
2931
export class AuroraReplicaLagSource implements ReplicaLagSource {
3032
readonly name = "aurora";
33+
#available: boolean | undefined;
3134

3235
constructor(private readonly db: RawQueryable) {}
3336

3437
async sampleLagMs(): Promise<number | undefined> {
38+
if (this.#available === undefined) {
39+
const probe = await this.db.$queryRawUnsafe<{ available: boolean | null }[]>(
40+
`SELECT to_regproc('aurora_replica_status') IS NOT NULL AS available`
41+
);
42+
this.#available = probe[0]?.available === true;
43+
}
44+
if (!this.#available) {
45+
throw new Error("aurora_replica_status() is not available on this database");
46+
}
3547
const rows = await this.db.$queryRawUnsafe<{ lag: number | null }[]>(
3648
`SELECT max(replica_lag_in_msec)::float8 AS lag FROM aurora_replica_status() WHERE session_id <> 'MASTER_SESSION_ID' AND replica_lag_in_msec IS NOT NULL`
3749
);

apps/webapp/app/services/realtime/runReader.server.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,11 @@ export interface RunListResolver {
8282
}
8383

8484
export type RunHydratorOptions = {
85-
/** A read-replica Prisma client (`$replica`). Always Postgres. */
86-
replica: Pick<PrismaClient, "taskRun">;
87-
/** RunStore the reads are routed through; `replica` is passed as the read client. */
85+
/** The Prisma client handed to the RunStore as the read client. Always Postgres. A branded
86+
* replica (`$replica`) keeps routed reads on each store's replica; an unbranded writer
87+
* (`prisma`) escalates them to each store's own primary. */
88+
readClient: Pick<PrismaClient, "taskRun">;
89+
/** RunStore the reads are routed through. */
8890
runStore: RunStore;
8991
/** Read-through cache TTL (ms) collapsing duplicate refetches for the same run. Set 0 to disable. Defaults to 250ms. */
9092
cacheTtlMs?: number;
@@ -154,7 +156,7 @@ export class RunHydrator {
154156
},
155157
select: buildHydratorSelect(skipColumns),
156158
},
157-
this.options.replica as PrismaClientOrTransaction
159+
this.options.readClient as PrismaClientOrTransaction
158160
);
159161
return rows as unknown as RealtimeRunRow[];
160162
}
@@ -166,7 +168,7 @@ export class RunHydrator {
166168
runtimeEnvironmentId: environmentId,
167169
},
168170
{ select: RUN_HYDRATOR_SELECT },
169-
this.options.replica as PrismaClientOrTransaction
171+
this.options.readClient as PrismaClientOrTransaction
170172
);
171173

172174
return (run ?? null) as RealtimeRunRow | null;

apps/webapp/app/services/realtime/shadowRealtimeClientInstance.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function initializeShadowRealtimeClient(): ShadowRealtimeClient {
2121
});
2222

2323
const comparator = new RealtimeShadowComparator({
24-
runReader: new RunHydrator({ replica: $replica, runStore }),
24+
runReader: new RunHydrator({ readClient: $replica, runStore }),
2525
runListResolver: new ClickHouseRunListResolver({
2626
getClickhouse: (organizationId) =>
2727
clickhouseFactory.getClickhouseForOrganization(organizationId, "realtime"),

apps/webapp/test/realtime/replicaLagEstimator.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, describe, expect, it } from "vitest";
22
import {
3+
AuroraReplicaLagSource,
34
FirstSupportedReplicaLagSource,
45
ReplicaLagEstimator,
56
type ReplicaLagSource,
@@ -162,3 +163,43 @@ describe("FirstSupportedReplicaLagSource", () => {
162163
expect(composed.name).toBe("flaky");
163164
});
164165
});
166+
167+
describe("AuroraReplicaLagSource", () => {
168+
function fakeDb(available: boolean) {
169+
const queries: string[] = [];
170+
return {
171+
queries,
172+
db: {
173+
async $queryRawUnsafe<T = unknown>(query: string): Promise<T> {
174+
queries.push(query);
175+
if (query.includes("to_regproc")) {
176+
return [{ available: available ? true : null }] as T;
177+
}
178+
return [{ lag: 12.5 }] as T;
179+
},
180+
},
181+
};
182+
}
183+
184+
it("never puts aurora_replica_status() on the wire when the function is absent", async () => {
185+
const { db, queries } = fakeDb(false);
186+
const aurora = new AuroraReplicaLagSource(db);
187+
188+
await expect(aurora.sampleLagMs()).rejects.toThrow(/not available/);
189+
await expect(aurora.sampleLagMs()).rejects.toThrow(/not available/);
190+
191+
expect(queries.filter((q) => q.includes("FROM aurora_replica_status()"))).toHaveLength(0);
192+
expect(queries.filter((q) => q.includes("to_regproc"))).toHaveLength(1);
193+
});
194+
195+
it("samples lag on Aurora and probes for the function only once", async () => {
196+
const { db, queries } = fakeDb(true);
197+
const aurora = new AuroraReplicaLagSource(db);
198+
199+
expect(await aurora.sampleLagMs()).toBe(12.5);
200+
expect(await aurora.sampleLagMs()).toBe(12.5);
201+
202+
expect(queries.filter((q) => q.includes("to_regproc"))).toHaveLength(1);
203+
expect(queries.filter((q) => q.includes("FROM aurora_replica_status()"))).toHaveLength(2);
204+
});
205+
});

apps/webapp/test/realtime/runReaderProjection.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ describe("RunHydrator.hydrateByIds column projection", () => {
5656
},
5757
} as any;
5858
const runStore = new PostgresRunStore({ prisma: replica, readOnlyPrisma: replica });
59-
return { hydrator: new RunHydrator({ replica, runStore }), getSelect: () => capturedSelect };
59+
return {
60+
hydrator: new RunHydrator({ readClient: replica, runStore }),
61+
getSelect: () => capturedSelect,
62+
};
6063
}
6164

6265
it("projects the SELECT by skipColumns", async () => {

apps/webapp/test/realtime/runReaderReadThrough.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () =
218218
});
219219

220220
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
221-
const hydrator = new RunHydrator({ replica: prisma14, runStore });
221+
const hydrator = new RunHydrator({ readClient: prisma14, runStore });
222222

223223
const rows = await hydrator.hydrateByIds(envId, [newRunId, legacyRunId]);
224224
expect(rows.map((r) => r.id).sort()).toEqual([legacyRunId, newRunId].sort());
@@ -267,7 +267,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () =
267267
});
268268

269269
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
270-
const hydrator = new RunHydrator({ replica: prisma14, runStore });
270+
const hydrator = new RunHydrator({ readClient: prisma14, runStore });
271271

272272
const row = await hydrator.getRunById(envId, migratedRunId);
273273
expect(row?.id).toBe(migratedRunId);
@@ -300,7 +300,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () =
300300
});
301301

302302
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
303-
const hydrator = new RunHydrator({ replica: prisma14, runStore });
303+
const hydrator = new RunHydrator({ readClient: prisma14, runStore });
304304

305305
const byId = await hydrator.getRunById(envId, oldRunId);
306306
expect(byId?.payload).toBe('{"era":"old"}');
@@ -338,7 +338,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () =
338338

339339
// A generic legacy replica would miss the NEW row entirely — the metadata must come off NEW.
340340
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
341-
const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 0 });
341+
const hydrator = new RunHydrator({ readClient: prisma14, runStore, cacheTtlMs: 0 });
342342

343343
const snapshot = await hydrator.getRunById(envId, terminalRunId);
344344
expect(snapshot?.id).toBe(terminalRunId);
@@ -385,7 +385,7 @@ describe("RunHydrator read-route through the runStore seam (legacy + new)", () =
385385
// Use a 0ms TTL so each getRunById re-reads through the seam (no cached stale row across the
386386
// crossing). Single-flight/TTL are proven separately below.
387387
const runStore = makeRoutingShapedStore({ newStore, legacyStore, classify });
388-
const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 0 });
388+
const hydrator = new RunHydrator({ readClient: prisma14, runStore, cacheTtlMs: 0 });
389389

390390
// Before migration: served from LEGACY.
391391
const before = await hydrator.getRunById(envId, runId);
@@ -434,7 +434,7 @@ describe("RunHydrator single-flight + TTL cache intact across the seam", () => {
434434
});
435435

436436
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
437-
const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 60_000 });
437+
const hydrator = new RunHydrator({ readClient: prisma14, runStore, cacheTtlMs: 60_000 });
438438

439439
// Two concurrent calls -> single-flight collapses to ONE underlying read.
440440
const [a, b] = await Promise.all([
@@ -466,7 +466,7 @@ describe("RunHydrator single-flight + TTL cache intact across the seam", () => {
466466
const missingRunId = newId("missing_run");
467467

468468
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
469-
const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 60_000 });
469+
const hydrator = new RunHydrator({ readClient: prisma14, runStore, cacheTtlMs: 60_000 });
470470

471471
const first = await hydrator.getRunById(envId, missingRunId);
472472
expect(first).toBeNull();
@@ -504,7 +504,7 @@ describe("RunHydrator single-DB passthrough (one PostgresRunStore over one clien
504504
});
505505
}
506506

507-
const hydrator = new RunHydrator({ replica: prisma, runStore: store, cacheTtlMs: 60_000 });
507+
const hydrator = new RunHydrator({ readClient: prisma, runStore: store, cacheTtlMs: 60_000 });
508508

509509
// hydrateByIds returns both rows from the single client.
510510
const rows = await hydrator.hydrateByIds(envId, [runIdA, runIdB]);
@@ -523,7 +523,7 @@ describe("RunHydrator single-DB passthrough (one PostgresRunStore over one clien
523523
postgresTest("empty id-set returns [] without touching the store", async ({ prisma }) => {
524524
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
525525
const findRunsSpy = vi.spyOn(store, "findRuns");
526-
const hydrator = new RunHydrator({ replica: prisma, runStore: store });
526+
const hydrator = new RunHydrator({ readClient: prisma, runStore: store });
527527

528528
const rows = await hydrator.hydrateByIds("env_none", []);
529529
expect(rows).toEqual([]);

apps/webapp/test/realtimeServices.replicaLag.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ describe("realtime-svc — replica-lag guards", () => {
216216
const replica = laggingReplica(prisma, [{ model: "taskRun", mode: "missing" }]);
217217
// The hydrator holds the real store; hydrateByIds passes `options.replica` as the read client.
218218
const hydrator = new RunHydrator({
219-
replica: replica.client as PrismaClient,
219+
readClient: replica.client as PrismaClient,
220220
runStore: writerStore,
221221
cacheTtlMs: 0,
222222
});
@@ -263,7 +263,7 @@ describe("realtime-svc — replica-lag guards", () => {
263263

264264
const replica = laggingReplica(prisma, [{ model: "taskRun", mode: "missing" }]);
265265
const hydrator = new RunHydrator({
266-
replica: replica.client as PrismaClient,
266+
readClient: replica.client as PrismaClient,
267267
runStore: writerStore,
268268
cacheTtlMs: 0,
269269
});

0 commit comments

Comments
 (0)