Skip to content

Commit 68ae8b0

Browse files
committed
test(webapp): pin mollifier drainer worker error-classification policy
Adds the smallest DI surface to `initMollifierDrainerWorker` (`isEnabled` and `getDrainer`, both optional, default to live env/singleton) so the catch-block policy can be tested without manipulating module-level env: - rethrows MollifierConfigurationError — deterministic misconfig escapes, which is what makes the production-path crash on boot (the call site in entry.server.tsx runs sync at module top level, before `process.on("uncaughtException", ...)` is registered, so an escape becomes a Node default-handler exit-1). - rethrows when `name === "MollifierConfigurationError"` even when `instanceof` fails — covers the Remix dev hot-reload realm edge case where the catch holds a stale class reference. - swallows non-configuration errors — a transient Redis blip during buffer init shouldn't take the whole webapp down. - no-op when disabled — the factory isn't invoked when the enabled predicate returns false. Also updates the existing mollifier server-changes note to: rename env vars to TRIGGER_MOLLIFIER_* prefix, document the TRIGGER_MOLLIFIER_DRAINER_ENABLED split for multi-replica drainer placement, and call out the new fail-loud behaviour on drainer misconfiguration.
1 parent c95e141 commit 68ae8b0

3 files changed

Lines changed: 88 additions & 4 deletions

File tree

.server-changes/mollifier-burst-protection.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: feature
44
---
55

