Skip to content

Commit 32e46e9

Browse files
committed
fix(webapp): extend mint-kind flip grace to global flips
Global runOpsMintKind flips now stamp the previous kind and flip time like per-org flips, so a global flip is a deterministic cutover and cannot route two concurrent triggers that share an idempotency key to different databases. The resolver reads the grace stamp from the same source as the kind.
1 parent 2e839a9 commit 32e46e9

4 files changed

Lines changed: 167 additions & 36 deletions

File tree

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

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { prisma } from "~/db.server";
4+
import { env } from "~/env.server";
45
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
56
import { makeSetMultipleFlags } from "~/v3/featureFlags.server";
6-
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
7+
import { FEATURE_FLAG, type FeatureFlagCatalog, validatePartialFeatureFlags } from "~/v3/featureFlags";
8+
import { stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
79

810
export async function action({ request }: ActionFunctionArgs) {
911
await requireAdminApiRequest(request);
@@ -24,9 +26,50 @@ export async function action({ request }: ActionFunctionArgs) {
2426
);
2527
}
2628

27-
const featureFlags = validationResult.data;
29+
// Derived grace-stamp fields are computed server-side; never trust them from the body.
30+
const {
31+
runOpsMintKindPrev: _ignoredPrev,
32+
runOpsMintKindFlippedAt: _ignoredFlippedAt,
33+
...requestedFlags
34+
} = validationResult.data;
35+
36+
let flagsToWrite: Partial<FeatureFlagCatalog> = requestedFlags;
37+
38+
if (requestedFlags.runOpsMintKind !== undefined) {
39+
// Read the current GLOBAL mint flags so the stamp is computed against the authoritative
40+
// stored state, mirroring the per-org route. stampMintKindFlip writes prev/flippedAt only
41+
// on a genuine global flip, and carries an in-flight stamp forward on a same-target save.
42+
const existingRows = await prisma.featureFlag.findMany({
43+
where: {
44+
key: {
45+
in: [
46+
FEATURE_FLAG.runOpsMintKind,
47+
FEATURE_FLAG.runOpsMintKindPrev,
48+
FEATURE_FLAG.runOpsMintKindFlippedAt,
49+
],
50+
},
51+
},
52+
select: { key: true, value: true },
53+
});
54+
const existingGlobal: Record<string, unknown> = {};
55+
for (const row of existingRows) {
56+
existingGlobal[row.key] = row.value;
57+
}
58+
59+
// Anchor the cutover to the control-plane DB clock, not this process's wall clock.
60+
const [{ now: controlPlaneNow }] =
61+
await prisma.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
62+
63+
flagsToWrite = stampMintKindFlip(
64+
existingGlobal,
65+
{ ...requestedFlags },
66+
controlPlaneNow.getTime(),
67+
env.RUN_OPS_MINT_FLIP_GRACE_MS
68+
) as Partial<FeatureFlagCatalog>;
69+
}
70+
2871
const setMultipleFlags = makeSetMultipleFlags(prisma);
29-
const updatedFlags = await setMultipleFlags(featureFlags);
72+
const updatedFlags = await setMultipleFlags(flagsToWrite);
3073

3174
return json({
3275
success: true,

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2-
import { effectiveMintKind, stampMintKindFlip, type MintFlagResolution } from "./mintFlipGrace";
2+
import {
3+
effectiveMintKind,
4+
resolveMintFlag,
5+
stampMintKindFlip,
6+
type MintFlagResolution,
7+
} from "./mintFlipGrace";
38

49
// GRACE-LINGER: during [flippedAt, flippedAt + GRACE) every process — stale or fresh —
510
// must resolve to the SAME (old) kind; at/after the cutover every process resolves to
@@ -157,3 +162,67 @@ describe("stampMintKindFlip", () => {
157162
).toBe("runOpsId");
158163
});
159164
});
165+
166+
// SOURCE-CONSISTENCY: the kind and its grace stamp must come from the SAME source. A per-org
167+
// runOpsMintKind override wins both the kind and the stamp; with no per-org override, BOTH the
168+
// kind and the stamp come from the global FeatureFlag rows. Never mix (e.g. a per-org kind with
169+
// the global stamp), which would date a grace window against the wrong flip.
170+
describe("resolveMintFlag", () => {
171+
it("a per-org override wins the kind AND owns the stamp, ignoring the global stamp entirely", () => {
172+
const perOrg = {
173+
runOpsMintKind: "runOpsId",
174+
runOpsMintKindPrev: "cuid",
175+
runOpsMintKindFlippedAt: new Date(T).toISOString(),
176+
};
177+
const global = {
178+
runOpsMintKind: "cuid",
179+
runOpsMintKindPrev: "runOpsId",
180+
runOpsMintKindFlippedAt: new Date(T + 500_000).toISOString(),
181+
};
182+
expect(resolveMintFlag(perOrg, global)).toEqual({
183+
kind: "runOpsId",
184+
prev: "cuid",
185+
flippedAtMs: T,
186+
});
187+
});
188+
189+
it("with NO per-org override, the kind AND the stamp come from the global rows (global flip is graced)", () => {
190+
const global = {
191+
runOpsMintKind: "runOpsId",
192+
runOpsMintKindPrev: "cuid",
193+
runOpsMintKindFlippedAt: new Date(T).toISOString(),
194+
};
195+
const resolution = resolveMintFlag({}, global);
196+
expect(resolution).toEqual({ kind: "runOpsId", prev: "cuid", flippedAtMs: T });
197+
// Mid-grace: a global flip resolves to the OLD kind for the whole window.
198+
expect(effectiveMintKind(resolution, T + GRACE_MS - 1, GRACE_MS)).toBe("cuid");
199+
expect(effectiveMintKind(resolution, T + GRACE_MS, GRACE_MS)).toBe("runOpsId");
200+
});
201+
202+
it("a per-org override with NO per-org stamp does NOT borrow the global stamp (kind stays ungraced)", () => {
203+
const perOrg = { runOpsMintKind: "runOpsId" };
204+
const global = {
205+
runOpsMintKind: "cuid",
206+
runOpsMintKindPrev: "cuid",
207+
runOpsMintKindFlippedAt: new Date(T).toISOString(),
208+
};
209+
expect(resolveMintFlag(perOrg, global)).toEqual({
210+
kind: "runOpsId",
211+
prev: undefined,
212+
flippedAtMs: undefined,
213+
});
214+
});
215+
216+
it("defaults to cuid with no stamp when neither source has a kind", () => {
217+
expect(resolveMintFlag({}, {})).toEqual({
218+
kind: "cuid",
219+
prev: undefined,
220+
flippedAtMs: undefined,
221+
});
222+
expect(resolveMintFlag(null, null)).toEqual({
223+
kind: "cuid",
224+
prev: undefined,
225+
flippedAtMs: undefined,
226+
});
227+
});
228+
});

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,40 @@ function readMintKind(flags: Record<string, unknown>, key: string): RunIdMintKin
3030
return value === "cuid" || value === "runOpsId" ? value : undefined;
3131
}
3232

