rustclamp 1.0.0

A minimal synchronous entrypoint for Clamp applications.
# ADR 0019: Queue

Status: Accepted, 2026-10-07.

## Context

A `rustclamp` web app has no way to run work after the response: resizing an
upload, calling a slow API, sending a webhook. Today a handler does it inline,
so the visitor waits and a failure becomes a 500, or the app spawns a thread
and loses the work on a restart.

Two queues exist nearby, and neither fits:

- **The worker crate** (ADRs 0020 and 0026) has `WorkerService` and a durable
  `SqliteQueue`. Both are async and run on Tokio. `web::App` is std-only, with
  blocking handlers on threads. Using them would add `rustclamp-worker`,
  `rustclamp-runtime` and `tokio` to the facade. ADR 0026's table already
  argues against those edges.
- **The mail outbox** (ADR 0015) is a background thread that delivers rows
  from one framework table. Its daily cap and once-a-minute pace are specific
  to mail.

The phase 11 plan (2026-09-30) chose a sync, std-only queue in the facade, on
the same pattern as the mail outbox.

## Decision

- **An optional `queue` feature** (`web`, `db`), with `rustclamp::queue`. It
  adds no new dependency. The Redis driver also needs the `redis` feature
  (ADR 0027).
- **Named handlers on the app.** `App` is a struct literal of function
  pointers, so the handlers are one more field:
  `jobs: || vec![("thumbnail", jobs::thumbnail)]`. A handler is
  `fn(&Job) -> Result<(), String>`; the `Job` carries the payload as a `&str`
  (the app chooses the format, usually JSON), the attempt number and the
  app's database, which most jobs need. A panic counts as a failure.
- **Dispatch from a handler.** `request.queue().dispatch(name, payload)` and
  `dispatch_later(name, payload, delay)` store the job and return a
  `Result`: when the driver can't be reached, the caller decides what to do.
  Dispatching a name that has no handler is an error at dispatch time, not a
  dead job later.
- **At-least-once delivery.** A worker reserves a job before running it. A
  reservation older than `QUEUE_RETRY_AFTER` (default 90 s) makes the job
  available again, so a job survives a crash or a kill. A job can therefore
  run twice, and handlers must be idempotent. A std thread can't be stopped,
  so a handler that runs longer than `QUEUE_RETRY_AFTER` can run alongside
  its own retry. The docs say to set the value above the slowest job.
- **Retries, then `failed_jobs`.** A failed attempt is retried after a
  backoff (10 s, 60 s, 300 s), up to `QUEUE_TRIES` attempts in total
  (default 3). After the last attempt, the job moves to the `failed_jobs`
  table in the app's database (name, payload, error, failed_at) and the
  failure is logged as an error. An attempt counts when the job is reserved, so a job
  whose worker dies every time also ends there instead of looping. `failed_jobs` lives in the database for both
  drivers, so failures survive a Redis flush. `queue:retry ID|all` moves
  failed jobs back onto the active driver with their attempts reset;
  `queue:forget ID|all` deletes them (#136).
- **Two drivers, chosen by `QUEUE_CONNECTION`** (`database` by default, or
  `redis`).
  - **database:** a framework-owned `jobs` table (id, name, payload,
    attempts, available_at, reserved_at), created by the framework's
    migrations like `mail_outbox`. Claiming is one `UPDATE ... RETURNING` on
    the oldest available row, so concurrent workers never take the same job.
    Workers poll once a second while the table is empty.
  - **redis:** built on the existing `rustclamp::redis::Redis`. A job is a
    list item, delayed jobs are in a sorted set by due time, and reserved
    jobs are in a sorted set by reservation time. One Lua script moves due
    delayed jobs and expired reservations back to the list, then pops and
    reserves the next job, so it is atomic. The Redis client is one
    connection behind a mutex, so each worker thread opens its own
    connection and polls rather than blocking on `BLPOP`. Keys start with
    `REDIS_PREFIX`. When `APP_ENV=production`, the app refuses to start the
    queue without a prefix, because the fleet shares Redis servers and two
    apps would otherwise run each other's jobs.
- **Where workers run.**
  - Outside production, `App::run` starts `QUEUE_WORKERS` threads (default 1)
    beside the HTTP server, like the mail thread.
  - In production, `QUEUE_WORKERS` defaults to 0. The same binary, started
    with the argument `queue:work`, runs only the workers. Deploys run it as
    its own service, so a slow job never takes HTTP threads and the two can
    restart separately. `queue:work` is a built-in console command
    (`cargo run -- list`, #122).
  - A worker checks a `web::Shutdown` between jobs: once stopped, it takes
    no new job and finishes the one it is running. Std has no signal hook,
    so `App::run` cannot stop it on SIGTERM; a kill cuts the job off, and
    it is retried after `QUEUE_RETRY_AFTER`.
- **Tests.** `App::test` starts no worker. A test dispatches through the app,
  then calls `app.queue(&config, db).work_once(now)` to run the next due job
  on the test thread, with a given clock, as mail tests call the delivery
  step directly.
- **Not included:**
  - several named queues and priorities: a job's name already routes it;
  - unique jobs, chains, batches and per-job rate limits;
  - a `sync` driver: tests use `work_once`;
  - a dashboard;
  - moving mail onto the queue: the outbox keeps its daily cap.

  Each one waits until an app needs it.

## Consequences

- The repository has two queues: this one for std web apps, and the worker
  crate's for async services. They share no code. A job written for one does
  not run on the other.
- The `queue` feature needs no change to `tools/boundaries.py`: it adds no
  crate and no dependency.
- Production deploys of an app that dispatches jobs need a second service
  running `queue:work`. Without it, jobs pile up in the `jobs` table or the
  Redis list and nothing runs them. `clamp doctor`, or a startup warning when
  `QUEUE_WORKERS=0` and jobs are waiting, can surface that; it is left to the
  implementation PR.
- The Redis driver gets the same CI coverage as ADR 0027: a fake RESP server
  in unit tests and a live test against the `redis:8-alpine` service.