Skip to content

Commit fbfc17e

Browse files
committed
fix(webapp): schedule run-ops mint-kind flips to avoid duplicate runs
Flipping an organization's run-ops mint kind (which database new runs are minted on) took effect independently per process as each cached value expired. For a window after a flip, two concurrent triggers using the same idempotency key could mint on different databases, where the per-database unique constraint can't dedupe them, producing a duplicate run. The flip is now a deterministic wall-clock cutover: the admin feature-flag routes stamp the previously-effective kind and a flip timestamp onto the organization, and the mint read resolves the old kind until flippedAt + grace (default 90s, chosen to outlast the caches a flip drains through), after which every process crosses to the new kind together. During the window all processes agree on one database, so a concurrent same-key collision lands on a single database and the existing unique-constraint retry returns the one idempotent run. A same-target re-save carries the in-flight stamp forward, so it can't slide the cutover.
1 parent 1a0198c commit fbfc17e

8 files changed

Lines changed: 330 additions & 21 deletions

apps/webapp/app/env.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1753,6 +1753,10 @@ const EnvironmentSchema = z
17531753
RUN_OPS_MINT_ENABLED: BoolEnv.default(false),
17541754
RUN_OPS_MINT_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
17551755
RUN_OPS_MINT_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(10_000),
1756+
// Deterministic wall-clock cutover after a runOpsMintKind flip. Must exceed the sum
1757+
// of RUN_OPS_MINT_FLAG_CACHE_TTL_MS and the control-plane cache TTL so every process
1758+
// (stale or fresh) resolves to the same kind for the whole window. See mintFlipGrace.ts.
1759+
RUN_OPS_MINT_FLIP_GRACE_MS: z.coerce.number().int().default(90_000),
17561760

