Back to writing

A Queue Message Can Disappear. The Review Still Has to Run.


GitHub sends Scopebase a pull request event. The webhook handler verifies it, writes a receipt to PostgreSQL, and then queues the work in BullMQ.

Now Redis falls over between those last two steps.

The webhook is recorded. The queue message is gone before it exists. If that message were the only copy of the work, Scopebase would never review the pull request.

This is why I make PostgreSQL the source of truth and treat BullMQ as a wakeup. But even that only gets the work started. If a developer pushes another commit while the review is running, the old worker must not save findings for a revision that is no longer current.

The queue is a doorbell

When GitHub sends an event, Scopebase verifies the signature over the bounded raw body, saves a unique delivery receipt, and updates the pull request's tracking record in PostgreSQL. Only after that transaction commits does it notify BullMQ.

The queue job carries an identifier like targetId. The durable record says what work is due; the job asks a worker to check it. A worker scans PostgreSQL for due work every 30 seconds, so a committed record can still be found if the notification fails.

This also makes duplicate notifications less scary. A webhook receipt has a unique identity, and a duplicate queue job cannot by itself create another model attempt or charge. The worker needs to claim eligible work through the durable state.

There is a cost: PostgreSQL and Redis do not share a transaction, so recovery logic has to bridge the gap. I chose BullMQ's queue ecosystem and accepted the need for a scan that finds work the queue missed.

An event is not the current pull request

Webhook events can repeat or arrive out of order. By the time the worker wakes, the event payload may describe an older state than the pull request on GitHub.

Scopebase reads current pull request metadata before choosing what to review. It also waits for 30 quiet seconds after accepted activity. If someone pushes three commits in a minute, there is little value in spending model budget on each intermediate version.

The data model keeps three identities separate:

  • Target: the pull request's current revision, generation, and due work.
  • Run: one exact comparison and its result and delivery state.
  • Attempt: one bounded review-engine invocation and its spending reservation.

This separation is not paperwork. The target can move, a run can stay attached to its original revision, and an attempt can finish spending money even when its result is too stale to publish.

Same head, different review

Say a pull request head stays at H1, but the base branch moves from B1 to B2. The code in the pull request hasn't changed, but the comparison has. A key based only on the PR number or head SHA could reuse the result for the wrong diff.

Scopebase identifies a run by repository, pull request number, retained lifecycle, base commit, and head commit. The source collector also keeps the resolved merge base used for the diff.

The lifecycle bounds how long deduplication applies. While the relevant accounting is retained, returning to the same revision reuses its run. After that accounting is deleted, future eligible activity can begin a new lifecycle.

Queue deduplication cannot express that identity. It only knows about jobs. Scopebase needs the revision identity for attempt limits, credit receipts, and review delivery too.

Cancellation doesn't stop a stale write

Now the worker has started analyzing B1/H1, and a new commit arrives. Scopebase advances the target generation and requests cancellation.

The cancellation signal helps, but it is not a fence. The model request may already be in flight. The process may be slow. Its lease may expire while it continues running. If the worker can write a result just because it once owned the job, stale findings can land after the pull request has moved on.

So the database checks ownership when the worker tries to mutate state. The write must match the claim ID, generation, lifecycle, current run, and a valid database lease. If any part changed, that worker no longer owns the target and the result is rejected.

Spending settlement has a different rule. If the authorized attempt owner finishes a model call after the target moved on, the system still needs to account for that spend. It can settle the attempt without saving obsolete findings.

That's the distinction I wanted: stale work cannot change the review result, but the system doesn't pretend the cost vanished.

Reliable work needs a durable identity

The queue helps Scopebase find work. PostgreSQL remembers what is due. The run identity says which comparison the work belongs to. The write-boundary check prevents an old worker from publishing after ownership changes.

The trade is more state and reconciliation. The payoff is that a lost notification can be recovered and a late worker cannot quietly overwrite the current review. For this system, that's a better place to spend complexity than trying to make a queue message carry every guarantee by itself.