ADR-023 — Api and Worker are single-instance; no fire-and-forget for real work
Status: Accepted · Date: 2026-10-07 · Review: 2026-10 #16, backend G3, G4 (fire-and-forget row), G9, N5 · Related: ADR-022 (one consumer per queue), review #29 (pgvector / Drift) · Implement after: the Play production launch (~2026-10-16)
Context
The Api serves requests and also runs 9 hosted services: 8 in
Api/Extensions/ServiceCollectionExtensions.HostedServices.cs:18-37 plus EdgeTtsService
(ServiceCollectionExtensions.Content.cs:75). Request code also starts work it does not wait for. The
Worker has its own 8 (Worker/Program.cs:71-103). Every push to main restarts both.
Api hosted services
| Service | Cadence | Work | Note |
|---|---|---|---|
EdgeTtsService |
hourly timer (EdgeTtsService.cs:26,50) |
sweeps the Api’s own TTS disk cache | fine where it is |
SsgPeriodicRebuildWorker |
5 min check | queues a Full SSG job if none completed in N h (default 24) | a second scheduler for the nightly rebuild backup.yml:122-126 already queues, and a crash loop: a Full job that keeps failing is queued again every 5 min |
AutoRetireSweeperWorker |
6 h | retires mastered words | timer restarts with the Api |
DailyCapReconcilerWorker |
1 h | promotes pending words into SRS | promoted words are never enriched (below) |
WordFrequencyLoaderWorker |
once | seeded 20k word_frequencies rows; removed 2026-10-27, the filter reads the file (#935) |
idempotent |
ClusterCandidateBuilderWorker |
24 h | Ollama groups recent words | waits 10 min, runs, waits 24 h, so it runs ~10 min after every deploy |
ConceptClusteringWorker |
7 d | full replace of concept clusters | same: runs after every deploy, not weekly |
ContinuousEvalWorker |
1 h check, OFF | scheduled judge evals | a session advisory lock (pg_try_advisory_lock, ContinuousEvalWorker.cs:221,235) + DB last-run |
DriftDetectionWorker |
1 h check, OFF | embedding drift | dead in prod |
Un-awaited SSG enqueues — 8 sites, one bug
EnqueueSsgRebuildAsync is two small DB writes. Eight request paths start it and do not wait:
Task.Runin 5 endpoints:AdminEndpoints.cs:438(cover upload),:513(import),:579(Standard Ebooks sync),AdminGenresEndpoints.cs:298,AdminAuthorsEndpoints.cs:346;_ = EnqueueSsgSafe(...)inAdminService.Editions.cs:268-270(edition update),:322(publish — also the auto-publish path through/internal/editions/{id}/publish) and:343(unpublish).
All of them use the request’s scoped ISsgJobService (Application/DependencyInjection.cs:270) and its
DbContext after the request may have ended, and swallow errors in an empty catch
(AdminService.cs:109-125 and each Task.Run). So the rebuild after an admin edit or a publish can
silently not happen. Worse, EnqueueSsgRebuildAsync saves the job Queued and then flips it Running
in a second save (SsgRebuildService.cs:247-248). If the context dies between the two, the row stays
Queued forever, and the duplicate check (:228-245) then refuses every later enqueue of that mode and
those slugs — for a Full job, rebuild-all answers “skipped” every night. Prod check:
select mode, status, count(*) from ssg_rebuild_jobs where status = 'Queued' group by 1, 2;Vocabulary enrichment
QueueEnrichment (VocabularyEndpoints.cs:342-400) is a Task.Run that embeds the word and asks Ollama
for distractors, hint and explanation. It is called from three endpoint paths (:273, .Pending.cs:84,
.Lookups.cs:137), but not from DailyCapService.ReconcileUserAsync (:129-148), so words promoted
by the hourly reconciler never get any of it. A deploy during the call also loses it. The review falls
back to random distractors either way.
Other fire-and-forget: admin “run evals now” (AdminAiQualityEndpoints.Evals.cs:61), the budget alert
email (RollingSpendTracker.cs:188), trace rows (TracingDecorator.cs:125), shadow calls
(ModelGateway.cs:295; removed 2026-10-27, #934), tag suggestions in the Worker (UserIngestionService.cs:339).
Instances. docker-compose.yml gives api, worker and ssg-worker a fixed container_name
(:82, :165, :295), so --scale cannot run, and each host poller is one systemd unit. Nothing says
this is a rule, yet a lot depends on it: per-process rate limits, the IMemoryCache guest debounce and
site cache, the static _evalRunning flag, timers without locks, and every queue in ADR-022.
Small doc drift: Worker/Program.cs:84 says guest cleanup runs “every 6h”; the code runs every 2 h
(GuestCleanupWorker.cs:15), as CLAUDE.md says. The comment is wrong.
Decision
1. Single-instance is the rule
One Api, one Worker, one ssg-worker, one of each host poller. It is the real constraint of one home
server. Write it in docker-compose.yml (a comment by each container_name) and in
docs/architecture/index.md, with what a second instance would need:
| Second… | Needs |
|---|---|
| Api | rate limits in nginx or a shared store (or accept N× limits); the eval flag in the DB; timers moved out or locked |
| Worker | ADR-022 says no: queues have no fencing. It would need fenced claims first |
| ssg-worker | separate output directories; do not |
2. Real work is awaited or durable, never fire-and-forget
Work a user would miss if it were lost is either awaited in the request or a durable row. Fire-and-forget is allowed only for best-effort telemetry and alerts (traces, shadow calls, budget email) and for admin-started runs the admin can see and repeat (run evals now).
3. Per item
| Item | Decision |
|---|---|
| SSG enqueue, 8 sites | Await it. Keep a try/catch that logs the error (never empty), so a committed publish or edit never turns into a 500. Shipped 2026-10-08 differently — removed, not awaited (owner, option C). Prod had zero Specific jobs ever, so these sites never queued anything; and ssg-worker ignores mode and slugs, so each awaited enqueue would have been a ~20-min full render plus ~2000 IndexNow URLs per edit, with deploys waiting on it. All 8 sites, the SEO-backfill applies and the featured-shelf replace now queue nothing, and Specific mode is gone. The nightly Full rebuild is the only automatic trigger; the admin button and make rebuild-ssg are the on-demand path |
SsgPeriodicRebuildWorker |
Delete, with the two ssg.periodicRebuild* settings and their admin form. backup.yml is the one nightly trigger. Shipped 2026-10-08 (the migration deletes the two rows) |
DriftDetectionWorker |
Delete, with drift_centroids (migration) and its admin tab (review #29). Shipped 2026-10-08; the scheduled-eval trend that shared the tab moved to the Evals tab |
MetadataBackfillWorker (Worker) |
Delete — a one-shot heal for an old env-var bug that still calls Ollama on every Worker start. Shipped 2026-10-08 |
| Reconciler gap | Owner decision. Not a one-liner (code review): QueueEnrichment is a private static in Api/Endpoints/VocabularyEndpoints.cs:342, DailyCapService lives in Application and may not call into Api, and QueueEnrichment is itself a Task.Run, which rule 2 forbids. Fix: move the enrichment body into an Application service; the endpoints keep their kick, and ReconcileUserAsync awaits it word by word after promoting (never a parallel burst: the hourly sweep can promote a whole cap across every user, and Ollama is one local model). Native language read from the profile. Durable enrichment in the Worker (a column + a loop) is deferred |
The 5 timers (AutoRetire, DailyCap, ClusterCandidate, ConceptClustering, WordFrequency) |
Stay in the Api, unchanged. The reset after each deploy is accepted: it costs local CPU (Ollama), not money or data. (WordFrequency removed 2026-10-27, #935) |
ContinuousEvalWorker |
Stays as is |
EdgeTtsService cache sweep |
Stays |
| Guest cleanup comment | Fix Worker/Program.cs:84 to “every 2h”. Shipped 2026-10-08 |
Alternatives
| Option | Verdict | Why |
|---|---|---|
| Do nothing | rejected | The SSG enqueue bug can block rebuilds for good; the reconciler gap is live. |
| Only document single-instance (review option A) | not enough | Leaves both bugs. |
Move all timers into one ScheduledJobsWorker (first draft) |
dropped by the consilium | Real code for a cosmetic gain: the reset costs local CPU only. |
| Durable vocab enrichment now (first draft: two columns + a Worker loop) | deferred | A migration and a new loop for a feature whose value is unproven (review #29); the one-line reconciler call closes the gap. |
| Advisory locks around every job (review option C) | rejected | For a second instance we will not run. |
Consequences
- An admin edit or publish queues nothing; crawlers see it after the nightly Full rebuild (as in practice they always did). The stuck-
Queuedtrap is gone (ADR-022 also moves the claim intossg-worker, so enqueue becomes one write). - Three dead workers and one admin form fewer.
- Timers still restart with the Api. Accepted and written down.
Migration plan (PR-sized)
- SSG enqueue: await the 8 sites with logging; run the prod SQL above and clear any stuck
Queuedrow. - Deletions:
SsgPeriodicRebuildWorker+ settings + form,DriftDetectionWorker+ table + tab,MetadataBackfillWorker; fix the guest comment; write the single-instance rule in compose and the architecture README. - Reconciler enrichment if the owner says yes.
Owner decisions (2026-10-07)
All as recommended.
- “One Api, one Worker”: accepted as a written rule.
- Admin SSG periodic-rebuild settings: deleted.
backup.yml’s nightly rebuild is the only schedule. - Words promoted by the reconciler: enrich them: the enrichment moves into an Application service and the reconciler awaits it one word at a time (see the table). Durable enrichment stays deferred.
Amendment 2026-10-27 (#992)
The nightly Full rebuild this ADR relied on is removed and its age alarm with it: SSG is frozen
while its future is decided (#992; Search Console, 90 days: 6 clicks, 295 impressions, against a
~20 min rebuild and ~2000 IndexNow URLs each night). Consequence: a publish, edit or Popular-shelf
change reaches crawlers only after a manual rebuild (make rebuild-ssg, the admin button, or a
deploy with rebuild_ssg). The per-edit enqueues stay deleted; nothing else in this decision changes.