Original delivery flow (problem context)
- A notification request arrives and we persist the notification record in the database with a unique notification ID and
statusset topending. - We enqueue a small ticket (message) into the relevant queue for that notification’s channel. We maintain one queue per channel — in our example we use four queues so a slow channel cannot block others.
- A free worker picks up the task from the queue.
- The worker checks a shared store (Redis) to see whether the idempotency key for this notification ID already exists. If it does, the worker drops the ticket to avoid duplicate delivery.
- If the idempotency key is not present, the worker writes the key into Redis (with an expiry/TTL) and then calls the external provider to deliver the notification.
- If the provider call fails, the worker retries using exponential backoff. If the notification keeps failing after several attempts, the worker moves the notification to a dead-letter queue for manual investigation by a support engineer.
- If the provider call succeeds, the worker updates the notification
statusfrompendingtosentin the database. - Finally, the worker acknowledges the message to the queue so the queue removes the ticket permanently.
The idempotency key must be set before calling the provider so concurrent workers don’t result in duplicate deliveries. Give the key a reasonable TTL to allow retries and to expire after the delivery window has passed.
The remaining reliability gap: the “two-writes” problem
There is one remaining reliability problem in the current flow: the notification server performs two separate writes.- First write: persist the full notification record in the database.
- Second write: enqueue the ticket into the queue.
Simple summary table
Solution: use the Outbox pattern
We fix the two-writes race by making the database the single source of truth and deriving enqueues from DB state. Corrected approach:- When a notification request arrives, only store the notification in the database with
status = 'pending'. Do not enqueue synchronously. - Run a small background job (an outbox processor) that periodically scans the database for notifications with
status = 'pending'and enqueues them into the appropriate queue(s). - Workers pick up messages from the queues as before. If the background job inserts the same notification into the queue more than once, this is harmless because the worker-side idempotency checks will prevent duplicate sends.
Implementation guidance and operational considerations
-
Indexing & efficiency
- Index notifications by
statusandcreated_atso the outbox processor can efficiently find pending items. - Batch enqueues where possible to reduce load on the queue and improve throughput.
- Index notifications by
-
Outbox processor reliability
- Give the outbox scan job a retry/backoff policy for transient queue errors.
- Implement leader election (if running multiple outbox scanners) to avoid thundering herd, or partition scanning ranges.
-
Idempotency and TTL
- Keep the idempotency TTL long enough to cover retries, possible redeliveries, and delays introduced by the outbox processor and the queue.
- Use the notification ID as the idempotency key to ensure uniqueness across workers.
-
Error handling and observability
- Move repeatedly failing deliveries to a dead-letter queue for manual inspection.
- Emit metrics: enqueue rate, delivery successes/failures, retries, dead-letter counts, outbox backlog size.
- Alert on a growing outbox backlog or rising dead-letter queue volume.
Make sure the idempotency key is set in the shared store before calling the external provider. If you set it too late, concurrent delivery attempts can slip through and cause duplicate notifications.
Example operational patterns
- Backoff policy for worker retries: exponential backoff with jitter and a max attempts threshold before sending the message to the dead-letter queue.
- Outbox scanning interval: tune based on traffic and required delivery latency (e.g., scan every few seconds for high-throughput systems, or every minute where slight delay is acceptable).
- Partitioning: for very large volumes, partition the outbox scan by notification channel or a hash range to allow concurrent, non-overlapping outbox workers.
Links and references
- Outbox pattern overview — Martin Fowler
- Redis documentation — key expiry and best practices
- Designing reliable message-driven systems — CQRS, Outbox, and idempotency patterns