fix(app): post-commit side effects on CancellationToken.None + guide-xml/empty-list hardening (#254) #266

Merged
timothy merged 3 commits from feat/254-mutation-hardening into main 2026-07-11 18:19:01 +02:00
Owner

Backend mutation-hardening cluster from the 2026-07-11 mutation-safety audit sweep (adversarial-reviewer #22/#23) — the parallel-safe, backend-isolated slice of #254. No API contract change (handler-internal only), no OpenAPI/SPA change.

What & why

audit#22 F4 — standardize post-commit enqueues on CancellationToken.None (20 handlers)

Handlers under MediaCollections/, ProgramSchedules/, Playouts/, Channels/ threaded the request cancellationToken into work that runs after SaveChangesAsync commits — the WriteAsync rebuild/refresh enqueue, mediator.Publish, search reindex, cache Refresh, and post-commit lookups that gate an enqueue. A late HTTP-client disconnect cancels that token, so an already-durable commit throws on the way out and silently drops its side effect (the playout rebuild is never queued → the persisted edit never takes visible effect). This generalizes the #251 deco-handler fix to the codebase.

Excluded (deliberate):

  • BuildPlayoutHandler — background/worker handler; its token is the worker shutdown token, not a client-disconnect token, so honoring cancellation on its downstream enqueues is correct.
  • UpdateFFmpegSettingsHandler + the two Configuration/ settings handlers — multi-Upsert commit with an interleaved enqueue; the "when is it committed" partial-commit question is broader than this clean single-SaveChanges pattern → separate follow-up.
  • Response-projection reloads (ReplaceProgramScheduleItemsHandler/AddProgramScheduleItemHandler) correctly keep the request token — a cancelled response after a durable commit + None-enqueue loses nothing.

audit#22 F2 — guide-xml delete: FS-abstraction + pre-commit ordering

DeleteChannelHandler/DeletePlayoutHandler deleted the channel-guide {number}.xml via static File.Delete (escaped MockFileSystem) after commit (orphans the xml on a crash — nothing reaps it; GetChannelGuideHandler serves the whole cache folder). Now routed through IFileSystem.File.Delete and done before the commit; the xml is regenerable on demand, so a pre-commit delete is the safe ordering.

audit#23 F4 — Max()-on-empty guard

ReplacePlayoutAlternateScheduleItemsHandler now rejects an empty item list in the handler (not only the controller pre-guard), so a direct caller (MCP/test/reuse) gets a clean 422 instead of the Max()-on-empty crash. Deletes the stale // TODO: validate that items is not empty.

Deferred (documented in the issue, not in this PR)

F6 dirty-nav guard (frontend editor-chrome → #230/#238/#248), F1 reindex reconcile (optional self-heal), F7 empty-list semantics + #21-F2 block/template immediacy (→ #197-prep docs), and the config/FFmpeg multi-upsert token case.

Docs

  • docs/api-conventions.md §7a — the post-commit CancellationToken.None convention + the two boundaries (response-projection keeps the request token; background handlers keep the worker token).
  • docs/decisions.md — rationale, exact sweep scope, and a coordination note for #253 PR2–PR4 (they add Version++ pre-commit to several of the same handlers — different line region, low conflict; merge main in and expect the None convention already present).

Tests

  • DeleteChannelHandlerTests / PlayoutHandlerTests — guide-cache file is deleted through the FS abstraction (seeded in MockFileSystem, asserted gone) for both delete handlers.
  • PlayoutHandlerTests — empty alternate-schedule-items list → BaseError.

Verification

  • dotnet build Application + Tests: 0 errors (warnings-as-errors clean on Application).
  • Affected handler tests (Application.{Channels,Playouts,MediaCollections,ProgramSchedules,Scheduling}): 126 passed.
  • Integration + Playout/Channel controller tests: 123 passed.
  • 3 new tests pass. No pre-commit SaveChangesAsync token was flipped (grep-verified).

Closes #254 (scoped slice; deferred items remain tracked on the issue).

Backend mutation-hardening cluster from the 2026-07-11 mutation-safety audit sweep (adversarial-reviewer #22/#23) — the parallel-safe, backend-isolated slice of #254. No API contract change (handler-internal only), no OpenAPI/SPA change. ## What & why ### audit#22 F4 — standardize post-commit enqueues on `CancellationToken.None` (20 handlers) Handlers under `MediaCollections/`, `ProgramSchedules/`, `Playouts/`, `Channels/` threaded the request `cancellationToken` into work that runs **after** `SaveChangesAsync` commits — the `WriteAsync` rebuild/refresh enqueue, `mediator.Publish`, search reindex, cache `Refresh`, and post-commit lookups that **gate** an enqueue. A late HTTP-client disconnect cancels that token, so an *already-durable* commit throws on the way out **and silently drops its side effect** (the playout rebuild is never queued → the persisted edit never takes visible effect). This generalizes the #251 deco-handler fix to the codebase. **Excluded (deliberate):** - `BuildPlayoutHandler` — background/worker handler; its token is the worker shutdown token, not a client-disconnect token, so honoring cancellation on its downstream enqueues is correct. - `UpdateFFmpegSettingsHandler` + the two `Configuration/` settings handlers — multi-`Upsert` commit with an interleaved enqueue; the "when is it committed" partial-commit question is broader than this clean single-`SaveChanges` pattern → separate follow-up. - Response-projection reloads (`ReplaceProgramScheduleItemsHandler`/`AddProgramScheduleItemHandler`) correctly keep the request token — a cancelled response after a durable commit + `None`-enqueue loses nothing. ### audit#22 F2 — guide-xml delete: FS-abstraction + pre-commit ordering `DeleteChannelHandler`/`DeletePlayoutHandler` deleted the channel-guide `{number}.xml` via **static** `File.Delete` (escaped `MockFileSystem`) **after** commit (orphans the xml on a crash — nothing reaps it; `GetChannelGuideHandler` serves the whole cache folder). Now routed through `IFileSystem.File.Delete` and done **before** the commit; the xml is regenerable on demand, so a pre-commit delete is the safe ordering. ### audit#23 F4 — `Max()`-on-empty guard `ReplacePlayoutAlternateScheduleItemsHandler` now rejects an empty item list in the handler (not only the controller pre-guard), so a direct caller (MCP/test/reuse) gets a clean 422 instead of the `Max()`-on-empty crash. Deletes the stale `// TODO: validate that items is not empty`. ## Deferred (documented in the issue, not in this PR) F6 dirty-nav guard (frontend editor-chrome → #230/#238/#248), F1 reindex reconcile (optional self-heal), F7 empty-list semantics + #21-F2 block/template immediacy (→ #197-prep docs), and the config/FFmpeg multi-upsert token case. ## Docs - `docs/api-conventions.md` §7a — the post-commit `CancellationToken.None` convention + the two boundaries (response-projection keeps the request token; background handlers keep the worker token). - `docs/decisions.md` — rationale, exact sweep scope, and a coordination note for **#253 PR2–PR4** (they add `Version++` pre-commit to several of the same handlers — different line region, low conflict; merge `main` in and expect the `None` convention already present). ## Tests - `DeleteChannelHandlerTests` / `PlayoutHandlerTests` — guide-cache file is deleted **through the FS abstraction** (seeded in `MockFileSystem`, asserted gone) for both delete handlers. - `PlayoutHandlerTests` — empty alternate-schedule-items list → `BaseError`. ## Verification - `dotnet build` Application + Tests: 0 errors (warnings-as-errors clean on Application). - Affected handler tests (Application.{Channels,Playouts,MediaCollections,ProgramSchedules,Scheduling}): **126 passed**. - Integration + Playout/Channel controller tests: **123 passed**. - 3 new tests pass. No pre-commit `SaveChangesAsync` token was flipped (grep-verified). Closes #254 (scoped slice; deferred items remain tracked on the issue).
timothy added the review label 2026-07-11 17:43:30 +02:00
timothy added 1 commit 2026-07-11 17:43:30 +02:00
fix(app): post-commit side effects on CancellationToken.None + guide-xml/empty-list hardening (#254)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 18m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 18m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
a1bd303cce
Backend mutation-hardening cluster from the 2026-07-11 mutation-safety audit
sweep (adversarial-reviewer #22/#23), the parallel-safe backend-isolated slice.

audit#22 F4 — standardize post-commit enqueues on CancellationToken.None:
20 command handlers under MediaCollections/, ProgramSchedules/, Playouts/,
Channels/ threaded the request cancellationToken into work that runs AFTER
SaveChangesAsync commits (WriteAsync rebuild/refresh enqueues, mediator.Publish,
reindex, cache Refresh, and post-commit lookups that gate an enqueue). A late
client-disconnect then turns an already-durable commit into a thrown request AND
drops the side effect. Generalizes the #251 deco-handler fix. Excludes
BuildPlayoutHandler (worker/background token, not a client-disconnect token),
the config/FFmpeg multi-upsert handlers (partial-commit case, separate
follow-up), and response-projection reloads (correctly keep the request token).

audit#22 F2 — DeleteChannelHandler/DeletePlayoutHandler now delete the channel
guide {number}.xml through IFileSystem.File.Delete (observable under
MockFileSystem) and BEFORE the commit (a post-commit delete orphans the xml on a
crash; the xml is regenerable on demand, so pre-commit delete is the safe order).

audit#23 F4 — ReplacePlayoutAlternateScheduleItemsHandler rejects an empty item
list in the handler (not only the controller pre-guard) so a direct caller can't
trip the Max()-on-empty crash.

Docs: api-conventions.md §7a (post-commit token convention + boundaries),
decisions.md entry (rationale, sweep scope, #253 PR2-4 coordination note).
Tests: guide-cache-delete-through-FS for both delete handlers, empty-list guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-11 17:49:20 +02:00
fix(app): fold Create/DeleteSmartCollection into the post-commit None sweep (#254 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 35s
6055bd3687
Independent adversarial review of PR #266 found two sibling handlers with the
identical post-commit `_smartCollectionCache.Refresh(cancellationToken)` pattern
that the sweep missed (only UpdateSmartCollection was caught). Same audit#22 F4
class: a late client-disconnect after the commit lands would throw and leave the
in-memory smart-collection cache stale vs the committed DB. → CancellationToken.None.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-11 18:12:29 +02:00
Merge remote-tracking branch 'origin/main' into feat/254-mutation-hardening
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m10s
13cd00c8fe
# Conflicts:
#	docs/api-conventions.md
#	docs/decisions.md
timothy merged commit 13cd00c8fe into main 2026-07-11 18:19:01 +02:00
timothy deleted branch feat/254-mutation-hardening 2026-07-11 18:19:01 +02:00
Sign in to join this conversation.