Skip to content

Commit 3f8b489

Browse files
committed
fix(mollifier): keep trigger hot path DB-free and fail open on flag errors
resolveOrgFlag now checks the per-org Organization.featureFlags override in-memory before falling back to the global flag() helper, so the common per-org enablement path resolves without a Prisma round-trip on every trigger call. evaluateGate also wraps the flag resolution in try/catch and fails open to false on error, mirroring the trip evaluator.
1 parent fc73e02 commit 3f8b489

2 files changed

Lines changed: 138 additions & 8 deletions

File tree

apps/webapp/app/v3/mollifier/mollifierGate.server.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { env } from "~/env.server";
22
import { logger } from "~/services/logger.server";
33
import { flag } from "~/v3/featureFlags.server";
4-
import { FEATURE_FLAG } from "~/v3/featureFlags";
4+
import { FEATURE_FLAG, FeatureFlagCatalog } from "~/v3/featureFlags";
55
import { getMollifierBuffer } from "./mollifierBuffer.server";
66
import { createRealTripEvaluator } from "./mollifierTripEvaluator.server";
77
import {
@@ -95,15 +95,35 @@ function logDivertDecision(
9595
});
9696
}
9797

98+
// Check per-org override in-memory before consulting the DB. `triggerTask`
99+
// is the hot path, so we resolve the common case (org has an explicit
100+
// `mollifierEnabled` value in its `Organization.featureFlags` JSON) without
101+
// a Prisma round-trip. Only orgs with no override fall through to `flag()`,
102+
// which queries the global `FeatureFlag` row.
103+
export function makeResolveMollifierFlag(
104+
flagFn: typeof flag = flag,
105+
): (inputs: GateInputs) => Promise<boolean> {
106+
return (inputs) => {
107+
const override = inputs.orgFeatureFlags?.[FEATURE_FLAG.mollifierEnabled];
108+
if (override !== undefined) {
109+
const parsed = FeatureFlagCatalog[FEATURE_FLAG.mollifierEnabled].safeParse(override);
110+
if (parsed.success) {
111+
return Promise.resolve(parsed.data);
112+
}
113+
}
114+
return flagFn({
115+
key: FEATURE_FLAG.mollifierEnabled,
116+
defaultValue: false,
117+
});
118+
};
119+
}
120+
121+
const resolveMollifierFlag = makeResolveMollifierFlag();
122+
98123
export const defaultGateDependencies: GateDependencies = {
99124
isMollifierEnabled: () => env.MOLLIFIER_ENABLED === "1",
100125
isShadowModeOn: () => env.MOLLIFIER_SHADOW_MODE === "1",
101-
resolveOrgFlag: (inputs) =>
102-
flag({
103-
key: FEATURE_FLAG.mollifierEnabled,
104-
defaultValue: false,
105-
overrides: inputs.orgFeatureFlags ?? {},
106-
}),
126+
resolveOrgFlag: resolveMollifierFlag,
107127
evaluator: defaultEvaluator,
108128
logShadow: (inputs, decision) =>
109129
logDivertDecision("mollifier.would_mollify", inputs, decision),
@@ -123,7 +143,21 @@ export async function evaluateGate(
123143
return { action: "pass_through" };
124144
}
125145

126-
const orgFlagEnabled = await d.resolveOrgFlag(inputs);
146+
// Fail open: a transient DB error resolving the per-org flag must not
147+
// block triggers. Mirror the evaluator's fail-open posture in
148+
// `mollifierTripEvaluator.server.ts`.
149+
let orgFlagEnabled: boolean;
150+
try {
151+
orgFlagEnabled = await d.resolveOrgFlag(inputs);
152+
} catch (error) {
153+
logger.warn("mollifier.resolve_org_flag_failed", {
154+
envId: inputs.envId,
155+
orgId: inputs.orgId,
156+
taskId: inputs.taskId,
157+
error: error instanceof Error ? error.message : String(error),
158+
});
159+
orgFlagEnabled = false;
160+
}
127161
const shadowOn = d.isShadowModeOn();
128162

129163
if (!orgFlagEnabled && !shadowOn) {

apps/webapp/test/mollifierGate.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { FEATURE_FLAG } from "~/v3/featureFlags";
1616
import { makeFlag } from "~/v3/featureFlags.server";
1717
import {
1818
evaluateGate,
19+
makeResolveMollifierFlag,
1920
type GateDependencies,
2021
type GateInputs,
2122
type TripDecision,
@@ -195,6 +196,101 @@ describe("evaluateGate cascade — exhaustive truth table", () => {
195196
// we build it via `makeFlag(prisma)` and let the `Organization.featureFlags`
196197
// blob flow through `flag()`'s overrides path. The global `FeatureFlag` table
197198
// is empty, so the only signal moving outcomes is the per-org JSON.
199+
// Hot-path guard: `triggerTask.server.ts` calls `evaluateGate` on every
200+
// trigger when `MOLLIFIER_ENABLED=1`. The per-org override path must resolve
201+
// without a Prisma round-trip — otherwise the gate adds a DB query to the
202+
// highest-throughput code path in the system (see apps/webapp/CLAUDE.md).
203+
describe("resolveMollifierFlag — hot path", () => {
204+
it("returns override value without calling flag() when override is set", async () => {
205+
let flagCalls = 0;
206+
const flagStub: any = async () => {
207+
flagCalls += 1;
208+
return false;
209+
};
210+
const resolve = makeResolveMollifierFlag(flagStub);
211+
212+
const enabled = await resolve({
213+
envId: "e",
214+
orgId: "o",
215+
taskId: "t",
216+
orgFeatureFlags: { mollifierEnabled: true },
217+
});
218+
const disabled = await resolve({
219+
envId: "e",
220+
orgId: "o",
221+
taskId: "t",
222+
orgFeatureFlags: { mollifierEnabled: false },
223+
});
224+
225+
expect(enabled).toBe(true);
226+
expect(disabled).toBe(false);
227+
expect(flagCalls).toBe(0);
228+
});
229+
230+
it("falls back to flag() when org has no override for the key", async () => {
231+
let flagCalls = 0;
232+
const flagStub: any = async () => {
233+
flagCalls += 1;
234+
return true;
235+
};
236+
const resolve = makeResolveMollifierFlag(flagStub);
237+
238+
const fromNull = await resolve({
239+
envId: "e",
240+
orgId: "o",
241+
taskId: "t",
242+
orgFeatureFlags: null,
243+
});
244+
const fromUnrelatedKeys = await resolve({
245+
envId: "e",
246+
orgId: "o",
247+
taskId: "t",
248+
orgFeatureFlags: { hasAiAccess: true },
249+
});
250+
251+
expect(fromNull).toBe(true);
252+
expect(fromUnrelatedKeys).toBe(true);
253+
expect(flagCalls).toBe(2);
254+
});
255+
});
256+
257+
describe("evaluateGate — fail open on resolveOrgFlag error", () => {
258+
it("treats org flag as false when resolveOrgFlag throws, and does not block triggers", async () => {
259+
const spies: Spies = {
260+
evaluatorCalls: 0,
261+
logShadowCalls: [],
262+
logMollifiedCalls: [],
263+
recordDecisionCalls: [],
264+
};
265+
const deps: Partial<GateDependencies> = {
266+
isMollifierEnabled: () => true,
267+
isShadowModeOn: () => false,
268+
resolveOrgFlag: async () => {
269+
throw new Error("simulated prisma timeout");
270+
},
271+
evaluator: async () => {
272+
spies.evaluatorCalls += 1;
273+
return trippedDecision;
274+
},
275+
logShadow: (inputs, decision) => {
276+
spies.logShadowCalls.push({ inputs, decision });
277+
},
278+
logMollified: (inputs, decision) => {
279+
spies.logMollifiedCalls.push({ inputs, decision });
280+
},
281+
recordDecision: (outcome, reason) => {
282+
spies.recordDecisionCalls.push({ outcome, reason });
283+
},
284+
};
285+
286+
const outcome = await evaluateGate(inputs, deps);
287+
288+
expect(outcome.action).toBe("pass_through");
289+
expect(spies.evaluatorCalls).toBe(0);
290+
expect(spies.recordDecisionCalls).toEqual([{ outcome: "pass_through", reason: undefined }]);
291+
});
292+
});
293+
198294
describe("evaluateGate — per-org isolation via Organization.featureFlags", () => {
199295
function makeIsolationDeps(
200296
realResolveOrgFlag: GateDependencies["resolveOrgFlag"],

0 commit comments

Comments
 (0)