test(mollifier): cover six previously-untested drainer behaviours

1. start() resets envCursor to 0 — new behaviour. A stop+start cycle now
   begins rotation cleanly from envs[0] rather than inheriting between-
   restart cursor drift.
2. Malformed payload → non-retryable handler error path. Pins that the
   deserialise failure goes terminal without invoking the handler.
3. Ack failure after handler success — documents the current behavioural
   gap. ack() lives inside processEntry's try, so a Redis blip on ack
   routes a successfully-handled entry through the retry/terminal path.
   Phase 2's engine-replay handler will need idempotency to absorb the
   re-execution, OR ack should be lifted out of the try block.
4. start() idempotency — second call is a no-op (no doubled loop).
5. stop() idempotency — safe to call when never started or twice.
6. Loop-level backoff actually grows on consecutive runOnce failures
   and resets on first success. Distinct from per-entry retry attempts
   already covered elsewhere; this is the consecutiveErrors counter
   that drives backoffMs between ticks.

Also adds org-level fairness analogue of the existing env starvation
test: a light org (1 env, 1 entry) is not starved behind a heavy org
with many envs and many entries. The buffer doesn't track orgs as a
separate axis, so org fairness is an emergent property of env rotation
— the test pins that property explicitly.
This commit is contained in:
Dan Sutton
2026-05-15 09:03:01 +01:00
parent cb8a54d214
commit 3daee331ab
2 changed files with 335 additions and 0 deletions
@@ -568,6 +568,336 @@ describe("MollifierDrainer per-tick env cap", () => {
expect(remaining).toBeLessThan(100);
}
});
it("a light org is not starved behind a heavy org with many envs", async () => {
// Org-level fairness analogue of the env-level no-starvation test.
// Org A has many envs each with many entries (a noisy tenant). Org B
// has a single env with a single entry. The drainer's per-env rotation
// means org_B's env still gets a turn each cycle — its single entry
// is drained within (envs.length - sliceSize + 1) ticks regardless of
// how much pressure org_A is applying through its many envs.
//
// The buffer doesn't track orgs as a separate axis (each entry just
// carries orgId on its payload); fairness across orgs is therefore an
// emergent property of fairness across envs. This test pins that
// property: org-level drainage latency is bounded by the env rotation,
// not by total org throughput.
const orgAEnvs = Array.from({ length: 6 }, (_, i) => `env_orgA_${i}`);
const orgBEnv = "env_orgB_only";
const queues = new Map<string, Array<{ runId: string; orgId: string }>>();
for (const e of orgAEnvs) {
queues.set(
e,
Array.from({ length: 100 }, (_, i) => ({
runId: `${e}_run_${i}`,
orgId: "org_A",
})),
);
}
queues.set(orgBEnv, [{ runId: `${orgBEnv}_run_0`, orgId: "org_B" }]);
const drainedByOrg: Record<string, number> = { org_A: 0, org_B: 0 };
const buffer = makeStubBuffer({
listEnvs: async () =>
[...queues.keys()].filter((k) => (queues.get(k)?.length ?? 0) > 0),
pop: async (envId: string) => {
const q = queues.get(envId);
if (!q || q.length === 0) return null;
const entry = q.shift()!;
return {
runId: entry.runId,
envId,
orgId: entry.orgId,
payload: "{}",
status: "DRAINING",
attempts: 0,
createdAt: new Date(),
} as any;
},
});
const drainer = new MollifierDrainer({
buffer,
handler: async (input) => {
drainedByOrg[input.orgId] = (drainedByOrg[input.orgId] ?? 0) + 1;
},
concurrency: 4,
maxAttempts: 3,
isRetryable: () => false,
maxEnvsPerTick: 4, // < 7 envs, exercises slicing
logger: new Logger("test-drainer", "log"),
});
// 7 envs (6 from org_A + 1 from org_B), sliceSize=4 → worst-case wait
// for org_B's env is `envs.length - sliceSize + 1 = 4` ticks.
const ticksUntilOrgBDrained = await (async () => {
for (let tick = 1; tick <= 7; tick++) {
await drainer.runOnce();
if ((drainedByOrg["org_B"] ?? 0) > 0) return tick;
}
return Infinity;
})();
expect(ticksUntilOrgBDrained).toBeLessThanOrEqual(4);
// Sanity: org_A is being drained too (not starved itself) but its many
// envs are far from empty.
expect(drainedByOrg["org_A"]).toBeGreaterThan(0);
for (const e of orgAEnvs) {
expect(queues.get(e)!.length).toBeGreaterThan(0);
}
});
});
describe("MollifierDrainer additional coverage", () => {
// Helper duplicated locally to keep these tests self-contained.
type StubBuffer = Partial<MollifierBuffer> & { [K in keyof MollifierBuffer]?: any };
function makeStubBuffer(overrides: StubBuffer): MollifierBuffer {
const base: StubBuffer = {
listEnvs: async () => [],
pop: async () => null,
ack: async () => {},
requeue: async () => {},
fail: async () => true,
getEntry: async () => null,
close: async () => {},
};
return { ...base, ...overrides } as unknown as MollifierBuffer;
}
it("a malformed payload is treated as a non-retryable handler error and goes terminal", async () => {
// The deserialise call lives inside processEntry's try, so a JSON parse
// failure is caught by the same handler-error branch. With
// isRetryable=false, the entry transitions directly to FAILED — the
// handler is never invoked because the throw happens before the
// handler call.
let handlerCalled = false;
const failedEntries: Array<{ runId: string; error: { code: string; message: string } }> = [];
const buffer = makeStubBuffer({
listEnvs: async () => ["env_a"],
pop: async () =>
({
runId: "run_malformed",
envId: "env_a",
orgId: "org_1",
payload: "not valid json {",
status: "DRAINING",
attempts: 0,
createdAt: new Date(),
}) as any,
fail: async (runId: string, error: { code: string; message: string }) => {
failedEntries.push({ runId, error });
return true;
},
});
const drainer = new MollifierDrainer({
buffer,
handler: async () => {
handlerCalled = true;
},
concurrency: 1,
maxAttempts: 3,
isRetryable: () => false,
logger: new Logger("test-drainer", "log"),
});
const result = await drainer.runOnce();
expect(handlerCalled).toBe(false);
expect(result.failed).toBe(1);
expect(result.drained).toBe(0);
expect(failedEntries).toHaveLength(1);
expect(failedEntries[0]?.runId).toBe("run_malformed");
});
it("an ack failure after a successful handler is currently treated as a handler error (documented behaviour)", async () => {
// CAVEAT: this pins a known behaviour gap, not the ideal behaviour.
// ack() lives inside the same try as the handler call, so if the
// handler succeeds but ack throws (e.g. transient Redis blip), the
// entry is routed through the retry/terminal path even though the
// handler-side work completed. Phase 2's engine-replay handler will
// need idempotency to absorb the re-execution this implies on retry,
// OR ack should be lifted out of the try block.
let handlerCalls = 0;
const failedEntries: string[] = [];
const buffer = makeStubBuffer({
listEnvs: async () => ["env_a"],
pop: async () =>
({
runId: "run_x",
envId: "env_a",
orgId: "org_1",
payload: "{}",
status: "DRAINING",
attempts: 0,
createdAt: new Date(),
}) as any,
ack: async () => {
throw new Error("simulated ack failure");
},
fail: async (runId: string) => {
failedEntries.push(runId);
return true;
},
});
const drainer = new MollifierDrainer({
buffer,
handler: async () => {
handlerCalls += 1;
},
concurrency: 1,
maxAttempts: 3,
isRetryable: () => false,
logger: new Logger("test-drainer", "log"),
});
await drainer.runOnce();
expect(handlerCalls).toBe(1); // handler did run
expect(failedEntries).toEqual(["run_x"]); // but entry was marked failed anyway
});
it("start() called twice does not spawn a second loop", async () => {
let listEnvsCalls = 0;
const buffer = makeStubBuffer({
listEnvs: async () => {
listEnvsCalls += 1;
return [];
},
});
const drainer = new MollifierDrainer({
buffer,
handler: async () => {},
concurrency: 1,
maxAttempts: 3,
isRetryable: () => false,
pollIntervalMs: 50,
logger: new Logger("test-drainer", "log"),
});
drainer.start();
drainer.start(); // no-op
await new Promise((r) => setTimeout(r, 150));
await drainer.stop({ timeoutMs: 500 });
// One loop's worth of polling, not two. Allow a small fudge for timing —
// a doubled loop would produce ~2x the calls in the same window.
expect(listEnvsCalls).toBeLessThan(10);
});
it("stop() is idempotent and safe to call when never started", async () => {
const buffer = makeStubBuffer({});
const drainer = new MollifierDrainer({
buffer,
handler: async () => {},
concurrency: 1,
maxAttempts: 3,
isRetryable: () => false,
logger: new Logger("test-drainer", "log"),
});
// Never started.
await expect(drainer.stop()).resolves.toBeUndefined();
// Started then stopped twice.
drainer.start();
await expect(drainer.stop()).resolves.toBeUndefined();
await expect(drainer.stop()).resolves.toBeUndefined();
});
it("envCursor resets to 0 on start() so a stop+start cycle begins from envs[0]", async () => {
const allEnvs = ["env_a", "env_b", "env_c", "env_d", "env_e", "env_f"];
const popLog: string[] = [];
const buffer = makeStubBuffer({
listEnvs: async () => allEnvs,
pop: async (envId: string) => {
popLog.push(envId);
return null;
},
});
const drainer = new MollifierDrainer({
buffer,
handler: async () => {},
concurrency: 3,
maxAttempts: 3,
isRetryable: () => false,
maxEnvsPerTick: 3,
// Long sleep so the loop ticks exactly once between start() and stop().
pollIntervalMs: 10_000,
logger: new Logger("test-drainer", "log"),
});
// Advance the cursor via runOnce so it's nonzero before start().
await drainer.runOnce();
await drainer.runOnce();
popLog.length = 0;
drainer.start();
// Wait long enough for the loop's first tick to complete.
await new Promise((r) => setTimeout(r, 100));
await drainer.stop({ timeoutMs: 1_000 });
// The first slice after start() should begin at envs[0] (cursor reset)
// — the slice is [env_a, env_b, env_c]. Without the reset, it would
// start at env_c (cursor was 2).
expect(popLog.slice(0, 3)).toEqual(["env_a", "env_b", "env_c"]);
});
it("loop backoff grows with consecutive runOnce failures and resets on success", async () => {
// The loop catches runOnce-level errors (e.g. listEnvs blip), increments
// `consecutiveErrors`, and delays for backoffMs(consecutiveErrors) —
// capped at 5s. This test pins the growth curve by failing N times in a
// row and observing increasing inter-tick gaps, then succeeding to
// verify the counter resets.
const tickTimestamps: number[] = [];
let listEnvsCalls = 0;
const buffer = makeStubBuffer({
listEnvs: async () => {
listEnvsCalls += 1;
tickTimestamps.push(Date.now());
if (listEnvsCalls <= 4) {
throw new Error("simulated sustained outage");
}
return []; // success — resets consecutiveErrors
},
});
const drainer = new MollifierDrainer({
buffer,
handler: async () => {},
concurrency: 1,
maxAttempts: 3,
isRetryable: () => false,
pollIntervalMs: 100,
logger: new Logger("test-drainer", "log"),
});
drainer.start();
// Allow time for 4 failures + first success + a few subsequent successes.
// Backoff schedule on errors 1..4: 200ms, 400ms, 800ms, 1.6s ≈ 3s total
// worst case. Add headroom for jitter.
await new Promise((r) => setTimeout(r, 4_000));
await drainer.stop({ timeoutMs: 1_000 });
expect(listEnvsCalls).toBeGreaterThanOrEqual(5);
// Inter-tick gaps during the failure run should grow (exponential).
const gap1 = tickTimestamps[1]! - tickTimestamps[0]!;
const gap2 = tickTimestamps[2]! - tickTimestamps[1]!;
const gap3 = tickTimestamps[3]! - tickTimestamps[2]!;
expect(gap2).toBeGreaterThan(gap1);
expect(gap3).toBeGreaterThan(gap2);
// After the first success (tick 5), counter resets, so the gap between
// tick 5 and tick 6 should drop back to pollIntervalMs-ish — much
// smaller than gap3 (which was the longest backoff).
if (tickTimestamps.length >= 6) {
const postRecoveryGap = tickTimestamps[5]! - tickTimestamps[4]!;
expect(postRecoveryGap).toBeLessThan(gap3);
}
});
});
describe("MollifierDrainer.start/stop", () => {
@@ -83,6 +83,11 @@ export class MollifierDrainer<TPayload = unknown> {
if (this.isRunning) return;
this.isRunning = true;
this.stopping = false;
// Reset rotation state on each (re)start. A stop+start cycle means
// operator intent to "begin clean" — between-restart cursor drift
// would otherwise carry implicit state across what should look like
// a fresh boot. Pinned by the cursor-reset test in drainer.test.ts.
this.envCursor = 0;
this.loopPromise = this.loop();
}