billing-worker is a single Node process hosting a set of BullMQ workers and repeatable jobs, all over the shared Redis.
The queue topology
One pattern, everywhere
Every async concern in Duro follows the same scan → enqueue → process shape. Learn it once and you understand the billing engine, the recovery engine, and webhook delivery: Why this shape, every time:- The scan is cheap and short. It selects ids and enqueues. It never holds a transaction open while charging a gateway.
- The work is isolated and retryable. Each item is its own job — it can fail, back off, and retry without affecting its neighbours. BullMQ’s
attempts+ exponential backoff handle transient failures for free. jobIddeduplicates. If a scan fires before the previous item finished, the duplicate collapses. Idempotency at the queue layer.- Both modes, always. A scanner has no “current mode,” so it sweeps
testandlive. This is the only place that loops both — request-scoped code is always single-mode.
The scanners
| Scanner | Interval | Finds | Enqueues |
|---|---|---|---|
| billing | 60s | active/trialing subs with currentPeriodEnd ≤ now | renew |
| reminder | 60m | active subs renewing within 60 days | remind |
| dunning | 60s | scheduled schedules with nextAttemptAt ≤ now | process |
| webhook | 15s | undispatched events + due deliveries | deliver |
Email as a queue, not an await
Sending email is IO that can be slow and flaky, so it’s never awaited in a request. Producers enqueue anEmailJobData and return; the dedicated email worker drains the queue and talks to SMTP.
The welcome/KYC emails (core-api), receipts and dunning reminders (worker), and recovery notices all flow through this one queue. See the email system →
Graceful shutdown
OnSIGTERM/SIGINT the worker closes the HTTP health server, then awaits every BullMQ worker and queue close, disconnects Redis and Postgres, and exits — with a 25-second hard backstop so a hung connection can’t wedge a deploy.
Next: the email & template system.