Skip to content

Commit bd88f15

Browse files
committed
fix(webapp,run-store): close snapshot-store activation-time correctness gaps
Found by an adversarial review of the off-by-default snapshot store. None affect the merged-inert state; all bite only once a Redis host is configured and orgs are ramped. - The v1 programmatic org flag route set the per-org dial but never stamped the one-way residency latch (only the v2 route did), so a run born after a v1 enable was resident while the census kept classifying the org never-enabled and skipped its transitions, freezing its Redis head. Stamp the latch and refresh the census, matching v2. - At redis-only a non-throwing append outcome (forked or skippedNoKeyspace) reached the outcome handler and enqueued a Postgres repair or was dropped, but Postgres holds no snapshot there. Treat both as fatal at redis-only, matching the thrown-error path, so the caller retries rather than losing the transition. - A failed post-save primary read of an org dial left it wedged on the deployment-wide position until the next save or a restart. Retry the primary briefly, then clear the pending flag so refreshes resume. - Correct the redis-only boot warning: this build suppresses Postgres snapshot writes at redis-only, so the old "always writes Postgres" note was misleading.
1 parent f6fe82d commit bd88f15

7 files changed

Lines changed: 361 additions & 42 deletions

apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.
99
import { globalFlagsRegistry } from "~/v3/globalFlagsRegistry.server";
1010
import { snapshotStoreFlagSaveError } from "~/v3/snapshotStoreFlagGuard.server";
1111
import { invalidateSnapshotStoreOrgMode } from "~/v3/snapshotStoreMode.server";
12+
import { snapshotStoreOrgCensus } from "~/v3/snapshotStoreOrgCensus.server";
1213
import { selectMintBaselineSource, stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
1314
import {
1415
FEATURE_FLAG,
16+
stampSnapshotStoreOrgEverEnabled,
1517
validatePartialFeatureFlags,
1618
withoutOrgForbiddenSnapshotKeys,
1719
} from "~/v3/featureFlags";
@@ -128,6 +130,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
128130
env.RUN_OPS_MINT_FLIP_GRACE_MS
129131
);
130132

