Skip to content

Commit e5fa79d

Browse files
committed
fix(webapp): grace-stamp runOpsMintKind flips from the global admin flags page
The global feature-flags admin page saved flags with a raw upsert, so changing runOpsMintKind there skipped the grace-window stamping that the JSON API (applyGlobalMintKindFlip) and the per-org routes apply. An ungraced flip lets concurrent processes diverge across the cutover, which is exactly what the grace window exists to prevent. Route the page's write through a new shared replaceGlobalFeatureFlags helper: any runOpsMintKind change goes through applyGlobalMintKindFlip (advisory lock, control-plane-clock flippedAt, prev stamp), and the three mint-kind rows are kept out of the raw upsert/delete sweep so the derived stamp is never bare-written or swept away. Other flags keep the page's existing replace semantics (submitted upserts, omitted deletes unless protected).
1 parent 1ab5066 commit e5fa79d

3 files changed

Lines changed: 257 additions & 33 deletions

File tree

apps/webapp/app/routes/admin.feature-flags.tsx

Lines changed: 13 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashb
1111
import {
1212
FEATURE_FLAG,
1313
GLOBAL_LOCKED_FLAGS,
14+
type FeatureFlagKey,
1415
type FlagControlType,
1516
getAllFlagControlTypes,
1617
validatePartialFeatureFlags,
1718
} from "~/v3/featureFlags";
18-
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
19+
import {
20+
flags as getGlobalFlags,
21+
replaceGlobalFeatureFlags,
22+
} from "~/v3/featureFlags.server";
1923
import { featuresForRequest } from "~/features.server";
2024
import { Button } from "~/components/primitives/Buttons";
2125
import { Callout } from "~/components/primitives/Callout";
@@ -116,39 +120,15 @@ export const action = dashboardAction(
116120
);
117121
}
118122

119-
const validatedFlags = validationResult.data as Record<string, unknown>;
120-
const controlTypes = getAllFlagControlTypes();
121-
const catalogKeys = Object.keys(controlTypes);
122-
123-
const keysToDelete: string[] = [];
124-
const upsertOps: ReturnType<typeof prisma.featureFlag.upsert>[] = [];
125-
126-
for (const key of catalogKeys) {
127-
if (key in validatedFlags) {
128-
upsertOps.push(
129-
prisma.featureFlag.upsert({
130-
where: { key },
131-
create: { key, value: validatedFlags[key] as any },
132-
update: { value: validatedFlags[key] as any },
133-
})
134-
);
135-
} else {
136-
// On cloud, never delete locked flags (they're not in the payload
137-
// because the UI doesn't include them). Locally, delete everything
138-
// the user didn't include - full control.
139-
const isProtected = isManagedCloud && GLOBAL_LOCKED_FLAGS.includes(key);
140-
if (!isProtected) {
141-
keysToDelete.push(key);
142-
}
143-
}
144-
}
123+
const catalogKeys = Object.keys(getAllFlagControlTypes()) as FeatureFlagKey[];
145124

146-
await prisma.$transaction([
147-
...upsertOps,
148-
...(keysToDelete.length > 0
149-
? [prisma.featureFlag.deleteMany({ where: { key: { in: keysToDelete } } })]
150-
: []),
151-
]);
125+
await replaceGlobalFeatureFlags(prisma, {
126+
requestedFlags: validationResult.data,
127+
catalogKeys,
128+
// On cloud, never delete locked flags (the UI omits them). Locally, full control.
129+
isProtected: (key) => isManagedCloud && GLOBAL_LOCKED_FLAGS.includes(key),
130+
graceMs: env.RUN_OPS_MINT_FLIP_GRACE_MS,
131+
});
152132

