Skip to content

Commit 128d9aa

Browse files
committed
fix(webapp): coalesce concurrent shard-list refreshes, and use the real txn helper
Two review findings. The cache had a lost-update race. Two misses each issued a read, so a slower read landing after a faster one wrote its older snapshot back into the cache for a whole TTL. Refreshes are now single-flight: concurrent misses await one read, and the in-flight handle is cleared on settle so a failure still lets the next call retry. Three tests cover it with a deferred promise through the injected reader, no mocking. The admin action test's transaction stand-in was a reimplementation. It now delegates to the same shared helper the production wrapper wraps, so the transactional semantics, the nesting case and the retry behaviour are the real ones. Only the wrapper's tracing span and infrastructure-error logging are absent, and neither is asserted there.
1 parent b586544 commit 128d9aa

3 files changed

Lines changed: 100 additions & 18 deletions

File tree

apps/webapp/app/v3/runOpsMigration/mintShardAssignment.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,66 @@ describe("resolveMintShardWith — cache, read failure and fail-safe", () => {
319319
expect(await resolveMintShardWith({ id: "env_1" }, deps)).toBe("new");
320320
});
321321

322+
it("coalesces concurrent misses into ONE read", async () => {
323+
// Two misses must share a single read. Otherwise a slower read landing after a faster one
324+
// writes its older snapshot back into the cache for a whole TTL.
325+
let release: (flags: Record<string, unknown>) => void = () => {};
326+
const gate = new Promise<Record<string, unknown>>((resolve) => {
327+
release = resolve;
328+
});
329+
const deps = wrapperDeps({ readFlags: () => gate });
330+
331+
const both = Promise.all([
332+
resolveMintShardWith({ id: "env_1" }, deps),
333+
resolveMintShardWith({ id: "env_2" }, deps),
334+
]);
335+
release({ runOpsMintShardSet: "a,b" });
336+
await both;
337+
338+
expect(deps.reads).toBe(1);
339+
});
340+
341+
it("does not let a slower read overwrite a newer one", async () => {
342+
// The slow read starts first and finishes last. Its result must not become the cached
343+
// value, because the fast read already published a newer snapshot.
344+
let releaseSlow: (flags: Record<string, unknown>) => void = () => {};
345+
const slow = new Promise<Record<string, unknown>>((resolve) => {
346+
releaseSlow = resolve;
347+
});
348+
let call = 0;
349+
const deps = wrapperDeps({
350+
readFlags: () => {
351+
call++;
352+
return call === 1 ? slow : Promise.resolve({ runOpsMintShardSet: "c" });
353+
},
354+
});
355+
356+
const first = resolveMintShardWith({ id: "env_1" }, deps);
357+
const second = resolveMintShardWith({ id: "env_2" }, deps);
358+
releaseSlow({ runOpsMintShardSet: "a" });
359+
await Promise.all([first, second]);
360+
361+
// One read served both, so there is no second snapshot to race with.
362+
expect(deps.reads).toBe(1);
363+
expect(deps.cache.current?.value.resolution.set).toEqual(["a"]);
364+
});
365+
366+
it("clears the in-flight refresh after a failure, so the next call retries", async () => {
367+
let fail = true;
368+
const deps = wrapperDeps({
369+
readFlags: async () => {
370+
if (fail) throw new Error("db down");
371+
return { runOpsMintShardSet: "a,b" };
372+
},
373+
});
374+
deps.onReadFailed = () => {};
375+
376+
expect(await resolveMintShardWith({ id: "env_1" }, deps)).toBe("new");
377+
fail = false;
378+
expect(["a", "b"]).toContain(await resolveMintShardWith({ id: "env_1" }, deps));
379+
expect(deps.reads).toBe(2);
380+
});
381+
322382
it("agrees with the pure core for the same inputs", async () => {
323383
const deps = wrapperDeps();
324384
const viaWrapper = await resolveMintShardWith({ id: "env_1" }, deps);

apps/webapp/app/v3/runOpsMigration/mintShardAssignment.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,17 @@ type GlobalShardConfig = { resolution: MintShardSetResolution; override: unknown
133133

134134
export type MintShardCache = { value: GlobalShardConfig; expiresAt: number } | undefined;
135135

136+
type MintShardCacheHandle = {
137+
current: MintShardCache;
138+
// The refresh currently in flight, if any. Concurrent misses share it.
139+
inFlight?: Promise<GlobalShardConfig>;
140+
};
141+
136142
export type ResolveMintShardDeps = {
137143
// Reads the list rows. Injected so the cache and the fail-safe are testable without a
138144
// database, the same way computeRunIdMintKind takes its flag reader.
139145
readFlags: () => Promise<Record<string, unknown>>;
140-
cache: { current: MintShardCache };
146+
cache: MintShardCacheHandle;
141147
nowMs: number;
142148
ttlMs: number;
143149
graceMs: number;
@@ -155,6 +161,20 @@ export type ResolveMintShardDeps = {
155161
//
156162
// A failed read falls back to gen-1 rather than guessing a list. Guessing would move every
157163
// environment's placement for the length of one blip.
164+
async function refreshConfig(deps: ResolveMintShardDeps): Promise<GlobalShardConfig> {
165+
try {
166+
const flags = await deps.readFlags();
167+
const config: GlobalShardConfig = {
168+
resolution: readMintShardSetResolution(flags),
169+
override: flags[FEATURE_FLAG.runOpsMintShardOverride],
170+
};
171+
deps.cache.current = { value: config, expiresAt: deps.nowMs + deps.ttlMs };
172+
return config;
173+
} finally {
174+
deps.cache.inFlight = undefined;
175+
}
176+
}
177+
158178
export async function resolveMintShardWith(
159179
environment: { id: string; orgFeatureFlags?: unknown },
160180
deps: ResolveMintShardDeps
@@ -165,16 +185,13 @@ export async function resolveMintShardWith(
165185
config = cached.value;
166186
} else {
167187
try {
168-
const flags = await deps.readFlags();
169-
config = {
170-
resolution: readMintShardSetResolution(flags),
171-
override: flags[FEATURE_FLAG.runOpsMintShardOverride],
172-
};
188+
// Single-flight. Without it, two misses both read, and a slower read landing after a
189+
// faster one puts its older snapshot back into the cache for a whole TTL.
190+
config = await (deps.cache.inFlight ??= refreshConfig(deps));
173191
} catch (error) {
174192
deps.onReadFailed?.(error);
175193
return "new";
176194
}
177-
deps.cache.current = { value: config, expiresAt: deps.nowMs + deps.ttlMs };
178195
}
179196

180197
return computeMintShard(environment, {

apps/webapp/test/adminFeatureFlagsRouteAction.test.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// bug surface. These drive the real exported action against a real Postgres and assert on the rows
33
// it leaves behind. The only module substituted is the auth wrapper, so the handler can be called
44
// without a super-admin session; the database is the genuine article, injected into db.server.
5-
import { boundedIn } from "@trigger.dev/database";
5+
import { boundedIn, $transaction as realTransaction } from "@trigger.dev/database";
66
import type { PrismaClient } from "@trigger.dev/database";
77
import { postgresTest } from "@internal/testcontainers";
88
import { describe, expect, vi } from "vitest";
@@ -22,18 +22,23 @@ vi.mock("~/db.server", () => ({
2222
return db.client;
2323
},
2424
boundedIn,
25-
// The real helper adds tracing around prisma.$transaction and resolves undefined when it
26-
// swallows an infrastructure error. Neither is under test here, but the transactional
27-
// semantics are, so this stands in with the same shape and a real interactive transaction.
28-
$transaction: async (
25+
// Delegates to the SAME shared implementation the production helper wraps, so the
26+
// transactional semantics, the nesting case and the retry behaviour are the real ones rather
27+
// than a reimplementation. Only the webapp wrapper's tracing span and its infrastructure-error
28+
// logging are absent, and neither is asserted here.
29+
$transaction: (
2930
client: PrismaClient,
3031
nameOrFn: unknown,
31-
fnOrOptions?: unknown
32-
): Promise<unknown> => {
33-
const fn = (typeof nameOrFn === "function" ? nameOrFn : fnOrOptions) as (
34-
tx: PrismaClient
35-
) => Promise<unknown>;
36-
return client.$transaction((tx) => fn(tx as unknown as PrismaClient));
32+
fnOrOptions?: unknown,
33+
options?: unknown
34+
) => {
35+
const fn = (typeof nameOrFn === "function" ? nameOrFn : fnOrOptions) as Parameters<
36+
typeof realTransaction
37+
>[1];
38+
const opts = (typeof nameOrFn === "function" ? fnOrOptions : options) as Parameters<
39+
typeof realTransaction
40+
>[3];
41+
return realTransaction(client, fn, () => {}, opts);
3742
},
3843
}));
3944

0 commit comments

Comments
 (0)