Skip to content
Payment Emulator LabDocumentationOpen console
On this page

Architecture

ADR 011-pluggable-webhook-transport

Webhook delivery ran on BullMQ over Redis, and nothing else. That was never adecision about Redis so much as an inheritance: the queue arrived before thetransactional outbox did.

3 min read

Índice / Index · Español

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:

  1. No payment truth in a driver. It holds an event id, a try count and a time. The row is the commitment and webhook_attempts is the history; losing a driver's store entirely costs a delay, never a fact.
  2. One retry policy, declared in RETRY and applied by all three — BullMQ internally, the other two by hand.
  3. 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.

Payment Emulator Lab · RonuSoftwareMIT