153133
return json({ success: true });
154134
}

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import {
99
} from "~/v3/featureFlags";
1010
import { stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
1111

12+
// The runOpsMintKind trio is managed only through applyGlobalMintKindFlip (grace-stamped under an
13+
// advisory lock), never a bare upsert or the replace sweep, so it stays out of both.
14+
const GLOBAL_MINT_KEYS: FeatureFlagKey[] = [
15+
FEATURE_FLAG.runOpsMintKind,
16+
FEATURE_FLAG.runOpsMintKindPrev,
17+
FEATURE_FLAG.runOpsMintKindFlippedAt,
18+
];
19+
1220
export type FlagsOptions<T extends FeatureFlagKey> = {
1321
key: T;
1422
defaultValue?: z.infer<(typeof FeatureFlagCatalog)[T]>;
@@ -191,3 +199,58 @@ export async function applyGlobalMintKindFlip(
191199
return makeSetMultipleFlags(tx)(stamped);
192200
});
193201
}
202+
203+
// Replace-semantics write for the global admin flags page: upsert submitted catalog flags, delete
204+
// omitted ones (unless protected), and route any runOpsMintKind change through the graced flip path.
205+
export async function replaceGlobalFeatureFlags(
206+
client: PrismaClient,
207+
params: {
208+
requestedFlags: Partial<z.infer<typeof FeatureFlagCatalogSchema>>;
209+
catalogKeys: FeatureFlagKey[];
210+
isProtected: (key: FeatureFlagKey) => boolean;
211+
graceMs: number;
212+
}
213+
): Promise<void> {
214+
// Derived grace-stamp fields are computed server-side; never trust them from the body.
215+
const {
216+
runOpsMintKindPrev: _ignoredPrev,
217+
runOpsMintKindFlippedAt: _ignoredFlippedAt,
218+
...requestedFlags
219+
} = params.requestedFlags;
220+
221+
if (requestedFlags.runOpsMintKind !== undefined) {
222+
await applyGlobalMintKindFlip(
223+
client,
224+
{ [FEATURE_FLAG.runOpsMintKind]: requestedFlags.runOpsMintKind },
225+
params.graceMs
226+
);
227+
}
228+
229+
const upsertOps: ReturnType<typeof client.featureFlag.upsert>[] = [];
230+
const keysToDelete: string[] = [];
231+
232+
for (const key of params.catalogKeys) {
233+
if (GLOBAL_MINT_KEYS.includes(key)) {
234+
continue;
235+
}
236+
if (key in requestedFlags) {
237+
const value = (requestedFlags as Record<string, unknown>)[key];
238+
upsertOps.push(
239+
client.featureFlag.upsert({
240+
where: { key },
241+
create: { key, value: value as any },
242+
update: { value: value as any },
243+
})
244+
);
245+
} else if (!params.isProtected(key)) {
246+
keysToDelete.push(key);
247+
}
248+
}
249+
250+
await client.$transaction([
251+
...upsertOps,
252+
...(keysToDelete.length > 0
253+
? [client.featureFlag.deleteMany({ where: { key: { in: keysToDelete } } })]
254+
: []),
255+
]);
256+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// A runOpsMintKind change on the global admin flags page must go through the graced flip path
2+
// (prev + flippedAt stamped), not a bare upsert. Real testcontainers Postgres, never mocked.
3+
import type { PrismaClient } from "@trigger.dev/database";
4+
import { postgresTest } from "@internal/testcontainers";
5+
import { describe, expect, vi } from "vitest";
6+
import { FEATURE_FLAG, type FeatureFlagKey } from "~/v3/featureFlags";
7+
import {
8+
makeSetMultipleFlags,
9+
replaceGlobalFeatureFlags,
10+
} from "~/v3/featureFlags.server";
11+
12+
vi.setConfig({ testTimeout: 60_000 });
13+
14+
const MINT_KEYS: FeatureFlagKey[] = [
15+
FEATURE_FLAG.runOpsMintKind,
16+
FEATURE_FLAG.runOpsMintKindPrev,
17+
FEATURE_FLAG.runOpsMintKindFlippedAt,
18+
];
19+
20+
// Mint trio + two ordinary boolean flags, to exercise the sweep alongside the carved-out mint keys.
21+
const CATALOG_KEYS: FeatureFlagKey[] = [
22+
...MINT_KEYS,
23+
FEATURE_FLAG.mollifierEnabled,
24+
FEATURE_FLAG.hasAiAccess,
25+
];
26+
27+
const NEVER_PROTECTED = () => false;
28+
29+
async function readFlags(
30+
prisma: PrismaClient,
31+
keys: FeatureFlagKey[]
32+
): Promise<Record<string, unknown>> {
33+
const rows = await prisma.featureFlag.findMany({
34+
where: { key: { in: keys } },
35+
select: { key: true, value: true },
36+
});
37+
const m: Record<string, unknown> = {};
38+
for (const row of rows) m[row.key] = row.value;
39+
return m;
40+
}
41+
42+
describe("replaceGlobalFeatureFlags — graces runOpsMintKind, preserves replace semantics", () => {
43+
postgresTest("a runOpsMintKind flip stamps prev + flippedAt (graced, not a bare upsert)", async ({
44+
prisma,
45+
}) => {
46+
await makeSetMultipleFlags(prisma)({ [FEATURE_FLAG.runOpsMintKind]: "cuid" });
47+
48+
await replaceGlobalFeatureFlags(prisma, {
49+
requestedFlags: { [FEATURE_FLAG.runOpsMintKind]: "runOpsId" },
50+
catalogKeys: CATALOG_KEYS,
51+
isProtected: NEVER_PROTECTED,
52+
graceMs: 60_000,
53+
});
54+
55+
const m = await readFlags(prisma, MINT_KEYS);
56+
expect(m[FEATURE_FLAG.runOpsMintKind]).toBe("runOpsId");
57+
expect(m[FEATURE_FLAG.runOpsMintKindPrev]).toBe("cuid");
58+
expect(typeof m[FEATURE_FLAG.runOpsMintKindFlippedAt]).toBe("string");
59+
});
60+
61+
postgresTest("derived stamp fields supplied in the body are ignored (computed server-side)", async ({
62+
prisma,
63+
}) => {
64+
await makeSetMultipleFlags(prisma)({ [FEATURE_FLAG.runOpsMintKind]: "cuid" });
65+
66+
await replaceGlobalFeatureFlags(prisma, {
67+
// Caller forges the stamp: prev="runOpsId", flippedAt=epoch. Both must be ignored.
68+
requestedFlags: {
69+
[FEATURE_FLAG.runOpsMintKind]: "runOpsId",
70+
[FEATURE_FLAG.runOpsMintKindPrev]: "runOpsId",
71+
[FEATURE_FLAG.runOpsMintKindFlippedAt]: "1970-01-01T00:00:00.000Z",
72+
},
73+
catalogKeys: CATALOG_KEYS,
74+
isProtected: NEVER_PROTECTED,
75+
graceMs: 60_000,
76+
});
77+
78+
const m = await readFlags(prisma, MINT_KEYS);
79+
expect(m[FEATURE_FLAG.runOpsMintKindPrev]).toBe("cuid");
80+
expect(m[FEATURE_FLAG.runOpsMintKindFlippedAt]).not.toBe("1970-01-01T00:00:00.000Z");
81+
});
82+
83+
postgresTest("re-applying the same runOpsMintKind does not reset the grace clock", async ({
84+
prisma,
85+
}) => {
86+
await makeSetMultipleFlags(prisma)({ [FEATURE_FLAG.runOpsMintKind]: "cuid" });
87+
88+
await replaceGlobalFeatureFlags(prisma, {
89+
requestedFlags: { [FEATURE_FLAG.runOpsMintKind]: "runOpsId" },
90+
catalogKeys: CATALOG_KEYS,
91+
isProtected: NEVER_PROTECTED,
92+
graceMs: 60_000,
93+
});
94+
const first = await readFlags(prisma, MINT_KEYS);
95+
96+
await replaceGlobalFeatureFlags(prisma, {
97+
requestedFlags: { [FEATURE_FLAG.runOpsMintKind]: "runOpsId" },
98+
catalogKeys: CATALOG_KEYS,
99+
isProtected: NEVER_PROTECTED,
100+
graceMs: 60_000,
101+
});
102+
const second = await readFlags(prisma, MINT_KEYS);
103+
104+
expect(second[FEATURE_FLAG.runOpsMintKind]).toBe("runOpsId");
105+
expect(second[FEATURE_FLAG.runOpsMintKindPrev]).toBe(first[FEATURE_FLAG.runOpsMintKindPrev]);
106+
expect(second[FEATURE_FLAG.runOpsMintKindFlippedAt]).toBe(
107+
first[FEATURE_FLAG.runOpsMintKindFlippedAt]
108+
);
109+
});
110+
111+
postgresTest("the mint trio survives a replace that omits runOpsMintKind (not swept)", async ({
112+
prisma,
113+
}) => {
114+
await replaceGlobalFeatureFlags(prisma, {
115+
requestedFlags: { [FEATURE_FLAG.runOpsMintKind]: "runOpsId" },
116+
catalogKeys: CATALOG_KEYS,
117+
isProtected: NEVER_PROTECTED,
118+
graceMs: 60_000,
119+
});
120+
const before = await readFlags(prisma, MINT_KEYS);
121+
122+
// A later save that edits only an unrelated flag and omits the mint keys entirely.
123+
await replaceGlobalFeatureFlags(prisma, {
124+
requestedFlags: { [FEATURE_FLAG.mollifierEnabled]: true },
125+
catalogKeys: CATALOG_KEYS,
126+
isProtected: NEVER_PROTECTED,
127+
graceMs: 60_000,
128+
});
129+
130+
const after = await readFlags(prisma, MINT_KEYS);
131+
expect(after[FEATURE_FLAG.runOpsMintKind]).toBe("runOpsId");
132+
expect(after[FEATURE_FLAG.runOpsMintKindPrev]).toBe(before[FEATURE_FLAG.runOpsMintKindPrev]);
133+
expect(after[FEATURE_FLAG.runOpsMintKindFlippedAt]).toBe(
134+
before[FEATURE_FLAG.runOpsMintKindFlippedAt]
135+
);
136+
const mollifier = await readFlags(prisma, [FEATURE_FLAG.mollifierEnabled]);
137+
expect(mollifier[FEATURE_FLAG.mollifierEnabled]).toBe(true);
138+
});
139+
140+
postgresTest("non-mint flags keep replace semantics: submitted upserts, omitted deletes", async ({
141+
prisma,
142+
}) => {
143+
await replaceGlobalFeatureFlags(prisma, {
144+
requestedFlags: { [FEATURE_FLAG.mollifierEnabled]: true },
145+
catalogKeys: CATALOG_KEYS,
146+
isProtected: NEVER_PROTECTED,
147+
graceMs: 60_000,
148+
});
149+
150+
await replaceGlobalFeatureFlags(prisma, {
151+
requestedFlags: { [FEATURE_FLAG.hasAiAccess]: true },
152+
catalogKeys: CATALOG_KEYS,
153+
isProtected: NEVER_PROTECTED,
154+
graceMs: 60_000,
155+
});
156+
157+
const m = await readFlags(prisma, [FEATURE_FLAG.mollifierEnabled, FEATURE_FLAG.hasAiAccess]);
158+
expect(m[FEATURE_FLAG.hasAiAccess]).toBe(true);
159+
expect(m[FEATURE_FLAG.mollifierEnabled]).toBeUndefined();
160+
});
161+
162+
postgresTest("protected omitted flags are not deleted", async ({ prisma }) => {
163+
await replaceGlobalFeatureFlags(prisma, {
164+
requestedFlags: { [FEATURE_FLAG.mollifierEnabled]: true },
165+
catalogKeys: CATALOG_KEYS,
166+
isProtected: NEVER_PROTECTED,
167+
graceMs: 60_000,
168+
});
169+
170+
// A replace that omits mollifierEnabled but marks it protected must keep it.
171+
await replaceGlobalFeatureFlags(prisma, {
172+
requestedFlags: { [FEATURE_FLAG.hasAiAccess]: true },
173+
catalogKeys: CATALOG_KEYS,
174+
isProtected: (key) => key === FEATURE_FLAG.mollifierEnabled,
175+
graceMs: 60_000,
176+
});
177+
178+
const m = await readFlags(prisma, [FEATURE_FLAG.mollifierEnabled]);
179+
expect(m[FEATURE_FLAG.mollifierEnabled]).toBe(true);
180+
});
181+
});

0 commit comments

Comments
 (0)