17571761
// Session replication (Postgres → ClickHouse sessions_v1). Shares Redis
17581762
// with the runs replicator for leader locking but has its own slot and

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
3+
import type { Prisma } from "@trigger.dev/database";
34
import { z } from "zod";
5+
import { env } from "~/env.server";
46
import { prisma } from "~/db.server";
57
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
68
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
9+
import { stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
710
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
811

912
const ParamsSchema = z.object({
@@ -82,18 +85,23 @@ export async function action({ request, params }: ActionFunctionArgs) {
8285
? validatePartialFeatureFlags(organization.featureFlags as Record<string, unknown>)
8386
: { success: false as const };
8487

85-
const mergedFlags = {
86-
...(existingFlags.success ? existingFlags.data : {}),
87-
...validationResult.data,
88-
};
88+
const mergedFlags = stampMintKindFlip(
89+
existingFlags.success ? existingFlags.data : {},
90+
{
91+
...(existingFlags.success ? existingFlags.data : {}),
92+
...validationResult.data,
93+
},
94+
Date.now(),
95+
env.RUN_OPS_MINT_FLIP_GRACE_MS
96+
);
8997

9098
// Update the organization's feature flags
9199
const updatedOrganization = await prisma.organization.update({
92100
where: {
93101
id: organizationId,
94102
},
95103
data: {
96-
featureFlags: mergedFlags,
104+
featureFlags: mergedFlags as Prisma.InputJsonValue,
97105
},
98106
select: {
99107
id: true,

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-r
22
import { json } from "@remix-run/server-runtime";
33
import { Prisma } from "@trigger.dev/database";
44
import { z } from "zod";
5+
import { env } from "~/env.server";
56
import { prisma } from "~/db.server";
67
import { requireUser } from "~/services/session.server";
78
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
9+
import { stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
810
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
911
import {
1012
FEATURE_FLAG,
@@ -119,6 +121,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
119121
);
120122
}
121123
featureFlags = validationResult.data;
124+
125+
const existingOrg = await prisma.organization.findFirst({
126+
where: { id: organizationId },
127+
select: { featureFlags: true },
128+
});
129+
featureFlags = stampMintKindFlip(
130+
existingOrg?.featureFlags as Record<string, unknown> | null | undefined,
131+
featureFlags,
132+
Date.now(),
133+
env.RUN_OPS_MINT_FLIP_GRACE_MS
134+
);
122135
}
123136

124137
try {

apps/webapp/app/v3/featureFlags.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export const FEATURE_FLAG = {
1919
computeMigrationRequireTemplate: "computeMigrationRequireTemplate",
2020
devBranchesEnabled: "devBranchesEnabled",
2121
runOpsMintKind: "runOpsMintKind",
22+
// Grace-linger stamp carried alongside runOpsMintKind on flip. See mintFlipGrace.ts.
23+
runOpsMintKindPrev: "runOpsMintKindPrev",
24+
runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt",
2225
} as const;
2326

2427
export const FeatureFlagCatalog = {
@@ -54,6 +57,10 @@ export const FeatureFlagCatalog = {
5457
// Per-org run-ops-id mint cutover. Defaults to "cuid"; only honored when
5558
// RUN_OPS_MINT_ENABLED is on AND isSplitEnabled() is true.
5659
[FEATURE_FLAG.runOpsMintKind]: z.enum(["cuid", "runOpsId"]),
60+
// Grace-linger stamp: the previously-effective kind and the flip timestamp, written
61+
// by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS).
62+
[FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]),
63+
[FEATURE_FLAG.runOpsMintKindFlippedAt]: z.string().datetime(),
5764
};
5865

5966
export type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
@@ -70,6 +77,8 @@ export const GLOBAL_LOCKED_FLAGS: FeatureFlagKey[] = [
7077
export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [
7178
FEATURE_FLAG.defaultWorkerInstanceGroupId,
7279
FEATURE_FLAG.taskEventRepository,
80+
FEATURE_FLAG.runOpsMintKindPrev,
81+
FEATURE_FLAG.runOpsMintKindFlippedAt,
7382
];
7483

7584
// Create a Zod schema from the existing catalog
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { effectiveMintKind, stampMintKindFlip, type MintFlagResolution } from "./mintFlipGrace";
3+
4+
// GRACE-LINGER: during [flippedAt, flippedAt + GRACE) every process — stale or fresh —
5+
// must resolve to the SAME (old) kind; at/after the cutover every process resolves to
6+
// the SAME (new) kind. This collapses the cross-process divergence window.
7+
const GRACE_MS = 90_000;
8+
const T = 1_000_000;
9+
10+
describe("effectiveMintKind", () => {
11+
beforeEach(() => vi.useFakeTimers());
12+
afterEach(() => vi.useRealTimers());
13+
14+
it("returns r.kind directly when prev is missing", () => {
15+
const r: MintFlagResolution = { kind: "cuid" };
16+
expect(effectiveMintKind(r, T, GRACE_MS)).toBe("cuid");
17+
});
18+
19+
it("returns r.kind directly when flippedAtMs is missing", () => {
20+
const r: MintFlagResolution = { kind: "runOpsId", prev: "cuid" };
21+
expect(effectiveMintKind(r, T, GRACE_MS)).toBe("runOpsId");
22+
});
23+
24+
it("CORE: stale and fresh resolutions agree at every instant during grace, then both flip to the new kind at cutover", () => {
25+
const stale: MintFlagResolution = { kind: "cuid" };
26+
const fresh: MintFlagResolution = { kind: "runOpsId", prev: "cuid", flippedAtMs: T };
27+
28+
for (const now of [T, T + 1, T + 1_000, T + 45_000, T + GRACE_MS - 1]) {
29+
const staleResolved = effectiveMintKind(stale, now, GRACE_MS);
30+
const freshResolved = effectiveMintKind(fresh, now, GRACE_MS);
31+
expect(staleResolved).toBe("cuid");
32+
expect(freshResolved).toBe("cuid");
33+
}
34+
35+
for (const now of [T + GRACE_MS, T + GRACE_MS + 1, T + GRACE_MS + 60_000]) {
36+
expect(effectiveMintKind(fresh, now, GRACE_MS)).toBe("runOpsId");
37+
}
38+
});
39+
40+
it("boundary: exactly at flippedAt + GRACE resolves to the NEW kind", () => {
41+
const fresh: MintFlagResolution = { kind: "runOpsId", prev: "cuid", flippedAtMs: T };
42+
expect(effectiveMintKind(fresh, T + GRACE_MS - 1, GRACE_MS)).toBe("cuid");
43+
expect(effectiveMintKind(fresh, T + GRACE_MS, GRACE_MS)).toBe("runOpsId");
44+
});
45+
});
46+
47+
describe("stampMintKindFlip", () => {
48+
beforeEach(() => vi.useFakeTimers());
49+
afterEach(() => vi.useRealTimers());
50+
51+
it("a genuine flip (cuid -> runOpsId) stamps prev and flippedAt", () => {
52+
const existing = { runOpsMintKind: "cuid" };
53+
const outgoing = { runOpsMintKind: "runOpsId" };
54+
const result = stampMintKindFlip(existing, outgoing, T, GRACE_MS);
55+
56+
expect(result.runOpsMintKind).toBe("runOpsId");
57+
expect(result.runOpsMintKindPrev).toBe("cuid");
58+
expect(result.runOpsMintKindFlippedAt).toBe(new Date(T).toISOString());
59+
});
60+
61+
it("defaults the existing effective kind to cuid when existing flags are null", () => {
62+
const outgoing = { runOpsMintKind: "runOpsId" };
63+
const result = stampMintKindFlip(null, outgoing, T, GRACE_MS);
64+
65+
expect(result.runOpsMintKind).toBe("runOpsId");
66+
expect(result.runOpsMintKindPrev).toBe("cuid");
67+
expect(result.runOpsMintKindFlippedAt).toBe(new Date(T).toISOString());
68+
});
69+
70+
it("resubmitting the same target kind mid-grace carries the stamp forward untouched (does not reset the cutover clock)", () => {
71+
const existing = {
72+
runOpsMintKind: "runOpsId",
73+
runOpsMintKindPrev: "cuid",
74+
runOpsMintKindFlippedAt: new Date(T).toISOString(),
75+
};
76+
const now = T + 10_000;
77+
const outgoing = { runOpsMintKind: "runOpsId", someOtherFlag: true };
78+
const result = stampMintKindFlip(existing, outgoing, now, GRACE_MS);
79+
80+
expect(result.runOpsMintKind).toBe("runOpsId");
81+
expect(result.runOpsMintKindPrev).toBe("cuid");
82+
// Unrelated re-save: the cutover time must NOT slide forward.
83+
expect(result.runOpsMintKindFlippedAt).toBe(new Date(T).toISOString());
84+
expect(result.someOtherFlag).toBe(true);
85+
});
86+
87+
it("an unchanged save after grace has elapsed carries the settled stamp forward and preserves unrelated flags", () => {
88+
const existing = {
89+
runOpsMintKind: "runOpsId",
90+
runOpsMintKindPrev: "cuid",
91+
runOpsMintKindFlippedAt: new Date(T).toISOString(),
92+
};
93+
const outgoing = { runOpsMintKind: "runOpsId", someOtherFlag: true };
94+
const result = stampMintKindFlip(existing, outgoing, T + GRACE_MS + 5_000, GRACE_MS);
95+
96+
expect(result.runOpsMintKind).toBe("runOpsId");
97+
expect(result.someOtherFlag).toBe(true);
98+
expect(result.runOpsMintKindPrev).toBe("cuid");
99+
expect(result.runOpsMintKindFlippedAt).toBe(new Date(T).toISOString());
100+
});
101+
102+
it("a flip-back requested after the original grace has elapsed stamps prev := the new settled (now-effective) kind, timestamped now", () => {
103+
const existing = {
104+
runOpsMintKind: "runOpsId",
105+
runOpsMintKindPrev: "cuid",
106+
runOpsMintKindFlippedAt: new Date(T).toISOString(),
107+
};
108+
const now = T + GRACE_MS + 1_000;
109+
const outgoing = { runOpsMintKind: "cuid" };
110+
const result = stampMintKindFlip(existing, outgoing, now, GRACE_MS);
111+
112+
expect(result.runOpsMintKind).toBe("cuid");
113+
expect(result.runOpsMintKindPrev).toBe("runOpsId");
114+
expect(result.runOpsMintKindFlippedAt).toBe(new Date(now).toISOString());
115+
});
116+
117+
it("a flip-back mid-grace re-stamps prev to the still-effective old kind, so it keeps serving that kind (no divergence)", () => {
118+
const existing = {
119+
runOpsMintKind: "runOpsId",
120+
runOpsMintKindPrev: "cuid",
121+
runOpsMintKindFlippedAt: new Date(T).toISOString(),
122+
};
123+
const now = T + 20_000;
124+
const outgoing = { runOpsMintKind: "cuid" };
125+
const result = stampMintKindFlip(existing, outgoing, now, GRACE_MS);
126+
127+
expect(result.runOpsMintKind).toBe("cuid");
128+
expect(result.runOpsMintKindPrev).toBe("cuid");
129+
expect(result.runOpsMintKindFlippedAt).toBe(new Date(now).toISOString());
130+
});
131+
132+
it("defaults outgoing kind to 'cuid' when runOpsMintKind is absent", () => {
133+
const existing = { runOpsMintKind: "runOpsId" };
134+
const outgoing: Record<string, unknown> = {};
135+
const result = stampMintKindFlip(existing, outgoing, T, GRACE_MS);
136+
137+
expect(result.runOpsMintKind).toBe("cuid");
138+
expect(result.runOpsMintKindPrev).toBe("runOpsId");
139+
expect(result.runOpsMintKindFlippedAt).toBe(new Date(T).toISOString());
140+
});
141+
142+
it("treats a malformed existing flippedAt as no stamp", () => {
143+
const existing = {
144+
runOpsMintKind: "runOpsId",
145+
runOpsMintKindPrev: "cuid",
146+
runOpsMintKindFlippedAt: "not-a-date",
147+
};
148+
const outgoing = { runOpsMintKind: "runOpsId" };
149+
const result = stampMintKindFlip(existing, outgoing, T, GRACE_MS);
150+
151+
expect(result.runOpsMintKind).toBe("runOpsId");
152+
});
153+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { RunIdMintKind } from "./runOpsMintKind.server";
2+
3+
export type { RunIdMintKind };
4+
5+
export type MintFlagResolution = {
6+
kind: RunIdMintKind;
7+
prev?: RunIdMintKind;
8+
flippedAtMs?: number;
9+
};
10+
11+
const DEFAULT_MINT_KIND: RunIdMintKind = "cuid";
12+
13+
// Deterministic wall-clock cutover: during [flippedAt, flippedAt + graceMs) every
14+
// process — stale (hasn't re-read the flag) or fresh (has the new stamp) — resolves to
15+
// the OLD kind. At/after the cutover, every process resolves to the NEW kind.
16+
export function effectiveMintKind(
17+
r: MintFlagResolution,
18+
nowMs: number,
19+
graceMs: number
20+
): RunIdMintKind {
21+
if (r.prev === undefined || r.flippedAtMs === undefined) {
22+
return r.kind;
23+
}
24+
return nowMs < r.flippedAtMs + graceMs ? r.prev : r.kind;
25+
}
26+
27+
function readMintKind(flags: Record<string, unknown>, key: string): RunIdMintKind | undefined {
28+
const value = flags[key];
29+
return value === "cuid" || value === "runOpsId" ? value : undefined;
30+
}
31+
32+
function resolveEffectiveFromFlags(
33+
flags: Record<string, unknown> | null | undefined,
34+
nowMs: number,
35+
graceMs: number
36+
): RunIdMintKind {
37+
const source = flags ?? {};
38+
const kind = readMintKind(source, "runOpsMintKind") ?? DEFAULT_MINT_KIND;
39+
const prev = readMintKind(source, "runOpsMintKindPrev");
40+
const flippedAtRaw = source.runOpsMintKindFlippedAt;
41+
const parsed = typeof flippedAtRaw === "string" ? Date.parse(flippedAtRaw) : NaN;
42+
const flippedAtMs = Number.isNaN(parsed) ? undefined : parsed;
43+
44+
return effectiveMintKind({ kind, prev, flippedAtMs }, nowMs, graceMs);
45+
}
46+
47+
// Stamps a grace window only when the outgoing TARGET kind differs from the stored one (a
48+
// genuine flip); prev := the currently-effective kind. A save that leaves the target kind
49+
// unchanged carries any in-flight stamp forward, so it can't reset the cutover clock.
50+
export function stampMintKindFlip(
51+
existingFlags: Record<string, unknown> | null | undefined,
52+
outgoingFlags: Record<string, unknown>,
53+
nowMs: number,
54+
graceMs: number
55+
): Record<string, unknown> {
56+
const storedKind = readMintKind(existingFlags ?? {}, "runOpsMintKind") ?? DEFAULT_MINT_KIND;
57+
const outgoingKind = readMintKind(outgoingFlags, "runOpsMintKind") ?? DEFAULT_MINT_KIND;
58+
outgoingFlags.runOpsMintKind = outgoingKind;
59+
60+
if (outgoingKind !== storedKind) {
61+
// Genuine target change: serve the currently-effective kind through the new grace window.
62+
outgoingFlags.runOpsMintKindPrev = resolveEffectiveFromFlags(existingFlags, nowMs, graceMs);
63+
outgoingFlags.runOpsMintKindFlippedAt = new Date(nowMs).toISOString();
64+
return outgoingFlags;
65+
}
66+
67+
const existing = existingFlags ?? {};
68+
const existingPrev = existing.runOpsMintKindPrev;
69+
const existingFlippedAt = existing.runOpsMintKindFlippedAt;
70+
if (existingPrev !== undefined) {
71+
outgoingFlags.runOpsMintKindPrev = existingPrev;
72+
}
73+
if (existingFlippedAt !== undefined) {
74+
outgoingFlags.runOpsMintKindFlippedAt = existingFlippedAt;
75+
}
76+
return outgoingFlags;
77+
}

apps/webapp/app/v3/runOpsMigration/runOpsMintKind.flipLatency.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { BoundedTtlCache } from "~/services/realtime/boundedTtlCache";
33
import { computeRunIdMintKind, type RunIdMintKind } from "./runOpsMintKind.server";
44

5-
// LOCK of the CURRENT (intentional) flip-latency behavior, NOT a change request.
6-
// resolveRunIdMintKind caches the per-org mint kind in a process-singleton
7-
// BoundedTtlCache (TTL RUN_OPS_MINT_FLAG_CACHE_TTL_MS, 30000ms default) with get/set
8-
// and NO invalidation hook (runOpsMintKind.server.ts:38-45,56-81). So after a flag
9-
// flip a process keeps minting the stale kind until its cached entry expires; in
10-
// multi-instance prod each process expires independently. This suite reconstructs the
11-
// same flag fn over a real cache and pins both edges of that window.
5+
// LOCK of the raw per-process cache's flip-latency behavior in isolation, NOT a change
6+
// request. Production resolveRunIdMintKind now wraps this same staleness in a deterministic
7+
// wall-clock grace window (mintFlipGrace.ts) so every process resolves to the SAME effective
8+
// kind for the whole window, then all cross together. This raw staleness is now an
9+
// intentional, safe input to that resolution. computeRunIdMintKind is unaffected, so this
10+
// suite's assertions stand as-is.
1211

13-
// Mirror of resolveRunIdMintKind's flag fn (runOpsMintKind.server.ts:56-81).
12+
// Bare cached-flag closure — deliberately NOT the production flag fn, which now layers
13+
// grace resolution on top (see runOpsMintKind.server.ts).
1414
function makeCachedFlag(
1515
cache: BoundedTtlCache<RunIdMintKind>,
1616
liveFlag: () => RunIdMintKind

0 commit comments

Comments
 (0)