Skip to content

Commit 30a6be9

Browse files
committed
fix(run-store): guard every redis-only read fallback so an empty Postgres is never served
1 parent 42d172a commit 30a6be9

4 files changed

Lines changed: 357 additions & 27 deletions

File tree

‎apps/webapp/app/v3/snapshotStoreMode.server.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ type ResolverOrgSource = Pick<OrgModeSource, "get" | "refresh"> &
6161
/** Resolves a run to its organisation. Cache-only and synchronous, undefined on a miss. */
6262
type ResolverRunOrgSource = {
6363
resolve(runId: string): string | undefined;
64+
/** Bounded authoritative read, throws on failure/timeout, for the redis-only fallback gate. */
65+
resolveAuthoritative?(runId: string): Promise<string>;
6466
};
6567

6668
/** The census read accessors the resolver delegates to. Both synchronous and no-query. */
@@ -137,6 +139,18 @@ export function buildSnapshotStoreModeResolver(deps: {
137139
}
138140
return resolveMode(organizationId);
139141
},
142+
// Authoritative counterpart, used only when the sync read is unresolved and some org is
143+
// redis-only. Resolves run→org from the primary (bounded, throws on failure), warms the org dial
144+
// so the immediate read is accurate, then answers with the org's mode. A throw propagates so the
145+
// decorator fails closed.
146+
readModeForAuthoritative: async (runId: string): Promise<DialMode | undefined> => {
147+
if (!deps.runOrg?.resolveAuthoritative) {
148+
return undefined;
149+
}
150+
const organizationId = await deps.runOrg.resolveAuthoritative(runId);
151+
await deps.orgMode.warm?.(organizationId);
152+
return resolveMode(organizationId);
153+
},
140154
anyOrgReadEnabled: (): boolean => deps.census?.anyOrgReadEnabled() ?? false,
141155
anyOrgRedisOnly: (): boolean => deps.census?.anyOrgRedisOnly() ?? false,
142156
};
@@ -329,7 +343,10 @@ export const snapshotStoreModeResolver: SnapshotStoreModeResolver = buildSnapsho
329343
refresh: (organizationId) => orgModeSource().refresh(organizationId),
330344
warm: (organizationId) => orgModeSource().warm(organizationId),
331345
},
332-
runOrg: { resolve: (runId) => snapshotRunOrgSource().resolve(runId) },
346+
runOrg: {
347+
resolve: (runId) => snapshotRunOrgSource().resolve(runId),
348+
resolveAuthoritative: (runId) => snapshotRunOrgSource().resolveAuthoritative(runId),
349+
},
333350
census: {
334351
anyOrgReadEnabled: () => snapshotStoreOrgCensus.anyOrgReadEnabled(),
335352
anyOrgRedisOnly: () => snapshotStoreOrgCensus.anyOrgRedisOnly(),

‎apps/webapp/test/snapshotStoreMode.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,67 @@ describe("the org-scoped read routing", () => {
177177
});
178178
});
179179

