Files
ersatztv/docs/decisions/records/api/async-op-contract.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

5.5 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
api.async-op-contract 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235) active 2026-07-11 none none Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. `QueueShowScanResult`, `ResetAllPlayoutsResponseModel`, `MaintenanceController.EmptyTrash`/`CleanArtwork` · paths: `LibrariesController.ScanShow`, `ChannelController.ResetPlayout`, `PlayoutController.ResetAll` · issues: #235, adversarial-reviewer#20 F7/F8/F9, #232, #215 `docs/api-conventions.md` §3a/§3b

Reviewer#20 F7/F8/F9. Normalizes the queue-triggering /api/* endpoints onto one contract, closes the two F9 Libraries.razor parity gaps, and hardens the Trakt batch-lock lifecycle. Much of the F8 surface was already normalized by #232 (library scan → QueueLibraryScanResult 202/404/409/422) and #215 (per-id playout mutations + reset → 409 lock guard) — this issue finished the remaining outliers.

Normalized async-op contract (queue-triggering endpoints): 202 Accepted = work queued; 404 ProblemDetails = entity missing (controller pre-check); 409 ProblemDetails = lock held (the running job, or a mutation racing it — §3a/§3b); 422 ProblemDetails = domain precondition (sync disabled / unsupported / start failed). Trakt was the reference implementation. Changes made:

  • MaintenanceController.EmptyTrash — error path 500 text/plain → 404/422 ProblemDetails (ToErrorResult).
  • MaintenanceController.CleanArtwork — silent 200 → 202 (fire-and-forget enqueue). No SPA consumer.
  • LibrariesController.ScanShow — conflated 400 {error} → 202/404/409/422 via a new QueueShowScanResult enum (6 outcomes incl. an honest ScanFailed→422, distinct from Unsupported).
  • ChannelController.ResetPlayout200 → 202 (queue-triggering; 404/409 unchanged).
  • PlayoutController.ResetAll202 (no body) → 202 + ResetAllPlayoutsResponseModel reporting queuedPlayoutIds / skippedLocked / skippedUnsupported (replaces the silent skip; still 202, still skips locked/ExternalJson by design per §3a — now it reports what it skipped).
  • TroubleshootController.TroubleshootPlayback — bare body-less NotFound()404/422 ProblemDetails with distinguishing detail. Status codes the SPA HLS player depends on were preserved — verified HlsPlayer.tsx never branches on this endpoint's status (playback state comes from the separate /api/troubleshoot/playback/status poll); only the error body was enriched.

Playout build observability: the list endpoint (GET /api/playouts) already stamped isLocked + BuildStatus on PlayoutListItemResponseModel (#215); this issue adds isLocked to the single-playout GET /api/playouts/{id} (PlayoutResponseModel), so the detail poll surface carries the §3a lock flag too. No dedicated GET /api/playouts/{id}/status push channel was added — the flag on the existing GETs is the HTTP-observable substitute for Blazor's live lock event, matching the GET /api/trakt/status precedent.

F9 parity endpoints (the Libraries.razor deletion gate — #202 did NOT close these):

  • Deep scan: POST /api/libraries/{id}/scan gains ?deep=false, threaded through QueueLibraryScanByLibraryId(LibraryId, DeepScan=false) into ForceSynchronize{Plex,Jellyfin,Emby}LibraryById(id, deep) (was hardcoded false). Non-breaking: existing callers omit it.
  • External-collections scan: new POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false on the three #202 media-source controllers, dispatching Synchronize{X}Collections(id, ForceScan:true, deep). Each pre-checks source existence (404), acquires the per-source collections lock (Lock{X}Collections() — the lock is the running scan, so a false = 409), then enqueues and returns 202; the controller compensating-unlocks in a catch if the enqueue throws (§3b), and ScannerService releases in its finally. Thin SPA clients shipped (scanLibrary(id, deep), scanCollections); the SPA deep-scan / collections buttons are the removal PR's remaining parity work (parity doc §5).

F7 Trakt batch-lock leak fix: the global Trakt lock was released only when the terminal batch message (Unlock: true) was processed; a WorkerService shutdown/cancellation before that message leaked the lock permanently (subsequent Trakt ops 409 until restart — same class as #231/#233/#234). Fix: WorkerService now releases the Trakt lock in a finally on read-loop exit if still held. Non-vacuous regression test proven against an inverted-condition control.

Accepted-by-design (per the issue's decision-record ask): the worker's channels are unbounded and there is no shutdown drain — messages still queued at process exit are dropped. This is acceptable because the entity locks are in-memory singletons that die with the process, so a dropped message can't strand a lock across restarts (the F7 finally covers the within-process shutdown-break leak, which is the only way a lock outlives its batch while the process keeps running). Adding a bounded-channel backpressure / graceful drain is out of scope and would not fix a correctness bug.