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
29 changed files with 263 additions and 55 deletions
@@ -55,7 +55,9 @@ public class BulkDeleteChannelsHandler(
}
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return Right<BaseError, Unit>(Unit.Default);
}
@@ -54,7 +54,10 @@ public class BulkMoveChannelsToGroupHandler(
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return Right<BaseError, Unit>(Unit.Default);
}
@@ -68,19 +68,22 @@ public class CreateChannelFromLineupHandler(
}
searchTargets.SearchTargetsChanged();
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
CancellationToken.None);
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await workerChannel.WriteAsync(
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
cancellationToken);
CancellationToken.None);
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return new CreateChannelFromLineupResponseModel(
prepared.Channel.Id,
@@ -47,20 +47,24 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
{
// Delete the guide cache file through the filesystem abstraction (so it's observable under a
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
// process crashes in between (nothing reaps it, and GetChannelGuideHandler serves everything
// in the cache folder). The guide xml is regenerable on demand, so losing it pre-commit is safe (#254).
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_fileSystem.File.Exists(cacheFile))
{
_fileSystem.File.Delete(cacheFile);
}
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_fileSystem.File.Exists(cacheFile))
{
File.Delete(cacheFile);
}
// refresh channel list to remove channel that has no playout
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
// CancellationToken.None so a late request cancellation can't abort it after the delete committed (#254)
await _workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return Unit.Default;
}
@@ -157,21 +157,23 @@ public class UpdateChannelHandler(
searchTargets.SearchTargetsChanged();
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
if (c.SubtitleMode != ChannelSubtitleMode.None)
{
Option<Playout> maybePlayout = await dbContext.Playouts
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, CancellationToken.None);
foreach (Playout playout in maybePlayout)
{
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), CancellationToken.None);
}
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
if (hasEpgChange)
{
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
}
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
@@ -81,10 +81,12 @@ public class UpdateChannelNumbersHandler(
await transaction.CommitAsync(cancellationToken);
// update channel list and xmltv
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
foreach (var channel in channelsToUpdate)
{
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), CancellationToken.None);
}
return Option<BaseError>.None;
@@ -81,13 +81,15 @@ public class AddItemsToCollectionHandler :
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
}
@@ -42,7 +42,10 @@ public class CreateSmartCollectionHandler :
await dbContext.SmartCollections.AddAsync(smartCollection, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _smartCollectionCache.Refresh(CancellationToken.None);
return ProjectToViewModel(smartCollection);
}
@@ -48,7 +48,10 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
dbContext.SmartCollections.Remove(smartCollection);
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _smartCollectionCache.Refresh(CancellationToken.None);
return Unit.Default;
}
@@ -63,14 +63,16 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(
new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()),
cancellationToken);
CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
}
@@ -61,11 +61,13 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
if (await dbContext.SaveChangesAsync(cancellationToken) > 0 && request.UseCustomPlaybackOrder.IsSome)
{
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
}
@@ -124,10 +124,12 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
_searchTargets.SearchTargetsChanged();
// refresh all playouts that use this collection
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
request.MultiCollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
}
@@ -43,11 +43,13 @@ public class UpdateRerunCollectionHandler(
// rebuild playouts
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
// refresh all playouts that use this rerun collection
foreach (int playoutId in await mediaCollectionRepository.PlayoutIdsUsingRerunCollection(
request.RerunCollectionId))
{
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
}
@@ -69,12 +69,15 @@ public class
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
_searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh(cancellationToken);
// post-commit side effects run on CancellationToken.None so a late request cancellation
// can't abort them after the commit landed (#254)
await _smartCollectionCache.Refresh(CancellationToken.None);
// refresh all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
}
@@ -57,7 +57,9 @@ public class UpdateTraktListHandler(
{
try
{
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request
// cancellation can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), CancellationToken.None);
}
catch
{
@@ -35,15 +35,18 @@ public class CreateScriptedPlayoutHandler(
{
await dbContext.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), CancellationToken.None);
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await channel.WriteAsync(
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
cancellationToken);
CancellationToken.None);
}
await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
await channel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return new CreatePlayoutResponse(playout.Id);
}
@@ -33,15 +33,18 @@ public class CreateSequentialPlayoutHandler(
{
await dbContext.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), CancellationToken.None);
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await channel.WriteAsync(
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
cancellationToken);
CancellationToken.None);
}
await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
await channel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return new CreatePlayoutResponse(playout.Id);
}
@@ -28,20 +28,25 @@ public class DeletePlayoutHandler(
foreach (Playout playout in maybePlayout)
{
dbContext.Playouts.Remove(playout);
await dbContext.SaveChangesAsync(cancellationToken);
// delete channel data from channel guide cache
// Delete the guide cache file through the filesystem abstraction (observable under a
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
// process crashes in between. The guide xml is regenerable on demand, so losing it pre-commit
// is safe (#254).
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
if (fileSystem.File.Exists(cacheFile))
{
File.Delete(cacheFile);
fileSystem.File.Delete(cacheFile);
}
// refresh channel list to remove channel that has no playout
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
dbContext.Playouts.Remove(playout);
await dbContext.SaveChangesAsync(cancellationToken);
await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), cancellationToken);
// post-commit side effects run on CancellationToken.None so a late request cancellation can't
// abort them after the delete committed (#254)
// refresh channel list to remove channel that has no playout
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), CancellationToken.None);
}
return maybePlayout
@@ -20,7 +20,13 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
ReplacePlayoutAlternateScheduleItems request,
CancellationToken cancellationToken)
{
// TODO: validate that items is not empty
// The handler reads the highest-index item as the default schedule (Max() below), so an empty
// list is invalid — reject it here rather than letting Max() throw. The controller pre-guards
// too, but a direct handler caller (MCP, test, reuse) must get a clean 422, not a raw crash (#254).
if (request.Items.Count == 0)
{
return BaseError.New("Playout alternate schedule items must not be empty");
}
try
{
@@ -152,9 +158,11 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
existingValue.Name,
schedule.Name);
// post-commit enqueue runs on CancellationToken.None: the schedule change is
// already committed, so a late cancellation must not drop the rebuild (#254)
await channel.WriteAsync(
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh),
cancellationToken);
CancellationToken.None);
break;
}
@@ -42,7 +42,9 @@ public class
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
}
return new PlayoutNameViewModel(
@@ -37,7 +37,9 @@ public class
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
}
return new PlayoutNameViewModel(
@@ -42,7 +42,9 @@ public class
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
}
return new PlayoutNameViewModel(
@@ -55,9 +55,11 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
await dbContext.SaveChangesAsync(cancellationToken);
// refresh any playouts that use this schedule
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
foreach (Playout playout in programSchedule.Playouts)
{
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
}
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
@@ -36,9 +36,11 @@ public class DeleteProgramScheduleItemHandler(
dbContext.ProgramScheduleItems.Remove(item);
await dbContext.SaveChangesAsync(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
foreach (Playout playout in playouts)
{
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -95,9 +95,11 @@ public class ReplaceProgramScheduleItemsHandler(
await dbContext.SaveChangesAsync(cancellationToken);
// refresh any playouts that use this schedule
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
foreach (Playout playout in programSchedule.Playouts)
{
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
}
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
@@ -14,7 +14,8 @@ namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class DeleteChannelHandlerTests : ChannelHandlerTestBase
{
private DeleteChannelHandler MakeHandler() => new(Worker, Db.Factory, new MockFileSystem(), SearchTargets);
private DeleteChannelHandler MakeHandler(MockFileSystem? fileSystem = null) =>
new(Worker, Db.Factory, fileSystem ?? new MockFileSystem(), SearchTargets);
[Test]
public async Task Should_Return_NotFoundError_When_Channel_Missing()
@@ -39,6 +40,25 @@ public class DeleteChannelHandlerTests : ChannelHandlerTestBase
exists.ShouldBeFalse();
}
[Test]
public async Task Should_Delete_Guide_Cache_File_Through_FileSystem_Abstraction()
{
await SeedChannel(1, "5");
var fileSystem = new MockFileSystem();
string cacheFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "5.xml");
fileSystem.Directory.CreateDirectory(FileSystemLayout.ChannelGuideCacheFolder);
await fileSystem.File.WriteAllTextAsync(cacheFile, "<tv/>");
fileSystem.File.Exists(cacheFile).ShouldBeTrue();
Either<BaseError, Unit> result =
await MakeHandler(fileSystem).Handle(new DeleteChannel(1), CancellationToken.None);
result.IsRight.ShouldBeTrue();
// routed through the abstraction (not static File.Delete), so the MockFileSystem observes the removal
fileSystem.File.Exists(cacheFile).ShouldBeFalse();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -9,6 +9,7 @@ using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -71,6 +72,52 @@ public class PlayoutHandlerTests
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Delete_Should_Remove_Guide_Cache_File_Through_FileSystem_Abstraction()
{
int channelId = await SeedChannel();
int playoutId;
await using (TvContext context = _db.CreateContext())
{
var playout = new Playout { ChannelId = channelId, Items = [] };
context.Playouts.Add(playout);
await context.SaveChangesAsync();
playoutId = playout.Id;
}
var fileSystem = new MockFileSystem();
string cacheFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "101.xml");
fileSystem.Directory.CreateDirectory(FileSystemLayout.ChannelGuideCacheFolder);
await fileSystem.File.WriteAllTextAsync(cacheFile, "<tv/>");
var handler = new DeletePlayoutHandler(
_worker,
_db.Factory,
fileSystem,
Substitute.For<IMediator>());
Either<BaseError, Unit> result = await handler.Handle(new DeletePlayout(playoutId), CancellationToken.None);
result.IsRight.ShouldBeTrue();
// routed through the abstraction (not static File.Delete), so the MockFileSystem observes the removal
fileSystem.File.Exists(cacheFile).ShouldBeFalse();
}
[Test]
public async Task ReplaceAlternateScheduleItems_Should_Reject_Empty_List()
{
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, []),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("must not be empty");
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
+32
View File
@@ -343,6 +343,38 @@ save → 412 (prove it non-vacuous by dropping `.IsConcurrencyToken()` and watch
Phase 2 (a later PR) flips a missing `If-Match` from force-write to **428 Precondition Required** once
every editor echoes and one release soaks.
## 7b. Post-commit side effects run on `CancellationToken.None`
Once a command handler's `await dbContext.SaveChangesAsync(cancellationToken)` (or repository upsert)
has **committed**, everything that runs afterwards to complete that mutation's side effect —
`channel.WriteAsync(new BuildPlayout(...))` / other worker-channel enqueues, `mediator.Publish(...)`,
`ISearchIndex`/reindex enqueues, a cache `Refresh(...)`, and any post-commit **lookup that gates one
of those enqueues** — must be passed **`CancellationToken.None`**, not the request `cancellationToken`.
Rationale (audit #22, issues #251 → #254): the request token is cancelled when the HTTP client
disconnects. If it's threaded into a post-commit enqueue, a late disconnect turns an *already-durable*
commit into a thrown request **and drops the side effect** (e.g. the rebuild is never queued → the
persisted change silently never takes visible effect). The commit is the point of no return: past it,
the compensating side effect must not be half-abortable. Exemplar idiom:
`ErsatzTV.Application/Scheduling/Commands/UpdateDecoHandler.cs` (the affected-playout queries **and** the
`WriteAsync(BuildPlayout..., CancellationToken.None)` enqueue all use `None`).
Two boundaries:
- **Response projection is NOT a side effect.** The post-commit reload that builds the *returned* view
model (§7 above) legitimately keeps the request `cancellationToken` — if the client disconnected, we
don't need to compute a response nobody will read, and the durable work (commit + `None`-enqueue) has
already happened. Only the *side effect* chain gets `None`.
- **Background-job handlers keep their token.** `BuildPlayoutHandler` and other handlers invoked by the
worker (not by an HTTP request) receive the *worker's* shutdown token, not a client-disconnect token —
their downstream enqueues correctly stay on that token so a shutdown stops enqueuing more work.
Pre-commit reads/validation and the `SaveChangesAsync` call itself keep the request token (cancelling
*before* the commit safely aborts with nothing persisted). A handler that passes **no** token to a
post-commit `WriteAsync()` is already behaviorally correct (`default` == `CancellationToken.None`);
making it explicit is optional cleanup, not required. Config handlers that commit via several sequential
`IConfigElementRepository.Upsert` calls are a distinct partial-commit-under-cancellation case not covered
by this rule (tracked separately).
## 8. Known API warts (don't "fix" without discussion — they're deliberate synthesized rows)
`GET /api/blocks` and `GET /api/templates` (via `GetAllBlocksHandler` / `GetAllTemplatesHandler` in
+41
View File
@@ -682,3 +682,44 @@ the mechanics live in `api-conventions.md` §7a. Decisions frozen here:
concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to **#259**.
- **If-Match status semantics** (non-canonical/weak/list → 400) are fail-safe; the stricter RFC 7232
"valid-but-non-matching → 412" refinement is deferred to #197 (**#265**).
## 2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)
Audit #22 (adversarial-reviewer) found ~20 command handlers threading the request `cancellationToken`
into work that runs **after** `SaveChangesAsync` commits — the post-commit `WriteAsync` enqueue that
rebuilds/refreshes the affected entity, `mediator.Publish`, search-index reindex, cache refresh. A late
HTTP-client disconnect cancels that token, so the *already-committed* mutation throws on the way out
**and silently drops its side effect** (the playout rebuild is never queued → the persisted edit never
takes visible effect until a manual Reset). #251 fixed this for the deco handlers; #254 generalizes the
policy across the codebase.
**Decision.** Once a mutation has committed, the *entire* compensating side effect — enqueues, publishes,
reindexes, cache refreshes, and any post-commit lookup that **gates** one of those enqueues — runs on
`CancellationToken.None`. The commit is the point of no return; past it the side effect must not be
half-abortable. Full convention + the two boundaries in `docs/api-conventions.md` §7b.
**Scope of the #254 sweep (this PR).** Swept the single-`SaveChanges` handlers under `MediaCollections/`,
`ProgramSchedules/`, `Playouts/`, `Channels/` (20 handlers). Deliberately **excluded**:
- **`BuildPlayoutHandler`** — a background/worker handler; its token is the worker shutdown token, not a
client-disconnect token, so its downstream enqueues *correctly* honor cancellation.
- **`UpdateFFmpegSettingsHandler` + the two `Configuration/` settings handlers** — they commit via several
sequential `IConfigElementRepository.Upsert` calls with an interleaved enqueue; "when is it committed"
is a partial-commit-under-cancellation question broader than the clean single-`SaveChanges` F4 pattern.
Left for a separate follow-up.
- **Response-projection reloads** (`ReplaceProgramScheduleItemsHandler` / `AddProgramScheduleItemHandler`
post-commit graph reload that builds the *returned* view model) keep the request token — a cancelled
response after a durable commit + `None`-enqueue loses nothing.
- Handlers a no-token `WriteAsync()` already makes behaviorally correct (`default` == `None`) were left
alone (explicit-`None` there is cosmetic).
Also folded in the two other #254 items on the same handlers: the channel-guide `{number}.xml` delete in
`DeleteChannelHandler`/`DeletePlayoutHandler` now routes through `IFileSystem.File.Delete` (observable
under `MockFileSystem`) **before** the commit (a post-commit delete orphans the xml on a crash; the guide
xml is regenerable on demand, so a pre-commit delete is the safe ordering), and
`ReplacePlayoutAlternateScheduleItemsHandler` now rejects an empty item list in the handler (not only at
the controller pre-guard) so a direct caller can't trip the `Max()`-on-empty crash.
**Coordination note for #253 PR2–PR4.** Those PRs add `Version++` (pre-commit) to the mutating handlers of
the versioned aggregates — several of which this sweep also touched (post-commit token, a different line
region). Low git-conflict risk, but merge `main` in and expect to see the `CancellationToken.None`
convention already present on the post-commit enqueues.