The GenServer is a cache, Postgres is the queue — why my agent queue survives deploys

I run a kanban of AI coding agents, and one afternoon the board showed three tasks running while the profile page on the same screen said two running and one queued. Both were correct: one card was waiting in line, and nothing in the interface said so. That UI bug turned out to sit on top of a design decision I think is worth sharing.

An agent run costs tokens and a container with a real toolchain, so concurrency has to be capped. The obvious way is to refuse work when you are full and let every caller retry. I did not want that: the pool always accepts a dispatch and makes the session wait its turn. But a queue that never rejects has to live somewhere — and a GenServer holding a :queue in its state is gone the moment you deploy. I deploy several times a week, so every deploy would silently drop every queued session. Not crash, just drop.

So the queue lives in Postgres, as Session rows with status: :queued, and the GenServer state is a fast index over those rows, rebuilt on boot by a reconciler. The state still does real work — a per-user :queue map for O(1) pops, a MapSet of active sessions for cap checks, waiter pids, monitor refs — but none of it is information you could not reconstruct. Delete the whole struct, replay the rows, and you are back where you were.

The one OTP feature I actually rely on: every waiter is monitored on enqueue, and handle_info({:DOWN, ...}) releases its slot and dispatches the next in line. Inside the VM, “notice when the thing holding a resource dies” is four lines, and they are exactly right.

Two rules I took out of it: if it must survive a restart, it is a row — kill the process and ask what was lost. And a queue that never rejects has to be visible, or your users think the app is broken while it works exactly as designed.

Full write-up with the code:
https://t0ha.dev/en/elixir/2026/10/29/genserver-is-a-cache.html

Where do you keep your queues — have you ever lost one to a deploy, or do you just refuse work when you are full?

“If it must survive a restart, it is a row” is the line I’m stealing. The visibility point matters even more with agents than with normal jobs: a queued session that looks stuck tends to get re-dispatched by an impatient user, and then you pay for the same tokens twice. (Disclosure: I’m with aicost.tools, so double-billed agent runs are my pet peeve.) Do you dedupe dispatches on the Postgres side, e.g. one queued session per task, or did the UI fix make that unnecessary?