fix(redis): stale-job reaping, single-writer reservoir refresh, no double-execution on release failure - #1
Open
taukirsheikh wants to merge 1 commit into
Open
Conversation
…uble-execution on release failure Three reviewed fixes plus two pre-existing bugs uncovered by their tests: 1. Stale-job reaping: a crashed process leaked running/currentWeight forever (heartbeats kept the state alive so it never expired). ACQUIRE now stamps job start times into a <state>:jobs:started zset; HEARTBEAT reaps jobs older than the new staleJobTimeout option (default 60s), reclaims counters (clamped at 0), tracks a 'reaped' stat, and the limiter emits a 'reaped' event. RELEASE only decrements when the job still holds its hash entry, so a reaped job's late release cannot drift counters negative. 2. Reservoir refresh multiplied by process count: every instance ran its own setInterval calling UPDATE_RESERVOIR, refilling N times per interval. Refresh is now lazy inside the atomic ACQUIRE script (single writer by construction, keyed off lastReservoirRefresh); the client-side timer is gone. Idle systems refresh on the next acquire. 3. Success-path release failure caused double execution: a Redis error on the success release fell into the catch, released a second time and retried a job whose fn had already succeeded. executeJob now runs fn, releases best-effort exactly once, and settles: success always resolves with the result (release failure only emits 'error'). Found while testing (both pre-existing, both could double-execute or starve jobs): - tryProcess set its re-entrancy guard after awaiting initPromise, so two concurrent callers raced into the loop: one job acquired and executed twice, its sibling dequeued but never executed (promise hung forever). The guard is now claimed synchronously. - PriorityQueue broke FIFO for same-millisecond enqueues (heap ties are order-arbitrary): a later job could starve an earlier equal-priority one behind a depleted reservoir. Added an insertion sequence stamp as final tie-break for a strict total order. Also hardened the post-loop idle check (skip after stop; catch getState rejection) and made the priority/reservoir tests deterministic. Review fixes (cross-model review of this commit): - INIT_STATE stamps lastReservoirRefresh at creation time (and ACQUIRE treats a missing/0 stamp as seed-only), so the first acquire no longer clobbers the configured initial reservoir with reservoirRefreshAmount. Regression test uses reservoir != refreshAmount. - ACQUIRE refuses a jobId still tracked as active (new 'duplicate' reason, treated as a blocked poll): keeps acquire/release accounting symmetric so reused job ids can no longer permanently leak running/currentWeight; the stale entry keeps its start score and is reaped after staleJobTimeout. - tryProcess removes the acquired job by id (removeById) instead of dequeuing the heap root, closing the double-execute/hung-sibling window when the root changes during the acquire round-trip; if the job vanished (cancel/abort) the just-acquired slot is released. - tryProcess loop now catches errors (emit 'error' + re-poll) so a Redis blip during acquire is no longer an unhandled rejection and queued jobs resume once Redis recovers. - Lazy-refresh test strengthened into a real regression test: staggered second instance must not re-grant capacity mid-interval after the first instance consumes the whole refreshed allowance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes three defects in
DistributedRateLimiterthat surface under real-world distributed conditions — process crashes, multiple processes sharing one limiter, and Redis blips. All three were found while investigating a production outage caused by a different limiter library (Bottleneck) wedging an entire worker fleet; this PR closes the equivalent failure modes here before they bite.Issue 1 — Crashed processes leak slots forever; the limiter can wedge permanently
The problem.
ACQUIRE_SLOTincrementsrunning/currentWeightand records the job in<state>:jobs. If the process dies beforeRELEASE_SLOTruns (SIGKILL, OOM, a deploy), that weight is never reclaimed. TheHEARTBEATscript even had the placeholder comment —-- Could add stale job cleanup here if needed— but no cleanup. And because heartbeats from surviving processes keep extending the key TTL, the leaked state never expires on a busy system.Example.
maxConcurrent: 10. A deploy SIGKILLs a worker holding 4 slots.currentWeightis now permanently 4, so only 6 slots exist. Three deploys later the limiter admits zero jobs — every acquire returns'concurrency'— and it stays wedged through every restart, because the poisoned state lives in Redis, not in any process. (This is exactly how a production fleet using Bottleneck died:running: 18263leaked in Redis, surviving restarts.)The fix. Job start times are tracked in a
<state>:jobs:startedZSET. The heartbeat now reaps jobs older than the newstaleJobTimeoutoption (default 60s — set it above your longest expected job): their weight is reclaimed, counters are clamped at ≥ 0, and areapedevent is emitted. A reaped job's late release is a no-op (release only decrements when it actually finds the job), so counters can't drift negative.Issue 2 — Every process refreshed the shared reservoir: N processes = N× the configured rate
The problem. The reservoir refresh ran in a client-side
setIntervalper limiter instance, each doing a blindHSET reservoir = amount. The reservoir is shared state in Redis, but every process refilled it on its own staggered timer.Example.
reservoir: 25, reservoirRefreshInterval: 1000across 10 worker processes — intended 25 req/s. Ten timers each reset the reservoir to 25 at staggered offsets, so capacity is re-granted up to 10 times per second: an effective rate approaching 250 req/s. The more you scale out, the less your rate limit means — the opposite of what a distributed limiter is for.The fix. The client timers are gone. Refresh now happens lazily inside the
ACQUIRE_SLOTLua script (lastReservoirRefreshstored in the state hash), so refill is atomic and single-writer by construction — the rate is the same whether one process or fifty share the limiter.Issue 3 — A Redis blip on the success path re-executed an already-succeeded job
The problem. In
executeJob, the success-pathreleaseSlotcall sat inside the maintry. If Redis hiccupped right there — after the job's fn had already succeeded — control fell into thecatch, which (a) calledreleaseSlota second time, drifting counters negative, and (b) ran the retry logic, re-executing a job whose side effects had already happened.Example. The job books an appointment via a third-party API. The booking succeeds; Redis times out on the release. The catch path retries the job → the appointment is booked twice. The caller's promise eventually resolves as if everything ran once.
The fix.
executeJobis restructured into explicit steps: run the fn, release the slot exactly once in its own try/catch, then settle. A succeeded fn always resolves with its result — a release failure only emits anerrorevent and can never trigger a retry or a second release.Hardening from adversarial review
The initial implementation of the three fixes was put through a three-lens adversarial review (Lua atomicity, JS job lifecycle, test adequacy — each claim verified against a live Redis with PoC scripts). Five additional defects were found and fixed in this PR:
lastReservoirRefreshseeded to0made the first acquire "refresh" immediately, overwritingreservoir: 1withreservoirRefreshAmount: 5. Init now stamps the current time; a legacy/missing stamp seeds without refreshing.JobOptions.idis public API): two acquires with the same id incrementedrunningtwice but tracked one entry; the second release was a no-op. Acquire now rejects an id that is still in flight with a'duplicate'reason, restoring increment/decrement symmetry.fncould still run twice (pre-existing, blocker):tryProcesspeeked the queue head, awaited the Redis acquire, then blindlydequeue()d — if the heap root changed during the await (higher-priority job,cancel(), abort), the peeked job ran while a different job was removed and its promise hung forever. The loop now removes the job by identity and releases the slot if the job vanished mid-acquire.tryProcess(fire-and-forget on five call sites) crashed Node ≥ 15 with an unhandledRejection and stranded queued jobs. The loop now catches, emitserror, and reschedules polling.Two more pre-existing bugs surfaced by the new tests are included: a
processing-guard race letting two concurrentschedule()calls double-execute one job, and a priority-queue tie-break (same priority, same millisecond) that could starve an earlier job behind a depleted reservoir.Tests
npm test: 18 passednpm run test:redis(live Redis): 19 passed, including new suites for all three fixes — dead-process reap + healthy-job non-reap, cross-instance single refresh with exact reservoir assertions, and success-path release failure (resolves with result, fn ran exactly once,erroremitted)npm run build: cleanKnown pre-existing issue left untouched: the
invalid-hostconnection test leaks a retrying ioredis client, so the redis suite prints its summary but the process needs a kill to exit.🤖 Generated with Claude Code