Status: Accepted
Supersedes the Redis assumption in ADR 005; the rule that ADR states is unchanged and now applies to every transport.
#Context
Webhook delivery ran on BullMQ over Redis, and nothing else. That was never a decision about Redis so much as an inheritance: the queue arrived before the transactional outbox did.
Once the outbox existed, the queue stopped being where the obligation lived. A
webhook_events row is committed in the same transaction as the payment that
owes it; a lost job is re-created from the row. The queue became transport.
Two things then made a single hard-wired transport a cost rather than a
simplification. Redis has no first-party Windows build, so on Windows it means
WSL — a virtual machine reserving gigabytes to host a process that needs
megabytes. And PostgreSQL, which is already mandatory, had by then grown most of
a queue inside the outbox: FOR UPDATE SKIP LOCKED claiming, batching, and a
staleness window that is a lease under another name.
#Decision
The worker takes its transport from WEBHOOK_QUEUE_DRIVER, behind a port with
three methods — enqueue, consume, close — plus two declarations: which
driver it is, and whether the outbox dispatcher has to feed it.
| Driver | Holds jobs in | Needs running |
|---|---|---|
postgres |
webhook_events itself |
nothing further |
mongo |
a webhook_jobs document |
mongod, standalone |
bullmq |
Redis | Redis |
postgres is the default, so a clone delivers webhooks with nothing installed
beyond the database the API already required. docker-compose names bullmq
explicitly, because the composed stack is what shows the shape this has in
production and a silent default would misrepresent it.
Three rules bind every driver:
- No payment truth in a driver. It holds an event id, a try count and a
time. The row is the commitment and
webhook_attemptsis the history; losing a driver's store entirely costs a delay, never a fact. - One retry policy, declared in
RETRYand applied by all three — BullMQ internally, the other two by hand. - One contract suite, run against each available driver. Without it these are not alternatives, only three things that resemble each other; the assertion that carries the most weight is that two consumers never receive the same job.
#Consequences
The worker starts on a machine with nothing but PostgreSQL, which is the common case for local development on Windows.
The retry policy is now expressed twice in effect — once as BullMQ options, once as arithmetic in the two hand-written drivers — and only the contract suite keeps them equal. That is the price of the seam and it is paid deliberately.
mongo polls rather than watching a change stream, because change streams
require a replica set and requiring rs.initiate() before anyone can develop is
a worse tax than a query every fifth of a second. Nothing it does needs a
multi-document transaction: a claim is one findOneAndUpdate on one document.
A third store is a third thing to keep working. If Mongo stops earning its place, the driver should be deleted rather than left to rot.