The delivery guarantee is a table, not a websocket
6 min read
DevPilot dispatches Linear issues to coding agents running on machines other people own. When someone assigns an issue, a webhook arrives, we work out which machine should take it, and that machine has to receive the work — possibly minutes later, possibly after a laptop lid has been closed and reopened on a train.
The first version used Google Pub/Sub. It was replaced with a Postgres table. This is the argument for why, which is narrower and less interesting than "Postgres is enough" and more useful.
The bug is in the gap between two writes
The dispatch path has to do two things: record that a session exists, and deliver the work to a machine. With a message broker, that is two systems, so it is two writes:
await db.insert(dispatchSessions).values(session) // 1. commit
await pubsub.topic('dispatch').publish(payload) // 2. publish
Between line one and line two there is a window. If the process dies there — a deploy, an OOM, a serverless instance reclaimed mid-invocation — the session row is committed and nothing was ever dispatched. The issue shows as accepted in Linear. No machine has heard about it. Nothing retries, because as far as every component is concerned, everything succeeded.
Swapping the order does not fix it, it moves it: publish first and a crash before the commit dispatches work referring to a session that does not exist.
This is the standard reason people reach for the transactional outbox pattern, and the outbox is a good pattern. But notice what the outbox actually is. It is a table you write to inside the transaction, which a separate process later reads and forwards to the broker. The durability is coming from the table. The broker is downstream of the thing that makes it correct.
So the question worth asking is whether the broker is carrying its weight at all.
Making the window unrepresentable
For our shape of problem it was not. What replaced it is a dispatch_queue
table and a function signature that refuses to be called wrongly:
export async function enqueueDispatch(
tx: Tx,
params: {
orgId: string
orchestratorId: string
sessionId: string
message: Omit<TaskDispatchMessage, 'messageId' | 'queueId'>
},
): Promise<{ queueId: string; messageId: string }>
The first parameter is a transaction handle, and it is required. Not an optional argument that defaults to the pooled connection, not a documented convention — a parameter you cannot omit and cannot satisfy with anything other than an open transaction.
That is the entire trick, and it is worth being precise about what it buys.
Taking tx as a parameter does not make the code more careful. It makes the
broken version unrepresentable: there is no way to write "enqueue outside
the transaction that created the session", because there is nothing to pass.
The session row and the queue row commit together or neither does.
A comment saying always call this inside a transaction would have been free, and it would have been obeyed until the first time somebody was in a hurry. The compiler does not get in a hurry.
Then Realtime became optional
Having done this, we noticed the second thing.
Once the table is the record of what has been dispatched, the transport is only answering a different question: should a machine go and look now, or in thirty seconds? That is a latency question. It is not a correctness question.
Supabase Realtime pushes a notification when a row is inserted, so an orchestrator picks work up in under a second instead of on its next poll. It is worth having. It is also entirely removable:
--transport poll
That flag is fully correct. Not degraded, not a fallback with caveats — the
same guarantees, more latency. Which is fortunate, because SUPABASE_JWT_SECRET
is a dashboard-only value we have not yet set, so every deployment has in fact
been running on polling since the day it shipped. The system does not care. The
comment on the poll route says so out loud, because a reader coming to that file
cold will otherwise assume it is the sad path:
The fallback transport AND the reconnect sweep. Deliberately a first-class route, not an afterthought: it works with Realtime disabled entirely and with
SUPABASE_JWT_SECRETunset, because the delivery guarantee lives in the queue table rather than in the socket. Removing Realtime makes the system slower, not incorrect — and this is what proves it.
There is a general shape here that took us a while to see. If your transport is load-bearing for correctness, you have two systems that must agree, and you will spend the next year discovering the ways they disagree. If your transport is load-bearing only for latency, you can turn it off on a Friday to see what happens.
What it costs
It is not free, and pretending otherwise would be the kind of claim this blog tries not to make.
You give up fan-out. One row belongs to one orchestrator. Broadcasting the same work to many consumers means writing many rows, and if we ever need real fan-out this design will be in the way.
You give up the broker's dead-lettering, so we wrote it: attempts are
counted on the row, backoff is min(n² × 60s, 15m), and past the attempt limit
the row is dropped and the session is failed. Perhaps forty lines. The broker
version was free but arrived with a second dashboard.
You inherit polling load. Every machine asking every few seconds is real
database traffic. At our scale it is noise. At a scale where it is not, the
answer is LISTEN/NOTIFY or bringing the broker back for wake-ups only —
which, note, you can do at any point, because the correctness argument never
depended on the transport.
Claim contention is now your problem. Two machines can race the same row, and the resolution has to be exactly-once or you dispatch the same issue twice. That one is short enough to be interesting on its own, and it is the next post.
The rule that generalises
Every architectural decision on this system now gets asked one question:
Is this component load-bearing for correctness, or for latency?
Realtime turned out to be the second, so it became an optimisation we can lose without a migration. Pub/Sub was being asked to be the first, and it was bad at it — not because it is a bad broker, but because it sits outside the transaction where the truth is decided.
The database is already in the transaction. Very often it is the only thing that is.