133+
// One-way per-org residency latch, exactly as the v2 route does. Without it a run born after
134+
// this enable is resident but the census keeps classifying the org definitely-never-enabled, so
135+
// its transitions are skipped and its Redis head freezes. ORed against the locked existing value
136+
// so a save back to off never clears it.
137+
stampSnapshotStoreOrgEverEnabled(existingRaw, mergedFlags);
138+
131139
return tx.organization.update({
132140
where: {
133141
id: organizationId,
@@ -150,6 +158,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
150158
// Org feature flags are embedded in every env of the org; drop all its cached env rows.
151159
controlPlaneResolver.invalidateOrganization(organizationId);
152160
invalidateSnapshotStoreOrgMode(organizationId);
161+
// Refresh the census in THIS process at once, as the v2 route does, so a just-enabled org stops
162+
// reading as definitely-never-enabled here immediately. Other pods lag at most the reload interval.
163+
void snapshotStoreOrgCensus.refresh();
153164

154165
const updatedFlagsResult = updatedOrganization.featureFlags
155166
? validatePartialFeatureFlags(updatedOrganization.featureFlags as Record<string, unknown>)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export async function assertSnapshotStoreBoot(deps: SnapshotStoreBootDeps): Prom
9595

9696
if (deps.mode === "redis-only") {
9797
deps.warn(
98-
"Snapshot store dial is redis-only but this build always writes Postgres snapshots. Double-writing is safe, but a Redis fault at this position fails run creation.",
98+
"Snapshot store dial is redis-only: Postgres snapshot writes are suppressed, so Redis is the sole store for run snapshots. A Redis fault at this position fails run creation and cannot be repaired from Postgres, and rolling the dial down does not recover runs born here.",
9999
{ mode: deps.mode }
100100
);
101101
}

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

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ const WARM_TIMEOUT_MS = 500;
186186
const DEFAULT_CACHE_MAX = 10_000;
187187
const DEFAULT_CACHE_TTL_MS = 30_000;
188188

189+
// A failed post-save primary read must not wedge the org on the global fallback forever: retry the
190+
// PRIMARY a few times (the replica stays blocked meanwhile so it cannot restore the superseded value),
191+
// then give up and let normal refreshes resume. A bounded stale window beats a permanent one.
192+
const DEFAULT_PRIMARY_INVALIDATE_RETRY_DELAYS_MS = [100, 250, 500, 1000];
193+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
194+
189195
type OrgModeClient = {
190196
organization: {
191197
findFirst(args: {
@@ -195,12 +201,17 @@ type OrgModeClient = {
195201
};
196202
};
197203

198-
export function createOrgModeSource(clients?: {
199-
primary: OrgModeClient;
200-
replica: OrgModeClient;
201-
}): OrgModeSource {
204+
export function createOrgModeSource(
205+
clients?: {
206+
primary: OrgModeClient;
207+
replica: OrgModeClient;
208+
},
209+
opts?: { primaryInvalidateRetryDelaysMs?: number[] }
210+
): OrgModeSource {
202211
const primaryClient = (clients?.primary ?? prisma) as OrgModeClient;
203212
const replicaClient = (clients?.replica ?? $replica) as OrgModeClient;
213+
const primaryInvalidateRetryDelaysMs =
214+
opts?.primaryInvalidateRetryDelaysMs ?? DEFAULT_PRIMARY_INVALIDATE_RETRY_DELAYS_MS;
204215
// Defaults inline as well as in the schema: this must not throw when a caller supplies a partial
205216
// env, and an LRU with neither bound set is a constructor error.
206217
const cache = new LRUCache<string, CachedOrg>({
@@ -233,22 +244,7 @@ export function createOrgModeSource(clients?: {
233244
generations.set(organizationId, generation);
234245
cache.delete(organizationId);
235246
primaryPending.set(organizationId, generation);
236-
void load(organizationId, primaryClient, generation).then((outcome) => {
237-
// Only if no NEWER save has claimed it since. Deleting unconditionally is what let an older
238-
// read reopen the window for a newer one.
239-
if (primaryPending.get(organizationId) !== generation) {
240-
return;
241-
}
242-
// And only if the primary actually ANSWERED. `load` swallows its own errors, so a rejected
243-
// primary read used to clear the flag with nothing cached, reopening the window to a lagging
244-
// replica that would then restore the pre-save value for a full cache lifetime. Staying
245-
// pending keeps replica refreshes out until a primary read succeeds; the resolver falls back
246-
// to the deployment-wide position meanwhile, which is the safe answer.
247-
if (outcome === "failed") {
248-
return;
249-
}
250-
primaryPending.delete(organizationId);
251-
});
247+
void loadWithPrimaryRetry(organizationId, generation);
252248
},
253249
refresh: (organizationId) => {
254250
// A save is mid-read for this organisation. Its answer is authoritative and a replica cannot
@@ -292,6 +288,36 @@ export function createOrgModeSource(clients?: {
292288
},
293289
};
294290

291+
// The save's own primary read, with bounded retry. primaryPending stays set for `generation` across
292+
// retries so a lagging replica cannot restore the superseded value; a success clears it, and so does
293+
// exhausting the retries, so a persistent primary fault falls back to normal refreshes rather than
294+
// wedging the org on the global position until the next save or a restart.
295+
async function loadWithPrimaryRetry(organizationId: string, generation: number): Promise<void> {
296+
for (let attempt = 0; ; attempt++) {
297+
const outcome = await load(organizationId, primaryClient, generation);
298+
// A newer save (or its read) has superseded this one; it now owns the flag.
299+
if (primaryPending.get(organizationId) !== generation) {
300+
return;
301+
}
302+
if (outcome !== "failed") {
303+
primaryPending.delete(organizationId);
304+
return;
305+
}
306+
if (attempt >= primaryInvalidateRetryDelaysMs.length) {
307+
// Retries exhausted: clear the flag so replica refreshes resume. The next save or reload
308+
// corrects any briefly-restored stale value; a permanent wedge would not.
309+
if (primaryPending.get(organizationId) === generation) {
310+
primaryPending.delete(organizationId);
311+
}
312+
return;
313+
}
314+
await sleep(primaryInvalidateRetryDelaysMs[attempt]);
315+
if (primaryPending.get(organizationId) !== generation) {
316+
return;
317+
}
318+
}
319+
}
320+
295321
function load(
296322
organizationId: string,
297323
client: OrgModeClient,
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// The v1 PAT route enables an org's snapshot dial with merge semantics. It must stamp the one-way
2+
// per-org residency latch (snapshotStoreOrgEverEnabled) exactly as the v2 route does; without it the
3+
// census keeps the org classified definitely-never-enabled and its resident runs' transitions are
4+
// skipped. These drive the real exported action against a real Postgres and assert the stored blob.
5+
// The guard is stubbed to a pass so the test isolates the stamp wiring (the guard has its own tests);
6+
// only peripheral module boundaries are substituted, the database is the genuine article.
7+
import type { PrismaClient } from "@trigger.dev/database";
8+
import { postgresTest } from "@internal/testcontainers";
9+
import { describe, expect, vi } from "vitest";
10+
import { FEATURE_FLAG } from "~/v3/featureFlags";
11+
12+
vi.setConfig({ testTimeout: 60_000 });
13+
14+
const db = vi.hoisted(() => ({ client: null as unknown as PrismaClient }));
15+
16+
vi.mock("~/services/personalAccessToken.server", () => ({
17+
requireAdminApiRequest: async () => ({}),
18+
}));
19+
20+
vi.mock("~/db.server", () => ({
21+
get prisma() {
22+
return db.client;
23+
},
24+
get $replica() {
25+
return db.client;
26+
},
27+
}));
28+
29+
// Bypass the host/arming-latch gate so the test exercises the stamp path directly. The guard's own
30+
// behaviour (reject without a host or before the global latch) is covered in its own tests.
31+
vi.mock("~/v3/snapshotStoreFlagGuard.server", () => ({
32+
snapshotStoreFlagSaveError: () => undefined,
33+
}));
34+
35+
vi.mock("~/v3/runOpsMigration/controlPlaneResolver.server", () => ({
36+
controlPlaneResolver: { invalidateOrganization: () => {} },
37+
}));
38+
39+
// Only used to seed the mint-flip baseline; irrelevant to the residency latch under test.
40+
vi.mock("~/v3/featureFlags.server", () => ({
41+
flags: async () => ({}),
42+
}));
43+
44+
import { action } from "~/routes/admin.api.v1.orgs.$organizationId.feature-flags";
45+
46+
const MODE = FEATURE_FLAG.snapshotStoreOrgMode;
47+
const LATCH = FEATURE_FLAG.snapshotStoreOrgEverEnabled;
48+
49+
let orgSeq = 0;
50+
51+
async function seedOrg(prisma: PrismaClient, featureFlags?: Record<string, unknown>) {
52+
db.client = prisma;
53+
const id = `org_v1route_${orgSeq++}`;
54+
await prisma.organization.create({
55+
data: {
56+
id,
57+
title: "V1 route test org",
58+
slug: `v1-route-${id}`,
59+
...(featureFlags ? { featureFlags } : {}),
60+
},
61+
});
62+
return id;
63+
}
64+
65+
async function post(organizationId: string, body: unknown) {
66+
const request = new Request(
67+
`https://localhost:3030/admin/api/v1/orgs/${organizationId}/feature-flags`,
68+
{
69+
method: "POST",
70+
body: JSON.stringify(body),
71+
headers: { "content-type": "application/json" },
72+
}
73+
);
74+
return (await (action as any)({
75+
request,
76+
params: { organizationId },
77+
context: {},
78+
})) as Response;
79+
}
80+
81+
async function readFlags(prisma: PrismaClient, id: string) {
82+
const row = await prisma.organization.findFirst({
83+
where: { id },
84+
select: { featureFlags: true },
85+
});
86+
return (row?.featureFlags ?? null) as Record<string, unknown> | null;
87+
}
88+
89+
describe("admin v1 org feature-flags route stamps the per-org residency latch", () => {
90+
postgresTest("stamps the latch when the dial is enabled past off", async ({ prisma }) => {
91+
const id = await seedOrg(prisma);
92+
93+
const response = await post(id, { [MODE]: "redis-read" });
94+
95+
expect(response.status).toBe(200);
96+
const flags = await readFlags(prisma, id);
97+
expect(flags?.[MODE]).toBe("redis-read");
98+
expect(flags?.[LATCH]).toBe(true);
99+
});
100+
101+
postgresTest(
102+
"keeps the latch when a later save sets the dial back to off",
103+
async ({ prisma }) => {
104+
const id = await seedOrg(prisma);
105+
106+
await post(id, { [MODE]: "redis-read" });
107+
const response = await post(id, { [MODE]: "off" });
108+
109+
expect(response.status).toBe(200);
110+
const flags = await readFlags(prisma, id);
111+
expect(flags?.[MODE]).toBe("off");
112+
// One-way: the latch survives the roll-back so a run still resident keeps mirroring.
113+
expect(flags?.[LATCH]).toBe(true);
114+
}
115+
);
116+
117+
postgresTest(
118+
"does not stamp the latch for an off save with no prior latch",
119+
async ({ prisma }) => {
120+
const id = await seedOrg(prisma);
121+
122+
const response = await post(id, { [MODE]: "off" });
123+
124+
expect(response.status).toBe(200);
125+
const flags = await readFlags(prisma, id);
126+
expect(flags?.[LATCH]).toBeUndefined();
127+
}
128+
);
129+
});

apps/webapp/test/snapshotStoreMode.test.ts

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -386,31 +386,74 @@ describe("warming the organisation dial before a birth", () => {
386386
// Still unknown, so the synchronous resolve falls back exactly as it did before.
387387
expect(source.get("org_1")).toBeUndefined();
388388
});
389-
it("keeps replica refreshes out after a FAILED primary read", async () => {
390-
// load() swallows its own errors, so a rejected primary read used to clear primaryPending with
391-
// nothing cached. A refresh could then read a lagging replica carrying the current generation,
392-
// which the generation guard cannot discard, restoring the pre-save value for a cache lifetime.
393-
const primary = deferred();
394-
const replica = deferred();
395-
const source = createOrgModeSource({
396-
primary: clientFor(() => primary.promise),
397-
replica: clientFor(() => replica.promise),
398-
});
389+
it("keeps replica refreshes out while a failed primary read is still retrying", async () => {
390+
// While the primary is failing and retries are pending, a lagging replica must not restore the
391+
// pre-save value: primaryPending stays set for the generation, so refresh does not read the replica.
392+
let primaryCalls = 0;
393+
const source = createOrgModeSource(
394+
{
395+
primary: clientFor(() => {
396+
primaryCalls += 1;
397+
return Promise.reject(new Error("primary unavailable"));
398+
}),
399+
replica: clientFor(() =>
400+
Promise.resolve({ featureFlags: { snapshotStoreOrgMode: "off" } })
401+
),
402+
},
403+
// Long delays: the retry window is still open for the duration of this test.
404+
{ primaryInvalidateRetryDelaysMs: [10_000, 10_000] }
405+
);
399406

400407
source.invalidate("org_1");
408+
await vi.waitFor(() => expect(primaryCalls).toBeGreaterThanOrEqual(1));
401409

402-
// The primary read FAILS.
403-
primary.resolve(Promise.reject(new Error("primary unavailable")) as never);
404-
await new Promise((r) => setTimeout(r, 0));
410+
// A refresh arriving mid-retry must not start a replica read; the resolver falls back to global.
411+
source.refresh("org_1");
405412
await new Promise((r) => setTimeout(r, 0));
413+
expect(source.get("org_1")).toBeUndefined();
414+
});
406415

407-
// A refresh arriving now must not start a replica read, because no primary answer ever landed.
416+
it("retries the primary and recovers the saved value after a transient failure", async () => {
417+
let calls = 0;
418+
const source = createOrgModeSource(
419+
{
420+
primary: clientFor(() => {
421+
calls += 1;
422+
return calls === 1
423+
? Promise.reject(new Error("primary unavailable"))
424+
: Promise.resolve({ featureFlags: { snapshotStoreOrgMode: "dual-write" } });
425+
}),
426+
replica: clientFor(() => Promise.resolve({ featureFlags: {} })),
427+
},
428+
{ primaryInvalidateRetryDelaysMs: [5, 5, 5] }
429+
);
430+
431+
source.invalidate("org_1");
432+
// A concurrent refresh during the retry must not read the replica out from under the retry.
408433
source.refresh("org_1");
409-
replica.resolve({ featureFlags: { snapshotStoreOrgMode: "off" } });
410-
await new Promise((r) => setTimeout(r, 0));
411434

412-
// Nothing cached: the resolver falls back to the deployment-wide position, which is the safe
413-
// answer, rather than serving a stale replica value as though it were the saved one.
414-
expect(source.get("org_1")).toBeUndefined();
435+
await vi.waitFor(() => expect(source.get("org_1")).toBe("dual-write"));
436+
expect(calls).toBeGreaterThanOrEqual(2);
437+
});
438+
439+
it("unwedges after exhausting primary retries so a later refresh repopulates", async () => {
440+
const source = createOrgModeSource(
441+
{
442+
primary: clientFor(() => Promise.reject(new Error("primary unavailable"))),
443+
replica: clientFor(() =>
444+
Promise.resolve({ featureFlags: { snapshotStoreOrgMode: "redis-read" } })
445+
),
446+
},
447+
{ primaryInvalidateRetryDelaysMs: [1, 1] }
448+
);
449+
450+
source.invalidate("org_1");
451+
452+
// Once the retries exhaust, primaryPending clears and a refresh can repopulate from the replica,
453+
// rather than the org staying wedged on the global position forever.
454+
await vi.waitFor(() => {
455+
source.refresh("org_1");
456+
expect(source.get("org_1")).toBe("redis-read");
457+
});
415458
});
416459
});

0 commit comments

Comments
 (0)