You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
popDueRetries() in src/repositories/deliveryRepository.js is the mechanism src/jobs/webhookRetryWorker.js uses to claim due retries from the shared webhooks:retries Redis sorted set:
This is a read, then delete — two separate round trips to Redis, not an atomic operation. webhookRetryWorker.js's in-process running boolean only prevents a single process from overlapping with itself; it provides zero protection across multiple instances of the backend running concurrently (the Docker Compose setup and any real production deployment behind a load balancer would run more than one replica for availability). If two app instances both call popDueRetries(Date.now(), 25) within the same small time window before either has executed its zrem, both instances' zrangebyscore calls can return the same delivery ids, and both will then call dispatcher.attempt(id) for those ids — resulting in the same webhook delivery being POSTed to the subscriber's endpoint twice for what should be a single retry attempt, doubling attempts bookkeeping unpredictably depending on which deliveryRepo.update() call lands last, and potentially double-firing a webhook that a subscriber has already deduplicated on delivery_id server-side (since both racing attempts would actually reuse the samedelivery_id, but attempt independently and race on updating attempts/status, corrupting the delivery's audit trail).
Requirements
Replace the read-then-delete pattern with a single atomic claim operation. Options include:
A Lua script (EVAL) that performs ZRANGEBYSCORE + ZREM atomically in one round trip.
ZPOPMIN-style iteration bounded by score (ioredis supports scripting; a small Lua script is the most direct fix given the existing sorted-set structure).
The fix must guarantee that when N instances call popDueRetries concurrently against the same Redis, the union of ids each one receives is disjoint (no id is returned to more than one caller).
Add a regression test that simulates two concurrent callers racing against a shared mock/real Redis sorted set and asserts no id is returned twice.
Document the atomicity guarantee in the schema comment block already present at the top of deliveryRepository.js.
Acceptance Criteria
Two concurrent calls to popDueRetries(now, max) against the same due-retry set never return an overlapping id.
test/webhookRepository.test.js (or a new test file) includes a concurrency test proving the race is closed, not just a single-caller happy-path test.
cancelRetry, scheduleRetry, and listByWebhook behavior is unchanged.
No behavioral change to single-instance operation — existing test/webhookDispatcher.test.js retry-scheduling tests continue to pass unmodified.
A short note is added to the README or a docs/ file clarifying that webhookRetryWorker is safe to run on multiple replicas without duplicate delivery.
Additional Notes
Additional edge cases / failure modes
ZPOPMIN (mentioned as an alternative in the Requirements) pops the lowest-score members regardless of whether they're actually due yet — it does not take a score ceiling argument, so using it directly would require popping unconditionally and pushing back any items whose score is still in the future, which is itself a second race-prone operation. The Lua-script (EVAL) approach is the more direct fit here since it can combine the score-bounded ZRANGEBYSCORE and the ZREM in one atomic round trip without needing to "un-pop" anything — the Requirements list both options but this distinction is worth resolving explicitly rather than leaving ZPOPMIN as an equally-valid-looking choice in the PR.
max = 25 (the batch size, config.webhooks.retryBatchSize) means each worker claims a bounded batch — with the atomic fix in place, confirm that N concurrently-running workers each still make progress (i.e. the retry queue overall throughput scales, not just correctness) rather than every worker repeatedly winning/losing a scramble for the same front-of-queue batch; this matters once retry queue depth (Missing production metrics for webhook delivery outcomes, retry-queue depth, and price-source failure rates #93 mentions metrics for this) is large enough that 25-per-tick isn't enough to keep up.
The Lua script needs to be loaded via defineCommand (ioredis's mechanism, e.g. redis.defineCommand('popDueRetries', { numberOfKeys: 1, lua: '...' })) once at startup, not re-sent as an inline EVAL string on every call — inline EVAL still works but loses the EVALSHA caching benefit and re-parses the script every tick; prefer defineCommand so cache.getClient()'s existing client is extended once.
Existing behavior when ids.length === 0 (empty due set) returns [] immediately without touching Redis again — the atomic version should preserve this short-circuit so the common "nothing due yet" case (most ticks, given WEBHOOK_RETRY_POLL_MS is presumably shorter than typical backoff windows) doesn't pay for a Lua EVAL round trip it doesn't need; the Lua script itself can just return an empty table cheaply, so this is likely a non-issue but worth confirming in the benchmark/test.
webhookRetryWorker.js's own in-process running guard (tick()) becomes purely a local-overlap guard once cross-instance safety is handled by this fix — make sure the two mechanisms are understood as solving different problems (local re-entrancy vs. cross-replica double-claim) so a future refactor doesn't mistakenly remove one thinking the other makes it redundant.
Implementation sketch
// deliveryRepository.jsconstPOP_DUE_RETRIES_LUA=` local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2]) if #ids > 0 then redis.call('ZREM', KEYS[1], unpack(ids)) end return ids`;functionensureScriptLoaded(redis){if(!redis.popDueRetriesAtomic){redis.defineCommand('popDueRetriesAtomic',{numberOfKeys: 1,lua: POP_DUE_RETRIES_LUA});}}asyncfunctionpopDueRetries(nowMs,max=25){constredis=cache.getClient();ensureScriptLoaded(redis);returnredis.popDueRetriesAtomic(RETRY_QUEUE_KEY,nowMs,max);}
Test / reproduction plan
Against a real (or ioredis-mock-backed, if it supports Lua scripting — verify first, may need a real Redis test container) Redis instance, seed the retry queue with 50 due ids; call popDueRetries(now, 25) twice concurrently via Promise.all; assert the two returned arrays are disjoint and their union has exactly 25+25=50 unique ids (or fewer if fewer than 50 were due, but never overlapping).
Simulate the pre-fix race directly (call zrangebyscore and zrem as two separate awaited steps with an injected delay between them, mimicking the old code) to first demonstrate duplicate ids are returned, then show the atomic version does not reproduce it — a good regression-test structure per the acceptance criteria's "not just a single-caller happy path" requirement.
Confirm cancelRetry/scheduleRetry still function against the same RETRY_QUEUE_KEY untouched by the Lua script change.
webhook_delivery:* records are never expired or pruned from Redis — unbounded memory growth #79 (unbounded delivery record growth) — a delivery id that's popped from the retry queue but whose underlying webhook_delivery:{id} record has since been pruned/expired is already handled gracefully by dispatcher.attempt()'s missing-delivery check; confirm that behavior is unaffected by switching to the atomic pop.
Overview
popDueRetries()insrc/repositories/deliveryRepository.jsis the mechanismsrc/jobs/webhookRetryWorker.jsuses to claim due retries from the sharedwebhooks:retriesRedis sorted set:This is a read, then delete — two separate round trips to Redis, not an atomic operation.
webhookRetryWorker.js's in-processrunningboolean only prevents a single process from overlapping with itself; it provides zero protection across multiple instances of the backend running concurrently (the Docker Compose setup and any real production deployment behind a load balancer would run more than one replica for availability). If two app instances both callpopDueRetries(Date.now(), 25)within the same small time window before either has executed itszrem, both instances'zrangebyscorecalls can return the same delivery ids, and both will then calldispatcher.attempt(id)for those ids — resulting in the same webhook delivery being POSTed to the subscriber's endpoint twice for what should be a single retry attempt, doublingattemptsbookkeeping unpredictably depending on whichdeliveryRepo.update()call lands last, and potentially double-firing a webhook that a subscriber has already deduplicated ondelivery_idserver-side (since both racing attempts would actually reuse the samedelivery_id, but attempt independently and race on updatingattempts/status, corrupting the delivery's audit trail).Requirements
EVAL) that performsZRANGEBYSCORE+ZREMatomically in one round trip.ZPOPMIN-style iteration bounded by score (ioredis supports scripting; a small Lua script is the most direct fix given the existing sorted-set structure).popDueRetriesconcurrently against the same Redis, the union of ids each one receives is disjoint (no id is returned to more than one caller).deliveryRepository.js.Acceptance Criteria
popDueRetries(now, max)against the same due-retry set never return an overlapping id.test/webhookRepository.test.js(or a new test file) includes a concurrency test proving the race is closed, not just a single-caller happy-path test.cancelRetry,scheduleRetry, andlistByWebhookbehavior is unchanged.test/webhookDispatcher.test.jsretry-scheduling tests continue to pass unmodified.docs/file clarifying thatwebhookRetryWorkeris safe to run on multiple replicas without duplicate delivery.Additional Notes
Additional edge cases / failure modes
ZPOPMIN(mentioned as an alternative in the Requirements) pops the lowest-score members regardless of whether they're actually due yet — it does not take a score ceiling argument, so using it directly would require popping unconditionally and pushing back any items whose score is still in the future, which is itself a second race-prone operation. The Lua-script (EVAL) approach is the more direct fit here since it can combine the score-boundedZRANGEBYSCOREand theZREMin one atomic round trip without needing to "un-pop" anything — the Requirements list both options but this distinction is worth resolving explicitly rather than leavingZPOPMINas an equally-valid-looking choice in the PR.max = 25(the batch size,config.webhooks.retryBatchSize) means each worker claims a bounded batch — with the atomic fix in place, confirm that N concurrently-running workers each still make progress (i.e. the retry queue overall throughput scales, not just correctness) rather than every worker repeatedly winning/losing a scramble for the same front-of-queue batch; this matters once retry queue depth (Missing production metrics for webhook delivery outcomes, retry-queue depth, and price-source failure rates #93 mentions metrics for this) is large enough that 25-per-tick isn't enough to keep up.defineCommand(ioredis's mechanism, e.g.redis.defineCommand('popDueRetries', { numberOfKeys: 1, lua: '...' })) once at startup, not re-sent as an inlineEVALstring on every call — inlineEVALstill works but loses theEVALSHAcaching benefit and re-parses the script every tick; preferdefineCommandsocache.getClient()'s existing client is extended once.ids.length === 0(empty due set) returns[]immediately without touching Redis again — the atomic version should preserve this short-circuit so the common "nothing due yet" case (most ticks, givenWEBHOOK_RETRY_POLL_MSis presumably shorter than typical backoff windows) doesn't pay for a LuaEVALround trip it doesn't need; the Lua script itself can just return an empty table cheaply, so this is likely a non-issue but worth confirming in the benchmark/test.webhookRetryWorker.js's own in-processrunningguard (tick()) becomes purely a local-overlap guard once cross-instance safety is handled by this fix — make sure the two mechanisms are understood as solving different problems (local re-entrancy vs. cross-replica double-claim) so a future refactor doesn't mistakenly remove one thinking the other makes it redundant.Implementation sketch
Test / reproduction plan
ioredis-mock-backed, if it supports Lua scripting — verify first, may need a real Redis test container) Redis instance, seed the retry queue with 50 due ids; callpopDueRetries(now, 25)twice concurrently viaPromise.all; assert the two returned arrays are disjoint and their union has exactly 25+25=50 unique ids (or fewer if fewer than 50 were due, but never overlapping).zrangebyscoreandzremas two separate awaited steps with an injected delay between them, mimicking the old code) to first demonstrate duplicate ids are returned, then show the atomic version does not reproduce it — a good regression-test structure per the acceptance criteria's "not just a single-caller happy path" requirement.cancelRetry/scheduleRetrystill function against the sameRETRY_QUEUE_KEYuntouched by the Lua script change.Related issues in this batch
SET NXatomic-claim pattern proposed here is directly reusable for that issue's(webhook_id, event_id)claim.webhook_delivery:{id}record has since been pruned/expired is already handled gracefully bydispatcher.attempt()'s missing-delivery check; confirm that behavior is unaffected by switching to the atomic pop.