ADR-022 — One consumer per queue; recover at startup, give back on shutdown
Status: Accepted · Date: 2026-10-07 · Review: 2026-10 #15, backend G4, N2, N3, N10 · Related: ADR-023 (single-instance rule), review #21 (pollers on the owner’s Claude login), #25 (two SEO engines) · Implement after: the Play production launch (~2026-10-16), except the live bug below
Context
Every queue in TextStack is a Postgres table that one process polls. That design stays. The problem is
that each queue claims, recovers and gives up in its own way. Evidence is against main @ d18f2244.
Since the review: the catalog retry cap is fixed (#706/#707), the podcast queue is deleted (#692), and a
hung SSG render now ends Failed (#757).
| # | Queue (table) | Consumer | Claim | Stale recovery | Attempts cap | Claim test |
|---|---|---|---|---|---|---|
| 1 | ingestion_jobs |
Worker IngestionWorker, 5 s (Worker/Services/IngestionWorker.cs:37) |
read, then save: pick Application/Ingestion/IngestionService.cs:68-73, mark :87-92 |
Processing older than 10 min is picked again (:38) |
3, then Failed (:39, :50-63) |
IngestionRetryCapTests (6, fake DbSet) |
| 2 | user_ingestion_jobs |
same loop, after #1 (IngestionWorker.cs:48) |
read, then save: Worker/Services/UserIngestionService.cs:82-87, :121-126 |
Processing older than 2 min (:47) |
3 (:49) |
none |
| 3 | user_books.metadata_enrichment_* |
Worker MetadataEnrichmentWorker, 30 s, plus an inline Task.Run (UserIngestionService.cs:334) |
atomic conditional UPDATE Pending→Running (UserBookEnrichmentService.cs:27-32) |
Running older than 10 min → Pending (MetadataEnrichmentWorker.cs:80-89) |
none | gate only (MetadataWorkerGateTests) |
| 4 | ssg_rebuild_jobs |
Node ssg-worker.mjs, 5 s |
no claim. The API sets Running at enqueue (SsgRebuildService.cs:247-248, :125-134); the worker picks status = 'Running' (ssg-worker.mjs:84-95). “Running” means both “ready” and “in progress” |
a restart re-runs the job; deploy.yml:177 notes “nothing recovers a job left ‘Running’” |
none | ssgJob.test.mjs (limits only) |
| 5 | auto_publish_jobs |
host seo-publish-poll.sh, 60 s |
SELECT … LIMIT 1 (:299), later UPDATE status=1 (:177) |
none. A reboot leaves the row RUNNING; auto_create_job then skips that edition forever (:142-143) |
5 failures / 24 h per edition (:144-147) |
none |
| 6 | seo_backfill_jobs |
host seo-backfill-poll.sh → POST /internal/seo/jobs/claim; and the admin crew path (AdminSeoBackfillEndpoints.cs:513-531, MarkRunningAsync) |
UPDATE … FOR UPDATE SKIP LOCKED RETURNING (SeoJobProcessor.cs:49-60), up to N per call |
none. If the poller dies, every claimed row stays Running |
admin retry only | none |
| 7 | book_quality_jobs |
host quality-poll.sh |
SELECT … LIMIT 1 (:547), then PUT status=1 over /internal (:334) |
none | none | none |
Not queues: pending_vocabulary_words (a backlog drained by the hourly reconciler, guarded by a unique
index), eval_runs (scheduled, OFF), drift_centroids (deleted by ADR-023).
A live bug: every deploy fails the upload in flight
Every push to main restarts the Worker. The host cancels stoppingToken, the extraction throws
OperationCanceledException, and the generic catch (Exception) records it as a parse failure:
- uploads:
UserIngestionService.cs:393-405marks the job and the bookFailedwith “Could not read this file. It may be corrupted or password-protected.” The reader is told their file is broken. - catalog:
Worker/Services/IngestionService.cs:305-338marks the jobFailed(parse_error).
The stale re-pick never sees these rows, because they are no longer Processing. This is the first fix.
Two consumers are not safe
There is no fencing anywhere: a consumer that lost its claim (or thinks it did) keeps writing. That is acceptable only because each queue has exactly one consumer. These jobs must not run twice at once:
- book quality rewrite — rewrites, merges and deletes chapters (
/internal/*/chapters,InternalEndpoints.cs:36-51); two runs interleave edits on the same chapters; - ingestion — deletes the old asset files after the save (
UserIngestionService.cs:197,:317-321); a second run deletes assets the first one just wrote; - enrichment — a paid OpenAI agent run per book; twice = double spend;
- auto-publish — publishes and pings IndexNow; twice = duplicate SSG jobs and IndexNow submissions;
- SEO apply — writes the
BeforeSnapshotfor revert; a second run snapshots the first run’s output, so revert restores AI text instead of the original.
That list is why the rule below is “one consumer”, not “any number of consumers with clever claims”.
Decision
The rule
Each queue has exactly one sequential consumer (ADR-023: one Worker, one ssg-worker, one of each host poller). On top of that:
- Startup recovery. When a consumer starts, nothing of its queue can be in flight, so one
UPDATEends whatever is: in-flight →Failedwitherror = 'interrupted (consumer restarted)'. Ingestion and enrichment already recover crashed rows (stale re-pick under a cap) and keep that. - Graceful shutdown gives the claim back.
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)→ statusQueued, attempt not counted, no error text. A shutdown is neverFailedand never “corrupted”. Failedis terminal. Only an admin retry brings it back.- One test per consumer for its startup
UPDATEand its cancellation path.
No leases, no generic queue helper, no shared claim endpoint: with one consumer, “in flight at my startup” already means “orphaned”.
Per queue
| Queue | Change |
|---|---|
ingestion_jobs, user_ingestion_jobs |
Cancellation catch before the generic one → Queued, AttemptCount-- (the claim counted it). The stale re-pick under the cap stays as the crash recovery; no startup UPDATE needed. PR 1, can ship before the launch. Shipped 2026-10-07: the give-back is one set-based UPDATE on a fresh context matching the row as this run claimed it (Processing at its attempt), so a never-committed claim never un-counts a crash and the half-built book in the change tracker is not flushed; the UserBook stays Processing. Plus ThrowIfCancellationRequested after extraction, because the PDF extractor returns a truncated result on cancel instead of throwing. |
| enrichment | Same cancellation catch (→ Pending). The stale reclaim stays. No new column. Second consumer today (code review): ingestion’s inline kick _ = Task.Run(() => _enrichmentService.EnrichAsync(bookId, CancellationToken.None)) (UserIngestionService.cs:334) runs beside MetadataEnrichmentWorker, and with CancellationToken.None the cancellation catch can never fire there, so a deploy leaves that row Running until the 10-min stale reclaim. Delete the kick: the book is Pending and the worker’s next tick takes it (≤ its poll interval of extra latency). Then the rule holds and the catch covers every run. Shipped 2026-10-07 (PR 1), kick deleted. |
ssg_rebuild_jobs |
Enqueue leaves the row Queued. ssg-worker claims Queued itself: UPDATE … SET status='Running', started_at=now() WHERE id = (SELECT … WHERE status='Queued' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING …. At startup it fails Running rows. Delete StartJobAsync, the admin Start endpoint and its button (create = queued). deploy.yml:184 and rebuild-ssg.sh:75 already count Queued + Running as pending. Shipped 2026-10-08: the claim as written (claimNextJob, CTE so it also returns the site). Recovery runs before every claim, not only at startup (nextJob = failInterruptedJobs + claimNextJob): the worker is sequential, so between jobs any Running row is dead — including one left while the worker stayed up (the DB dropped mid-job so both status writes failed, or a claim committed but its reply was lost), which a startup-only sweep would have left blocking every enqueue until the next deploy. Error: interrupted (not owned by the running ssg-worker). Only Full jobs remain (ADR-023 removed per-edit Specific jobs); a Queued or Running Full blocks a new one from /internal/ssg/rebuild-all, and rebuild-ssg.sh follows a Queued one or waits out a Running one |
auto_publish_jobs |
One startup UPDATE in seo-publish-poll.sh: in-flight (1, 2, 4) → Failed. Frees stranded editions for the 5/24 h breaker. Keep ORDER BY priority DESC, created_at |
seo_backfill_jobs |
Before each claim, Running older than 1 h → Failed. Not a startup UPDATE, because there is a second consumer: the crew path. Fix that path to insert its row as Running (no Queued → MarkRunningAsync window the poller can claim) |
book_quality_jobs |
Owner decision: delete (recommended). If kept: one startup UPDATE in quality-poll.sh |
SSG has a second crash loop
SsgPeriodicRebuildWorker checks every 5 min and queues a Full rebuild when none completed inside the
interval. A Full job that keeps failing is therefore queued again every 5 min, forever. ADR-023 deletes
that worker; backup.yml stays the one nightly trigger (removed 2026-10-27, #992: SSG is frozen, rebuilds are manual).
Alternatives
| Option | Verdict | Why |
|---|---|---|
| Do nothing | rejected | Every deploy can tell a reader their book is corrupted; reboots strand host-queue rows. |
Multi-consumer-safe claims (first draft: one SKIP LOCKED helper, leases, a shared /internal/jobs/{queue}/claim) |
rejected by the consilium | Solves a second consumer we do not run, and without fencing it would still not make the jobs above safe. More code, same safety. |
| Leases / heartbeats | rejected | With one consumer, “in flight at startup” is exact; a lease is a guess. |
| A job library (Hangfire, Quartz, TickerQ) | rejected | New dependency for the C# half only. |
| Patch each queue differently | rejected | That is how we got four styles. |
Consequences
- A deploy no longer fails uploads. A job interrupted by a deploy simply runs again.
- A crash (not a graceful stop) leaves a job
Failedat the next start, with a clear error; the admin retries. Ingestion keeps retrying under its cap. - A second consumer stays unsafe, by design. ADR-023 writes that down.
Migration plan (PR-sized)
- Live bug, before the launch if possible: cancellation catch in both ingestion services and the
enrichment service. Tests: cancelled job →
Queued, attempt not counted,UserBooknotFailed. - Host pollers: startup
UPDATEinseo-publish-poll.sh(andquality-poll.shif kept); 1 h sweep in the SEO backfill claim; crew path inserts asRunning. - SSG: enqueue leaves
Queued;ssg-workerclaims with SKIP LOCKED and failsRunningbefore each claim; deleteStartJobAsync+ endpoint + button. - Docs: architecture queue section, STATUS.
Owner decisions (2026-10-07)
All as recommended.
- Book quality queue: delete it (poller, systemd unit, Make targets, entity, the 12
/internalroutes, admin page, auto-queue). - Admin’s separate SSG Start step: dropped. Creating a job means it is Queued; ssg-worker claims it.