# A queue

2026-08-09. Convoro can now defer work to something that is not a visitor's
request.

---

## Why it did not exist, and why it does now

The standing rule has been in place since the beginning: **nothing reaches
another server inside a visitor's request**, with a few named exceptions where
the outbound call *is* what the visitor asked for — starting a checkout,
answering a search, asking the assistant.

There was no queue to obey it with. Anything slow either went in the request
anyway, or became a cron command of its own — `purchases:sweep`,
`privacy:prune`, `search:reindex`, `mail:send`. That is four commands for four
jobs, and each one is a cron line an operator has to know about.

The AI Assistant is what made a real queue unavoidable. It has to answer a
reply in a topic, which takes tens of seconds against somebody else's API, and
that cannot be tens of seconds of the member's page load. Nor can it be a
fifth bespoke command.

🚨 The exception the assistant currently claims — an outbound model call inside
a request, at `/assistant` — should shrink to just that page. The topic
responder must not use it.

## The shape, and where it came from

`mail_outbox` has been doing exactly this for one job type since 5 August and
got the hard parts right, so this is deliberately the same shape rather than a
new idea: a state machine, a claim, counted attempts, and the last error kept.

- **A database table, not Redis and not a daemon.** Convoro installs by
  uploading a zip onto shared hosting. A queue that needs another service
  running is a queue most sites would not have.
- **`queue:work` does one pass and exits**, driven by a minutely cron. A
  long-running worker is not something a shared host will reliably allow.
- **A handler key, not a serialised class.** A serialised class belonging to an
  extension that has since been uninstalled is an unrunnable row nobody can
  even read.

## The three details that are load-bearing

🚨 **Claiming is a conditional UPDATE, not a SELECT then an UPDATE.** Two
overlapping cron runs otherwise both see the same pending row and both run it.
For mail that is a duplicate email. For the assistant it is the same answer
posted twice into a real topic, and billed twice. `WHERE state = 'pending'` on
the update is what makes the loser's affected-row count zero.

🚨 **A job whose handler is missing is put back, and pushed out of the way.**
Missing means the module is disabled or mid-deploy — temporary — so counting it
as an attempt would let three ordinary deploys silently destroy real work. But
putting it back as due-now is worse: the same pass reclaims it immediately, and
one unhandleable job ate all 25 slots of every run while looking, from outside,
like a queue working hard. *(Found by a test, not by reasoning. It would have
shipped.)*

🚨 **A failed push returns 0 rather than throwing.** Nobody pushes a job as the
point of their request; they push it alongside something that already
succeeded. Losing the assistant's reply is better than losing the member's post.

**Reclaiming is generous.** A worker killed mid-job leaves a row claimed
forever, so something must reclaim it — but reclaiming too eagerly runs a
merely-slow job a second time, concurrently with itself. Ten minutes is well
past the longest thing here (a model call with a 45-second read timeout) and
well short of a person noticing.

## Retention

Successes are pruned after three days, failures after thirty. A `done` row is a
receipt nobody reads; a `failed` row is the only account of something that did
not happen.

## What this does not decide

- **Whether the four existing cron commands fold into it.** They work, they are
  documented, and moving them is churn with no reward until something needs it.
- **Priorities.** Everything is FIFO by id. The moment there is a job type that
  must jump ahead of another, that is a column and a sort — not before.
- **An admin screen.** `pending()` and `failures()` exist for one, and the
  first person to want it is whoever is watching the assistant not reply.