6-
Lay the groundwork for an opt-in burst-protection layer on the trigger hot path. This release ships **monitoring only** — operators can observe per-env trigger storms via two opt-in modes, but no trigger calls are diverted or rate-limited yet (active burst smoothing follows in a later release). All new env vars default off, so existing deployments see no behaviour change. With `MOLLIFIER_SHADOW_MODE=1`, each trigger evaluates a per-env rate counter and logs `mollifier.would_mollify` when the threshold is crossed. With `MOLLIFIER_ENABLED=1` plus a per-org `mollifierEnabled` flag, over-threshold triggers are also recorded in a Redis audit buffer alongside the normal `engine.trigger` call, drained by a background no-op consumer. Emits the `mollifier.decisions` OTel counter for per-env rate visibility.
6+
Lay the groundwork for an opt-in burst-protection layer on the trigger hot path. This release ships **monitoring only** — operators can observe per-env trigger storms via two opt-in modes, but no trigger calls are diverted or rate-limited yet (active burst smoothing follows in a later release). All new env vars are prefixed `TRIGGER_MOLLIFIER_*` and default off, so existing deployments see no behaviour change. With `TRIGGER_MOLLIFIER_SHADOW_MODE=1`, each trigger evaluates a per-env rate counter and logs `mollifier.would_mollify` when the threshold is crossed. With `TRIGGER_MOLLIFIER_ENABLED=1` plus a per-org `mollifierEnabled` flag, over-threshold triggers are also recorded in a Redis audit buffer alongside the normal `engine.trigger` call, drained by a background no-op consumer. The drainer has its own switch (`TRIGGER_MOLLIFIER_DRAINER_ENABLED`) so multi-replica deployments can pin the polling loop to a single worker service while every replica still produces into the buffer; unset, it inherits `TRIGGER_MOLLIFIER_ENABLED` so single-container self-hosters need only one flag. Drainer misconfiguration (shutdown-timeout reconciliation against `GRACEFUL_SHUTDOWN_TIMEOUT`, or `TRIGGER_MOLLIFIER_ENABLED=1` with no buffer Redis) now throws `MollifierConfigurationError` at boot and crashes the process, so the misconfig surfaces to the orchestrator instead of disappearing into a log line; transient init failures (Redis blip) are still logged-and-swallowed. Emits the `mollifier.decisions` OTel counter for per-env rate visibility.

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,25 @@ declare global {
4343
* master kill switch; the new flag only controls WHICH replicas
4444
* run the drainer when the system is on.
4545
*/
46-
export function initMollifierDrainerWorker(): void {
47-
if (env.TRIGGER_MOLLIFIER_DRAINER_ENABLED !== "1") {
46+
export function initMollifierDrainerWorker(
47+
opts: {
48+
// Test seams. Production callers pass nothing; the defaults read the
49+
// live env and resolve the live singleton. Tests inject overrides so
50+
// the misconfig-rethrow / transient-swallow branches can be driven
51+
// without manipulating module-level env state.
52+
isEnabled?: () => boolean;
53+
getDrainer?: typeof getMollifierDrainer;
54+
} = {},
55+
): void {
56+
const isEnabled = opts.isEnabled ?? (() => env.TRIGGER_MOLLIFIER_DRAINER_ENABLED === "1");
57+
const getDrainer = opts.getDrainer ?? getMollifierDrainer;
58+
59+
if (!isEnabled()) {
4860
return;
4961
}
5062

5163
try {
52-
const drainer = getMollifierDrainer();
64+
const drainer = getDrainer();
5365
if (drainer && !global.__mollifierShutdownRegistered__) {
5466
// `__mollifierShutdownRegistered__` guards against double-register
5567
// on dev hot-reloads (this bootstrap is called from
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from "vitest";
2+
import { MollifierConfigurationError } from "~/v3/mollifier/mollifierDrainer.server";
3+
import { initMollifierDrainerWorker } from "~/v3/mollifierDrainerWorker.server";
4+
5+
// Pins the error-classification policy inside the bootstrap's catch:
6+
// deterministic misconfig errors propagate (so a deploy fails loud
7+
// rather than silently disabling the drainer), and anything else is
8+
// logged-and-swallowed (so a transient Redis blip during boot doesn't
9+
// take the whole webapp down). The corresponding production-path
10+
// integration is the call at `entry.server.tsx`: a sync throw out of
11+
// `initMollifierDrainerWorker` propagates to the module top level
12+
// BEFORE `process.on("uncaughtException", ...)` is registered, so Node
13+
// crashes with a stack trace and exit code 1 — which is exactly what we
14+
// want from the orchestrator's health-check perspective.
15+
describe("initMollifierDrainerWorker error classification", () => {
16+
it("rethrows MollifierConfigurationError so the process can crash on misconfig", () => {
17+
const misconfig = new MollifierConfigurationError(
18+
"TRIGGER_MOLLIFIER_DRAIN_SHUTDOWN_TIMEOUT_MS must be at least 1000ms below GRACEFUL_SHUTDOWN_TIMEOUT",
19+
);
20+
21+
expect(() =>
22+
initMollifierDrainerWorker({
23+
isEnabled: () => true,
24+
getDrainer: () => {
25+
throw misconfig;
26+
},
27+
}),
28+
).toThrow(MollifierConfigurationError);
29+
});
30+
31+
it("rethrows when the error carries the marker name even if instanceof fails (dev-realm hot-reload fallback)", () => {
32+
// Simulate the cross-realm case where the consumer's instanceof
33+
// check sees a different class instance from the one the throw
34+
// site used. The bootstrap's `.name === "MollifierConfigurationError"`
35+
// fallback must catch this so dev hot-reload doesn't silently
36+
// suppress misconfig errors.
37+
const cousin = new Error("buffer not initialised");
38+
cousin.name = "MollifierConfigurationError";
39+
40+
expect(() =>
41+
initMollifierDrainerWorker({
42+
isEnabled: () => true,
43+
getDrainer: () => {
44+
throw cousin;
45+
},
46+
}),
47+
).toThrow(cousin);
48+
});
49+
50+
it("swallows non-configuration errors so transient init failures don't take the webapp down", () => {
51+
expect(() =>
52+
initMollifierDrainerWorker({
53+
isEnabled: () => true,
54+
getDrainer: () => {
55+
throw new Error("transient redis blip during buffer init");
56+
},
57+
}),
58+
).not.toThrow();
59+
});
60+
61+
it("is a no-op when the drainer is disabled for this replica", () => {
62+
let factoryCalled = false;
63+
initMollifierDrainerWorker({
64+
isEnabled: () => false,
65+
getDrainer: () => {
66+
factoryCalled = true;
67+
return null;
68+
},
69+
});
70+
expect(factoryCalled).toBe(false);
71+
});
72+
});

0 commit comments

Comments
 (0)