Skip to content

fix: make deliveryRepository.popDueRetries atomic to close cross-replica retry race (#76) - #107

Merged
prodbycorne merged 12 commits into
SmartDropLabs:mainfrom
boluwacodes:fix/76-atomic-pop-due-retries
Jul 22, 2026
Merged

fix: make deliveryRepository.popDueRetries atomic to close cross-replica retry race (#76)#107
prodbycorne merged 12 commits into
SmartDropLabs:mainfrom
boluwacodes:fix/76-atomic-pop-due-retries

Conversation

@boluwacodes

Copy link
Copy Markdown
Contributor

Summary

Closes #76.

popDueRetries() claimed due webhook retries from the webhooks:retries
Redis sorted set via a ZRANGEBYSCORE followed by a separate ZREM -
two round trips, not one atomic operation. Running more than one
backend replica (the normal case behind a load balancer) let two
instances read the same due ids before either had removed them,
double-claiming the same delivery and double-firing the webhook.

  • Replaced the read-then-delete pattern with a single Lua script
    (ZRANGEBYSCORE + ZREM in one round trip), registered once via
    ioredis's defineCommand so it's cached as EVALSHA rather than
    re-parsed every tick.
  • cancelRetry, scheduleRetry, and listByWebhook are unchanged.
  • Documented the atomicity guarantee in the schema comment block at the
    top of deliveryRepository.js and in the README's retry-semantics
    section (multi-replica safety comes from the atomic claim, not from
    the worker's in-process running guard, which only prevents a
    single process from overlapping with itself).
  • Added test/deliveryRepository.test.js: concurrent-caller tests
    proving no id is ever returned twice (2 and 4 concurrent callers
    against a shared due set), plus happy-path/empty-set/not-yet-due
    cases, an unchanged-behavior check for cancelRetry/scheduleRetry/
    listByWebhook, and a direct reproduction of the original
    read-then-delete race to prove this is closing a real failure mode.
  • Extended the shared test Redis mock (test/helpers/cacheMock.js)
    with defineCommand support so the mock preserves the same
    single-round-trip atomicity a real Lua script gets from Redis.

Unrelated CI breakage fixed along the way

main's CI has been red since PR #100 (4130067+): a chain of merges
(#100, #105, #67) pasted large blocks of src/config.js, src/index.js,
and test/errorHandler.test.js twice, breaking require() outright, and
left several tests (auth.test.js, airdrops.test.js,
alerts-routes.test.js, airdrops-service.test.js, config.test.js)
out of sync with the Redis Set → Sorted Set conversion from #105 (missing
zadd/zrem/zrevrange/zscan on inline test mocks, and one stale
sscan assertion). Since this branch is built on top of main, none of
that could be left broken and still show green CI here - each fix is its
own commit, clearly separated from the #76 work itself.

Test plan

  • npm test - all 29 suites / 258 tests pass
  • npx @redocly/cli lint openapi.yaml - passes (7 pre-existing warnings, no errors)
  • Manually verified src/config.js and src/index.js load without
    syntax errors and export the fields other modules depend on
    (config.rateLimit, config.priceRateLimit, config.airdrops.*)

The merge of SmartDropLabs#67 (fix-10-api-rate-limiting) into main pasted the entire
top of the file twice and, within the second copy, declared `airdrops`
twice in module.exports so the later object-literal key silently dropped
expiryCheckIntervalSeconds/ledgerCacheTtlMs/expiryScanBatchSize used by
the airdrop expiry job. Broke `require('./config')` outright (duplicate
`const` in the same scope), taking every test that touches config with it.
Same bad-merge pattern as config.js: the whole file was pasted twice.
Kept the newer body (health checks, price-source circuit state,
airdrop expiry job wiring) and restored the API rate-limiting
middleware mounts from the older copy, since that wiring is the actual
feature SmartDropLabs#67 was merging in and would otherwise have been lost.
Same duplicated-file pattern; merged the two copies (keeping the
PAYLOAD_TOO_LARGE coverage added by the newer copy) and fixed the
RATE_LIMITED assertion to include retry_after_seconds, matching what
src/middleware/rateLimit.js actually returns.
This assertion predates the airdrop expiry job's config additions
(expiryCheckIntervalSeconds, ledgerCacheTtlMs, expiryScanBatchSize),
which landed in a parallel PR and were never reflected here.
scanIds() was converted from SSCAN (plain set) to ZSCAN (sorted set)
by SmartDropLabs#105, but this one assertion still checked mockRedis.sscan instead
of mockRedis.zscan, so it silently asserted 0 > 1 was never reached
via the real code path and would have passed for the wrong reason.
SmartDropLabs#105 converted apiKeys.js's id index from a plain Set (sadd/srem/
smembers) to a sorted Set (zadd/zrem/zrevrange) for stable pagination,
but this test's inline Redis mock was never updated, so createKey()
threw "redis.zadd is not a function" for every test that creates a key.
Same missing zadd/zrem/zrevrange/zcard/zscan gap as auth.test.js -
airdrops.js's id index and expiry scan also moved to a sorted set
under SmartDropLabs#105. Also removes leftover console.log debugging statements
someone left in while chasing this same failure.
alerts.js's id index also moved to a sorted set under SmartDropLabs#105
(zadd/zrevrange/zcard/zrem); this route-level test's Redis mock only
had smembers, so GET /api/v1/alerts 500'd before ever reaching the
pagination assertions it's meant to check.
popDueRetries() was a ZRANGEBYSCORE followed by a separate ZREM - two
round trips, not one atomic operation. Two backend replicas polling
the shared webhooks:retries sorted set at the same time could both
read the same due ids before either had removed them, double-claiming
the same delivery and double-firing the webhook.

Replaces it with a single Lua script (ZRANGEBYSCORE + ZREM in one
round trip) registered via ioredis's defineCommand, so N concurrent
callers against the same Redis always partition the due set instead
of racing on it. cancelRetry/scheduleRetry/listByWebhook are untouched.
The atomic popDueRetries fix registers a custom Redis command via
defineCommand, which the in-memory cache mock didn't implement. Adds
a minimal emulation of the one custom command this codebase registers
(popDueRetriesAtomic), reading and deleting without an intervening
await so the mock preserves the same single-round-trip atomicity a
real Lua script gets from Redis's single-threaded execution.
…pLabs#76)

Covers the acceptance criteria from SmartDropLabs#76: two and four concurrent
callers against a shared due-retry set never receive overlapping ids
and the union exactly partitions what was seeded, plus happy-path/
empty-set/not-yet-due cases and a sanity check that cancelRetry,
scheduleRetry, and listByWebhook still behave as before.

Also includes a direct regression test that reproduces the original
bug by calling the old read-then-delete-with-a-gap pattern directly
against the mock Redis, proving the failure mode this fix closes
rather than just asserting the new code happens not to trigger it.
…abs#76)

Notes that popDueRetries' atomic Lua claim - not the worker's
in-process `running` flag - is what makes it safe to run
webhookRetryWorker on multiple backend replicas without double
delivery, per the SmartDropLabs#76 acceptance criteria.
@boluwacodes
boluwacodes force-pushed the fix/76-atomic-pop-due-retries branch from 53df22d to 83bcb0a Compare July 22, 2026 12:43
@prodbycorne

Copy link
Copy Markdown
Contributor

All ci is green. well done

@prodbycorne
prodbycorne merged commit 9234686 into SmartDropLabs:main Jul 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

deliveryRepository.popDueRetries() is not atomic — horizontally-scaled workers can double-process the same retry

2 participants