180+
describe("the authoritative read position (redis-only fallback gate)", () => {
181+
function buildAuth(opts: {
182+
globalMode?: SnapshotStoreMode;
183+
perOrg?: Record<string, SnapshotStoreMode>;
184+
resolveAuthoritative?: (runId: string) => Promise<string>;
185+
warm?: (organizationId: string) => Promise<void>;
186+
}) {
187+
return buildSnapshotStoreModeResolver({
188+
globalMode: () => opts.globalMode,
189+
orgMode: {
190+
get: (id: string) => opts.perOrg?.[id],
191+
refresh: () => {},
192+
...(opts.warm && { warm: opts.warm }),
193+
},
194+
runOrg: {
195+
resolve: () => undefined,
196+
...(opts.resolveAuthoritative && { resolveAuthoritative: opts.resolveAuthoritative }),
197+
},
198+
envFloor: "off",
199+
});
200+
}
201+
202+
it("resolves the run's org authoritatively, warms the dial, and returns the org mode", async () => {
203+
const warmed: string[] = [];
204+
const r = buildAuth({
205+
globalMode: "redis-read",
206+
perOrg: { org_ro: "redis-only" },
207+
resolveAuthoritative: async () => "org_ro",
208+
warm: async (id) => {
209+
warmed.push(id);
210+
},
211+
});
212+
await expect(r.readModeForAuthoritative?.("run_1")).resolves.toBe("redis-only");
213+
expect(warmed).toContain("org_ro");
214+
});
215+
216+
it("returns a non-redis-only mode for a pre-cutover run so the decorator falls back", async () => {
217+
const r = buildAuth({
218+
globalMode: "redis-read",
219+
perOrg: {},
220+
resolveAuthoritative: async () => "org_pre",
221+
});
222+
await expect(r.readModeForAuthoritative?.("run_1")).resolves.toBe("redis-read");
223+
});
224+
225+
it("propagates a throw from the authoritative run→org read so the decorator fails closed", async () => {
226+
const r = buildAuth({
227+
globalMode: "redis-read",
228+
resolveAuthoritative: async () => {
229+
throw new Error("run→org read timed out");
230+
},
231+
});
232+
await expect(r.readModeForAuthoritative?.("run_1")).rejects.toThrow(/timed out/);
233+
});
234+
235+
it("returns undefined when no authoritative run→org source is wired", async () => {
236+
const r = buildAuth({ globalMode: "redis-read" });
237+
await expect(r.readModeForAuthoritative?.("run_1")).resolves.toBeUndefined();
238+
});
239+
});
240+
180241
describe("a saved organisation dial survives a lagging replica", () => {
181242
type Deferred = { resolve: (v: unknown) => void; promise: Promise<unknown> };
182243

‎internal-packages/run-store/src/taskRunExecutionSnapshotStore.redisOnlyFallback.test.ts‎

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,205 @@ describe("redis-only throws on a miss instead of serving empty Postgres", () =>
239239
).rejects.toThrow(/redis-only/);
240240
expect(h.delegateTouched).not.toContain("findSnapshotCompletedWaitpointIds");
241241
});
242+
243+
it("findExecutionSnapshot throws on a by-id miss at redis-only", async () => {
244+
const h = missHarness({ globalMode: "redis-only" });
245+
246+
await expect(
247+
h.decorated.findExecutionSnapshot({
248+
where: { id: "snap_1", runId: "run_x" },
249+
select: { createdAt: true },
250+
} as never)
251+
).rejects.toThrow(/redis-only/);
252+
expect(h.delegateTouched).not.toContain("findExecutionSnapshot");
253+
});
254+
});
255+
256+
// The unified decision: when the run→org cache is COLD (readModeFor undefined) and some org is
257+
// redis-only, the sync gate cannot tell whether THIS run is redis-only. It must resolve the run's
258+
// org authoritatively rather than either strand a redis-only run (empty Postgres) or over-throw a
259+
// pre-cutover run. This harness supplies that authoritative hook and a configurable Redis read.
260+
function authHarness(opts: {
261+
globalMode: SnapshotStoreMode;
262+
read: "miss" | "dangling" | "error";
263+
readModeFor?: (runId: string, environmentId?: string) => SnapshotStoreMode | undefined;
264+
anyOrgRedisOnly?: () => boolean;
265+
anyOrgReadEnabled?: () => boolean;
266+
authoritative?: (runId: string) => Promise<SnapshotStoreMode | undefined>;
267+
}) {
268+
const danglingRead = {
269+
id: "snap_head",
270+
seq: 1,
271+
isValid: true,
272+
entry: { id: "snap_head", createdAt: new Date().toISOString() },
273+
raw: "{}",
274+
danglingCycle: true,
275+
};
276+
const redisRead = () => {
277+
if (opts.read === "error") throw new Error("redis brownout");
278+
return Promise.resolve(opts.read === "dangling" ? danglingRead : null);
279+
};
280+
const redis = new Proxy({} as RedisSnapshotStore, {
281+
get: (_t, prop) => {
282+
if (prop === "getSinceCreatedAt")
283+
return () => (opts.read === "error" ? redisRead() : Promise.resolve({ kind: "miss" }));
284+
if (prop === "getSnapshotWaitpointIds")
285+
return () =>
286+
opts.read === "error"
287+
? redisRead()
288+
: Promise.resolve({ present: false, distinctIds: [], order: [] });
289+
return () => redisRead();
290+
},
291+
});
292+
293+
const delegateTouched: string[] = [];
294+
const delegate = new Proxy({} as Record<string, unknown>, {
295+
get: (_t, prop) => {
296+
return (...__: unknown[]) => {
297+
delegateTouched.push(String(prop));
298+
return Promise.resolve({ id: "pg_fallback" });
299+
};
300+
},
301+
}) as unknown as RunStore;
302+
303+
const authoritativeCalls: string[] = [];
304+
const modeResolver: SnapshotStoreModeResolver = {
305+
resolve: () => opts.globalMode,
306+
...(opts.readModeFor && {
307+
readModeFor: opts.readModeFor as SnapshotStoreModeResolver["readModeFor"],
308+
}),
309+
...(opts.anyOrgRedisOnly && { anyOrgRedisOnly: opts.anyOrgRedisOnly }),
310+
...(opts.anyOrgReadEnabled && { anyOrgReadEnabled: opts.anyOrgReadEnabled }),
311+
...(opts.authoritative && {
312+
readModeForAuthoritative: (async (runId: string) => {
313+
authoritativeCalls.push(runId);
314+
return opts.authoritative!(runId);
315+
}) as SnapshotStoreModeResolver["readModeForAuthoritative"],
316+
}),
317+
};
318+
319+
const decorated = new TaskRunExecutionSnapshotStore(delegate, {
320+
store: redis,
321+
mode: opts.globalMode,
322+
modeResolver,
323+
readPercent: 100,
324+
});
325+
326+
return { decorated, delegateTouched, authoritativeCalls };
327+
}
328+
329+
describe("redis-only fallback resolves a cold run→org cache authoritatively", () => {
330+
it("routing branch: a cold-cache redis-only run at global dual-write THROWS, never empty PG", async () => {
331+
const h = authHarness({
332+
globalMode: "dual-write",
333+
read: "miss",
334+
readModeFor: () => undefined,
335+
anyOrgRedisOnly: () => true,
336+
authoritative: async () => "redis-only",
337+
});
338+
339+
await expect(
340+
h.decorated.findLatestExecutionSnapshot("run_cold", undefined, "env_a")
341+
).rejects.toThrow(/redis-only/);
342+
expect(h.delegateTouched).not.toContain("findLatestExecutionSnapshot");
343+
expect(h.authoritativeCalls).toContain("run_cold");
344+
});
345+
346+
it("routing branch: a cold-cache dual-write run falls back (no over-throw)", async () => {
347+
const h = authHarness({
348+
globalMode: "dual-write",
349+
read: "miss",
350+
readModeFor: () => undefined,
351+
anyOrgRedisOnly: () => true,
352+
authoritative: async () => "dual-write",
353+
});
354+
355+
const result = await h.decorated.findLatestExecutionSnapshot("run_cold", undefined, "env_a");
356+
expect(result).toEqual({ id: "pg_fallback" });
357+
expect(h.delegateTouched).toContain("findLatestExecutionSnapshot");
358+
});
359+
360+
it("off/dual-write with no org redis-only falls back WITHOUT an authoritative read", async () => {
361+
const h = authHarness({
362+
globalMode: "dual-write",
363+
read: "miss",
364+
readModeFor: () => undefined,
365+
anyOrgRedisOnly: () => false,
366+
authoritative: async () => "redis-only",
367+
});
368+
369+
const result = await h.decorated.findLatestExecutionSnapshot("run_x", undefined, "env_a");
370+
expect(result).toEqual({ id: "pg_fallback" });
371+
expect(h.authoritativeCalls).toEqual([]);
372+
});
373+
374+
it("MISS at global redis-read with a cold-cache pre-cutover run FALLS BACK (coexistence)", async () => {
375+
const h = authHarness({
376+
globalMode: "redis-read",
377+
read: "miss",
378+
anyOrgReadEnabled: () => true,
379+
readModeFor: () => undefined,
380+
anyOrgRedisOnly: () => true,
381+
authoritative: async () => "redis-read",
382+
});
383+
384+
const result = await h.decorated.findLatestExecutionSnapshot("run_pre", undefined, "env_a");
385+
expect(result).toEqual({ id: "pg_fallback" });
386+
expect(h.delegateTouched).toContain("findLatestExecutionSnapshot");
387+
});
388+
389+
it("MISS at global redis-read with a cold-cache redis-only run THROWS", async () => {
390+
const h = authHarness({
391+
globalMode: "redis-read",
392+
read: "miss",
393+
anyOrgReadEnabled: () => true,
394+
readModeFor: () => undefined,
395+
anyOrgRedisOnly: () => true,
396+
authoritative: async () => "redis-only",
397+
});
398+
399+
await expect(
400+
h.decorated.findLatestExecutionSnapshot("run_ro", undefined, "env_a")
401+
).rejects.toThrow(/redis-only/);
402+
expect(h.delegateTouched).not.toContain("findLatestExecutionSnapshot");
403+
});
404+
405+
it("fails closed (THROWS) when the authoritative read itself fails and some org is redis-only", async () => {
406+
const h = authHarness({
407+
globalMode: "redis-read",
408+
read: "miss",
409+
anyOrgReadEnabled: () => true,
410+
readModeFor: () => undefined,
411+
anyOrgRedisOnly: () => true,
412+
authoritative: async () => {
413+
throw new Error("run→org read timed out");
414+
},
415+
});
416+
417+
await expect(
418+
h.decorated.findLatestExecutionSnapshot("run_cold", undefined, "env_a")
419+
).rejects.toThrow(/redis-only/);
420+
expect(h.delegateTouched).not.toContain("findLatestExecutionSnapshot");
421+
});
422+
423+
it("danglingCycle at global redis-only THROWS, never empty PG", async () => {
424+
const h = authHarness({ globalMode: "redis-only", read: "dangling" });
425+
426+
await expect(
427+
h.decorated.findLatestExecutionSnapshot("run_x", undefined, "env_a")
428+
).rejects.toThrow(/redis-only/);
429+
expect(h.delegateTouched).not.toContain("findLatestExecutionSnapshot");
430+
});
431+
432+
it("danglingCycle below redis-only still falls back to Postgres", async () => {
433+
const h = authHarness({
434+
globalMode: "redis-read",
435+
read: "dangling",
436+
anyOrgReadEnabled: () => true,
437+
});
438+
439+
const result = await h.decorated.findLatestExecutionSnapshot("run_x", undefined, "env_a");
440+
expect(result).toEqual({ id: "pg_fallback" });
441+
expect(h.delegateTouched).toContain("findLatestExecutionSnapshot");
442+
});
242443
});

0 commit comments

Comments
 (0)