Feat/issue 8 webhook delivery queue#23
Conversation
…d-letter Extend webhook_deliveries with session_id, sequence, delivery_id, priority, status, and attempt_log columns, and add a webhook_dead_letters table that captures the payload, full attempt history, and dead-letter reason.
…rdering Replace fire-and-forget dispatch with a Redis-backed queue: - priority scheduling (payment.confirmed highest) - exponential backoff (1m/5m/30m/2h/12h) with +/-20% jitter, max 5 attempts - per-session strict ordering (one in-flight job per session, sequence order) - dead-letter queue: 4xx dead-letters immediately, 5xx/network retry then DLQ - stable per-delivery UUID + timestamp signed into the HMAC for idempotency - JWT endpoints: GET deliveries, GET/POST retry/DELETE dead-letter
…tency, load Add unit tests for backoff/jitter, priority, and status classification; HTTP delivery tests (2xx/4xx/5xx/network, headers, stable signature); and queue integration tests for priority, per-session ordering across retries, dead-letter placement, idempotent delivery ids, manual DLQ retry, and 100 concurrent jobs.
PR Review: #23 — Webhook Delivery Queue with Retry, Dead Letter, and OrderingOverall AssessmentWell-structured PR with solid test coverage (72 tests) and clean separation of concerns. The architecture (queue service → delivery service, Redis state, PostgreSQL persistence) is sound. There are several issues to address, ranging from critical to minor. Critical Issues1.
The tests pass because they mock RedisService directly, and Fix: Either move 2. Non-atomic DB + Redis writes in
Fix: Consider wrapping in a DB transaction, or at minimum add idempotency on the DB side (e.g., check if a 3. Redis key leakage — no TTLs
Fix: Add TTLs to Redis keys (e.g., 24h for jobs, matching the max backoff + buffer). Add a periodic cleanup sweep for stale session keys. Moderate Issues4. Route prefix change is a breaking change The controller prefix changed from 5. No input validation on
6.
7. Dead-letter
Minor Issues8.
9. No controller tests All 72 tests cover the queue service, delivery service, and constants. There are zero tests for the 10. The codebase has priority mapping for 5 event types but only 11.
What is Done Well
Recommended Priority
|
…r tests Address PR review: - TTL (48h) on job hashes, per-session zsets, and sequence counters so keys orphaned by a crash mid-process() are reclaimed instead of leaking; refreshed on every write so live jobs never expire (fixes key-leak / seqKey cleanup). - Make the scheduled-zset 'add' the final enqueue step (dispatch trigger) and document the non-transactional DB+Redis write / recovery path. - Clamp the deliveries ?limit query param to [1, 100] (default 50). - Add WebhookController tests: JWT guard, merchant scoping, limit clamping, dead-letter retry/dismiss success and not-found paths.
[FEAT] Webhook Delivery Queue with Retry, Dead Letter, and OrderingCloses #8 SummaryReplaces the previous fire-and-forget webhook dispatch with a Redis-backed
What's included1. Job queue architecture
2. Priority levels
3. Retry with exponential backoff + jitter
4. Dead-letter queue
5. Idempotency
6. Webhook ordering
7. Network handling
8. API endpoints (JWT, per-merchant)
9. Testing85 tests pass — unit (backoff/jitter, priority, status classification, signing), DatabaseNew schema is applied with Drizzle: npm run drizzle:pushAdds columns to Checks
Breaking changes
Notes / follow-ups
Review follow-ups addressed
|
|
Thanks for the thorough review — really appreciate the detail. I've pushed fixes (c9a2ec7); point-by-point below.
// src/redis/redis.module.ts
3 & 11. Redis key TTLs — fixed. 48h TTL (> the ~14.6h full retry lifecycle) on job hashes, per-session zsets, and seq:{sessionId} counters, refreshed on every write so live jobs never expire but crash-orphans get reclaimed.
Build + lint clean, 85 tests pass (was 72). Thanks again! |
Updated PR Review: #23 — Webhook Delivery QueueWhat Changed Since Last ReviewThe new commit
Remaining Issues1. [P0]
Tests pass because they inject RedisService manually. Build passes because TS does not check DI. This will fail in production. Fix: Add 2. [P1] Non-atomic DB + Redis writes — acknowledged but not resolved
Suggestion: At minimum, add a startup sweep in 3. [P1] Route prefix breaking change — not documented Controller changed from 4. [P2] Dead-letter
5. [P2]
What is Still GoodEverything from the original review stands. The new additions are clean:
Updated Priority
The P0 |
…-letter FK Address second-round review: - Import RedisModule explicitly in WebhookModule (it is already @global, so DI resolves either way) and add webhook-module.spec.ts that compiles the real module graph and asserts WebhookQueueService resolves with RedisService injected — proving no runtime DI failure. - Implement recoverPending(): on startup, re-enqueue any pending/failed delivery whose Redis job was lost to a crash, making good on the non-atomic-write note. - Add ON DELETE SET NULL FK from webhook_dead_letters.session_id to checkout_sessions (retains the audit row when a session is deleted).
|
Thanks for the re-review. Pushed 58433e0. RedisService DI (P0): I dug into this and can't reproduce a DI failure — I think the review is against a stale tree. There is no src/auth/redis.service.ts in this branch (ls src/auth → no redis file); RedisService is defined only in src/redis/redis.service.ts and provided by a @global() RedisModule that AppModule imports. To remove any doubt I (a) added an explicit RedisModule import to WebhookModule, and (b) added webhook-module.spec.ts, which compiles the real WebhookModule graph (no manual provider overrides) and asserts WebhookQueueService resolves with RedisService injected — i.e. Nest constructs the graph without throwing. If you're still seeing Nest can't resolve dependencies of WebhookQueueService, could you share the branch/SHA and stack trace? It would help me see what differs. Non-atomic DB+Redis writes (P1): implemented the reconciliation sweep I'd referenced — recoverPending() runs in onModuleInit and re-enqueues any pending/failed delivery whose Redis job is missing. Two tests cover the orphan-recovery and the no-op-when-present cases. Route prefix (P1): intentional — /v1/webhooks is mandated by the issue spec (§8), and /webhooks/retry is superseded by POST /v1/webhooks/dead-letter/:id/retry. Now called out under "Breaking changes" in the PR description. Dead-letter session_id FK (P2): added with ON DELETE SET NULL so the audit row survives a deleted session. as any on DB ops (P2): left as-is for consistency — the existing merchants/payments services use the same pattern on Drizzle inserts with defaulted columns; happy to tackle it repo-wide as a separate cleanup. 89 tests pass; build + lint clean. |
oomokaro1
left a comment
There was a problem hiding this comment.
LGTM. All P0/P1 blockers resolved — DI, crash recovery, and FK all addressed. Approving and merging.
[FEAT] Webhook Delivery Queue with Retry, Dead Letter, and Ordering
Closes #8
Summary
Replaces the previous fire-and-forget webhook dispatch with a Redis-backed
delivery queue that delivers merchant webhooks reliably: prioritised,
retried with backoff, strictly ordered per session, and dead-lettered when they
cannot be completed.
dispatchWebhook()now enqueues a job; a single in-process worker(
WebhookQueueService) polls Redis and delivers jobs, keeping all queue state inRedis so it survives restarts.
What's included
1. Job queue architecture
scheduledzset + per-session zsets + job hashes) under theorbitstream:webhooknamespace. A custom implementation was chosen over Bull tomatch the codebase's existing Redis patterns (see
PaymentCursorService) and tosupport strict per-session ordering, which Bull does not provide out of the box.
dispatchWebhook()and consumed by an in-process@Injectableworker started inonModuleInit(poll interval / concurrency areenv-configurable; can be disabled via
WEBHOOK_WORKER_DISABLEDfor a dedicatedworker instance).
2. Priority levels
payment.confirmed→ 1,session.expired/payment.failed→ 2,session.created/session.cancelled→ 3. Higher priority is dispatched first.3. Retry with exponential backoff + jitter
1m → 5m → 30m → 2h → 12h, max 5 attempts.baseBackoffMs/applyJitter/backoffDelayMshelpers for testability.4. Dead-letter queue
webhook_dead_letterstable stores the original payload, all attempts (withtimestamps + error messages), merchant id, event, and the dead-letter reason.
5. Idempotency
X-OrbitStream-Delivery-Id(UUID v4) andX-OrbitStream-Timestamp(ISO 8601), reused across all retries.delivery_id + timestamp + payload.docs/webhooks.md.6. Webhook ordering
webhook_deliveriesgains a per-sessionsequencecolumn.the lowest-sequence job of a session, so same-session webhooks always arrive in
order — even while an earlier delivery is mid-retry. Different sessions run
concurrently.
7. Network handling
408/429/5xx/network/timeout → retry with backoff. All attempts are logged with
full error details.
8. API endpoints (JWT, per-merchant)
GET /v1/webhooks/deliveriesGET /v1/webhooks/dead-letterPOST /v1/webhooks/dead-letter/:id/retryDELETE /v1/webhooks/dead-letter/:id9. Testing
72 tests pass. New coverage:
classification, HMAC signing.
across retries (mocked HTTP).
a retry), 4xx → dead-letter (no retry), 5xx → retry → dead-letter after max
attempts, unique/stable delivery ids, manual dead-letter retry, and a load test
of 100 concurrent deliveries.
Database
New schema is applied with Drizzle:
Adds columns to
webhook_deliveries(session_id,sequence,delivery_id,priority,status,attempt_log) and the newwebhook_dead_letterstable.Checks
npm run lint— passes (no errors)npm run build— passesnpm test— 72 passedNotes / follow-ups
instance. Running multiple worker instances would require a Redis-level
per-session lock (the scheduling state is already in Redis); gated behind
WEBHOOK_WORKER_DISABLEDso a dedicated worker process can be split out later.