Files
triggerdotdev--trigger.dev/apps/webapp/app/v3/mollifier/mollifierGate.server.ts
T
2026-07-13 13:32:57 +08:00

242 lines
9.7 KiB
TypeScript

import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { FEATURE_FLAG, FeatureFlagCatalog } from "~/v3/featureFlags";
import { getMollifierBuffer } from "./mollifierBuffer.server";
import { createRealTripEvaluator } from "./mollifierTripEvaluator.server";
import {
recordDecision,
type DecisionOutcome,
type RecordDecisionOptions,
} from "./mollifierTelemetry.server";
// `count` is the fleet-wide fixed-window counter for the env (INCR with a
// PEXPIRE armed on the first tick of each window — see
// `mollifierEvaluateTrip` in `packages/redis-worker/src/mollifier/buffer.ts`).
// All webapp replicas pointing at the same Redis share the key
// `mollifier:rate:${envId}`, so the threshold is the fleet-wide ceiling
// rather than a per-instance one. At a window boundary an env can briefly
// admit up to ~2x threshold across the fleet before tripping (fixed-window
// not sliding-window). The tripped marker is refreshed on every overage
// call, so a sustained burst holds the divert state until the rate falls
// below threshold within a window.
export type TripDecision =
| { divert: false }
| {
divert: true;
reason: "per_env_rate";
count: number;
threshold: number;
windowMs: number;
holdMs: number;
};
export type GateOutcome =
| { action: "pass_through" }
| { action: "mollify"; decision: Extract<TripDecision, { divert: true }> }
| { action: "shadow_log"; decision: Extract<TripDecision, { divert: true }> };
export type GateInputs = {
envId: string;
orgId: string;
taskId: string;
// Org-scoped flag overrides — taken from `Organization.featureFlags` on the
// AuthenticatedEnvironment at the call site. The repo-wide `flag()` helper
// queries the global `FeatureFlag` table; passing per-org overrides lets the
// mollifier opt in a single org without touching the global row, matching
// the pattern used by `canAccessAi`, `canAccessPrivateConnections`, and the
// compute-template beta gate.
orgFeatureFlags: Record<string, unknown> | null;
// Trigger options that drive the debounce / OTU / triggerAndWait
// bypasses. The mollify path can't
// serialise stateful callbacks (debounce), can't safely break OTU's
// synchronous-rejection contract, and shouldn't intercept single
// triggerAndWait (batchTriggerAndWait still funnels through per item).
options?: {
debounce?: unknown;
oneTimeUseToken?: string;
parentTaskRunId?: string;
resumeParentOnCompletion?: boolean;
};
};
export type TripEvaluator = (inputs: GateInputs) => Promise<TripDecision>;
// DI seam type for consumers (e.g. triggerTask.server.ts) that inject the
// gate at construction time. Deliberately narrower than `evaluateGate`'s
// real signature — no `deps` param — because consumers only call it with
// inputs and rely on the module-level defaults.
export type MollifierEvaluateGate = (inputs: GateInputs) => Promise<GateOutcome>;
export type GateDependencies = {
isMollifierEnabled: () => boolean;
isShadowModeOn: () => boolean;
resolveOrgFlag: (inputs: GateInputs) => Promise<boolean>;
evaluator: TripEvaluator;
logShadow: (inputs: GateInputs, decision: Extract<TripDecision, { divert: true }>) => void;
logMollified: (inputs: GateInputs, decision: Extract<TripDecision, { divert: true }>) => void;
recordDecision: (outcome: DecisionOutcome, opts: RecordDecisionOptions) => void;
};
// `options` is a thunk so env reads happen per-evaluation, not at module load.
// Don't "simplify" to a plain object — dynamic config relies on the
// gate observing whichever env values are live at trigger time.
const defaultEvaluator = createRealTripEvaluator({
getBuffer: () => getMollifierBuffer(),
options: () => ({
windowMs: env.TRIGGER_MOLLIFIER_TRIP_WINDOW_MS,
threshold: env.TRIGGER_MOLLIFIER_TRIP_THRESHOLD,
holdMs: env.TRIGGER_MOLLIFIER_HOLD_MS,
}),
});
function logDivertDecision(
message: "mollifier.would_mollify" | "mollifier.mollified",
inputs: GateInputs,
decision: Extract<TripDecision, { divert: true }>
): void {
logger.debug(message, {
envId: inputs.envId,
orgId: inputs.orgId,
taskId: inputs.taskId,
reason: decision.reason,
count: decision.count,
threshold: decision.threshold,
windowMs: decision.windowMs,
holdMs: decision.holdMs,
});
}
// Resolve the per-org mollifier flag purely from the in-memory
// `Organization.featureFlags` JSON. No DB query — `triggerTask` is the
// trigger hot path and the webapp CLAUDE.md forbids adding Prisma calls
// there. The fleet-wide kill switch lives in `TRIGGER_MOLLIFIER_ENABLED`; rollout
// is per-org via the JSON, matching the pattern used by `canAccessAi`,
// `hasComputeAccess`, etc. There is no global `FeatureFlag` table read
// in this path by design.
export function makeResolveMollifierFlag(): (inputs: GateInputs) => Promise<boolean> {
return (inputs) => {
const override = inputs.orgFeatureFlags?.[FEATURE_FLAG.mollifierEnabled];
if (override !== undefined) {
const parsed = FeatureFlagCatalog[FEATURE_FLAG.mollifierEnabled].safeParse(override);
if (parsed.success) {
return Promise.resolve(parsed.data);
}
}
return Promise.resolve(false);
};
}
const resolveMollifierFlag = makeResolveMollifierFlag();
export const defaultGateDependencies: GateDependencies = {
isMollifierEnabled: () => env.TRIGGER_MOLLIFIER_ENABLED === "1",
isShadowModeOn: () => env.TRIGGER_MOLLIFIER_SHADOW_MODE === "1",
resolveOrgFlag: resolveMollifierFlag,
evaluator: defaultEvaluator,
logShadow: (inputs, decision) => logDivertDecision("mollifier.would_mollify", inputs, decision),
logMollified: (inputs, decision) => logDivertDecision("mollifier.mollified", inputs, decision),
recordDecision,
};
export async function evaluateGate(
inputs: GateInputs,
deps: Partial<GateDependencies> = {}
): Promise<GateOutcome> {
const d = { ...defaultGateDependencies, ...deps };
// Resolve the per-org flag up front so every decision below — including
// the bypasses — can be labelled enrolled vs not on the
// `mollifier.decisions` counter. Fail open: a transient error must not
// block triggers. The resolver is purely in-memory (reads
// `Organization.featureFlags`); it adds no DB round-trip to the hot path.
let orgFlagEnabled: boolean;
try {
orgFlagEnabled = await d.resolveOrgFlag(inputs);
} catch (error) {
logger.warn("mollifier.resolve_org_flag_failed", {
envId: inputs.envId,
orgId: inputs.orgId,
taskId: inputs.taskId,
error: error instanceof Error ? error.message : String(error),
});
orgFlagEnabled = false;
}
// Passed to every `recordDecision`. `org` only becomes a label for the
// (operationally capped) enrolled cohort — the guard is in
// `decisionLabels`, so passing orgId unconditionally here is safe.
const labels: RecordDecisionOptions = { enrolled: orgFlagEnabled, orgId: inputs.orgId };
// Debounce bypass. onDebounced is a closure over webapp state and
// can't be snapshotted into the buffer for drainer replay. Skip before the
// trip evaluator so debounce traffic is never counted against the rate.
if (inputs.options?.debounce) {
d.recordDecision("pass_through", labels);
return { action: "pass_through" };
}
// OneTimeUseToken bypass. OTU is a security feature on the PUBLIC_JWT
// auth path; its synchronous-rejection contract is materially worse to
// break than the idempotency-key contract.
if (inputs.options?.oneTimeUseToken) {
d.recordDecision("pass_through", labels);
return { action: "pass_through" };
}
// Single triggerAndWait bypass. batchTriggerAndWait still funnels
// through TriggerTaskService.call per item so the dominant burst pattern
// remains covered.
if (inputs.options?.parentTaskRunId && inputs.options?.resumeParentOnCompletion) {
d.recordDecision("pass_through", labels);
return { action: "pass_through" };
}
if (!d.isMollifierEnabled()) {
d.recordDecision("pass_through", labels);
return { action: "pass_through" };
}
const shadowOn = d.isShadowModeOn();
if (!orgFlagEnabled && !shadowOn) {
d.recordDecision("pass_through", labels);
return { action: "pass_through" };
}
// Fail open on evaluator errors too. The default `createRealTripEvaluator`
// catches its own errors and returns `{ divert: false }`, but injected or
// future evaluators may not — keep the contract symmetric with the org
// flag resolution above so the trigger hot path can never be broken by a
// gate-internal failure.
//
// Note: the evaluator INCRs the per-env Redis counter (`mollifier:rate:${envId}`)
// in *both* shadow-only and flag-on modes — shadow mode is observation-only at
// the user-visible level (no diversion), but not Redis-passive. It has to write
// because the threshold is computed from a counter, and a counter that doesn't
// increment isn't a counter. There's no cross-org bleed: `RuntimeEnvironment`
// is 1:1 with `Organization`, so the per-env counter is effectively per-org.
let decision: TripDecision;
try {
decision = await d.evaluator(inputs);
} catch (error) {
logger.warn("mollifier.evaluator_failed", {
envId: inputs.envId,
orgId: inputs.orgId,
taskId: inputs.taskId,
error: error instanceof Error ? error.message : String(error),
});
decision = { divert: false };
}
if (!decision.divert) {
d.recordDecision("pass_through", labels);
return { action: "pass_through" };
}
if (orgFlagEnabled) {
d.logMollified(inputs, decision);
d.recordDecision("mollify", { ...labels, reason: decision.reason });
return { action: "mollify", decision };
}
d.logShadow(inputs, decision);
d.recordDecision("shadow_log", { ...labels, reason: decision.reason });
return { action: "shadow_log", decision };
}