33-
function resolveEffectiveFromFlags(
34-
flags: Record<string, unknown> | null | undefined,
35-
nowMs: number,
36-
graceMs: number
37-
): RunIdMintKind {
33+
// Reads the { kind, prev, flippedAtMs } trio out of one flag record — either an org's
34+
// featureFlags override blob or the global FeatureFlag rows projected into a record. Pure.
35+
export function readMintResolution(
36+
flags: Record<string, unknown> | null | undefined
37+
): MintFlagResolution {
3838
const source = flags ?? {};
3939
const kind = readMintKind(source, "runOpsMintKind") ?? DEFAULT_MINT_KIND;
4040
const prev = readMintKind(source, "runOpsMintKindPrev");
4141
const flippedAtRaw = source.runOpsMintKindFlippedAt;
4242
const parsed = typeof flippedAtRaw === "string" ? Date.parse(flippedAtRaw) : NaN;
4343
const flippedAtMs = Number.isNaN(parsed) ? undefined : parsed;
44+
return { kind, prev, flippedAtMs };
45+
}
46+
47+
// SOURCE-CONSISTENT resolution: a per-org runOpsMintKind override wins the kind AND owns the
48+
// grace stamp; with no per-org override, the kind AND the stamp both come from the global rows.
49+
// The stamp is never read from a different source than the kind, which would date a grace
50+
// window against the wrong flip.
51+
export function resolveMintFlag(
52+
perOrgOverrides: Record<string, unknown> | null | undefined,
53+
globalFlags: Record<string, unknown> | null | undefined
54+
): MintFlagResolution {
55+
if (readMintKind(perOrgOverrides ?? {}, "runOpsMintKind") !== undefined) {
56+
return readMintResolution(perOrgOverrides);
57+
}
58+
return readMintResolution(globalFlags);
59+
}
4460

45-
return effectiveMintKind({ kind, prev, flippedAtMs }, nowMs, graceMs);
61+
function resolveEffectiveFromFlags(
62+
flags: Record<string, unknown> | null | undefined,
63+
nowMs: number,
64+
graceMs: number
65+
): RunIdMintKind {
66+
return effectiveMintKind(readMintResolution(flags), nowMs, graceMs);
4667
}
4768

4869
// Stamps a grace window only when the outgoing TARGET kind differs from the stored one (a

apps/webapp/app/v3/runOpsMigration/runOpsMintKind.server.ts

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import { env } from "~/env.server";
33
import { logger } from "~/services/logger.server";
44
import { BoundedTtlCache } from "~/services/realtime/boundedTtlCache";
55
import { singleton } from "~/utils/singleton";
6-
import { FEATURE_FLAG, FeatureFlagCatalog } from "~/v3/featureFlags";
7-
import { makeFlag } from "~/v3/featureFlags.server";
6+
import { FEATURE_FLAG } from "~/v3/featureFlags";
87
import { DEFAULT_CP_CACHE_TTL_MS } from "./controlPlaneCache.server";
9-
import { effectiveMintKind, type MintFlagResolution } from "./mintFlipGrace";
8+
import { effectiveMintKind, resolveMintFlag, type MintFlagResolution } from "./mintFlipGrace";
109
import { isSplitEnabled } from "./splitMode.server";
1110

1211
export type RunIdMintKind = "cuid" | "runOpsId";
@@ -36,7 +35,6 @@ export async function computeRunIdMintKind(
3635
}
3736

3837
// ENV-BOUND wrapper — the only place env/$replica/isSplitEnabled are read.
39-
const flagFn = singleton("runOpsMintFlag", () => makeFlag($replica));
4038
const mintCache = singleton(
4139
"runOpsMintCache",
4240
() =>
@@ -64,14 +62,6 @@ if (env.RUN_OPS_MINT_FLIP_GRACE_MS <= env.RUN_OPS_MINT_FLAG_CACHE_TTL_MS + contr
6462
);
6563
}
6664

67-
function readValidatedFlag<T extends "runOpsMintKindPrev" | "runOpsMintKindFlippedAt">(
68-
overrides: Record<string, unknown>,
69-
key: T
70-
): string | undefined {
71-
const parsed = FeatureFlagCatalog[FEATURE_FLAG[key]].safeParse(overrides[FEATURE_FLAG[key]]);
72-
return parsed.success ? parsed.data : undefined;
73-
}
74-
7565
export async function resolveRunIdMintKind(environment: {
7666
organizationId: string;
7767
id: string;
@@ -106,22 +96,30 @@ export async function resolveRunIdMintKind(environment: {
10696

10797
const overridesRecord = (overrides as Record<string, unknown>) ?? {};
10898

109-
const kind = await flagFn({
110-
key: FEATURE_FLAG.runOpsMintKind,
111-
defaultValue: "cuid",
112-
overrides: overridesRecord,
99+
// One global read over the three mint-flag keys (kind + grace stamp), folded into the
100+
// single cache-miss round-trip. This replaces the former single-key flag read, so a
101+
// GLOBAL flip is now grace-stamped WITHOUT adding any new per-mint/per-resolve query.
102+
// (The cache-hit branch above never touches the DB.)
103+
const globalRows = await $replica.featureFlag.findMany({
104+
where: {
105+
key: {
106+
in: [
107+
FEATURE_FLAG.runOpsMintKind,
108+
FEATURE_FLAG.runOpsMintKindPrev,
109+
FEATURE_FLAG.runOpsMintKindFlippedAt,
110+
],
111+
},
112+
},
113+
select: { key: true, value: true },
113114
});
115+
const globalFlags: Record<string, unknown> = {};
116+
for (const row of globalRows) {
117+
globalFlags[row.key] = row.value;
118+
}
114119

115-
// Read the grace-linger stamp DIRECTLY from the already-in-memory org overrides — no
116-
// extra DB read, and no forcing these optional fields through flagFn's defaultValue path.
117-
const prev = readValidatedFlag(overridesRecord, "runOpsMintKindPrev") as
118-
| RunIdMintKind
119-
| undefined;
120-
const flippedAtRaw = readValidatedFlag(overridesRecord, "runOpsMintKindFlippedAt");
121-
const parsedFlippedAt = flippedAtRaw !== undefined ? Date.parse(flippedAtRaw) : NaN;
122-
const flippedAtMs = Number.isNaN(parsedFlippedAt) ? undefined : parsedFlippedAt;
123-
124-
const resolution: MintFlagResolution = { kind, prev, flippedAtMs };
120+
// Source-consistent: a per-org override wins the kind AND its stamp; otherwise the
121+
// global row wins the kind AND its stamp.
122+
const resolution: MintFlagResolution = resolveMintFlag(overridesRecord, globalFlags);
125123
mintCache.set(orgId, resolution);
126124
return effectiveMintKind(resolution, Date.now(), env.RUN_OPS_MINT_FLIP_GRACE_MS);
127125
},

0 commit comments

Comments
 (0)