fix(api): #269 rotate aggregate ETag on Collection/Playout config siblings #307

Merged
timothy merged 2 commits from fix/269-etag-rotation into main 2026-07-12 19:50:12 +02:00
22 changed files with 776 additions and 148 deletions
+14 -6
View File
@@ -15,9 +15,10 @@ public static class ConcurrencyExtensions
/// UPDATE of that row with <c>WHERE Version=@orig</c>, so a concurrent bump from a replace-all
/// editor would otherwise surface as an unhandled <see cref="DbUpdateConcurrencyException" /> →
/// 500 (issue #253 / #269). Phase-1 semantics for a missing <c>If-Match</c> is <b>force-write</b>,
/// so on a concurrency failure we adopt the stored token as both original (the retry's WHERE then
/// matches) and current (so we don't revert the concurrent bump) and retry — a client-wins merge
/// scoped to the token; our own modified scalars still win. Bounded to avoid a livelock; if the row
/// so on a concurrency failure we rebase onto the stored token: original becomes the stored value
/// (the retry's WHERE then matches) and current becomes stored + our pending delta (a bumper's ++
/// still advances the ETag past the concurrent writer's value — #269 rotation; a non-bumper adopts
/// it unchanged) and retry; our own modified scalars still win. Bounded to avoid a livelock; if the row
/// was deleted out from under us, that's a genuine conflict and rethrows.
/// </summary>
public static async Task<int> SaveChangesForcingVersion(
@@ -48,9 +49,16 @@ public static class ConcurrencyExtensions
}
PropertyEntry version = entry.Property(nameof(IVersionedAggregate.Version));
object currentVersion = databaseValues[nameof(IVersionedAggregate.Version)]!;
version.OriginalValue = currentVersion;
version.CurrentValue = currentVersion;
int dbVersion = (int)databaseValues[nameof(IVersionedAggregate.Version)]!;
// Rebase our pending delta on top of the stored token instead of adopting it verbatim:
// a Version-bumping sibling (pending current = original + 1) must still advance the
// ETag PAST the concurrent writer's value, or an editor holding that writer's ETag is
// never invalidated by our change (#269 rotation silently lost under race). Non-bumpers
// (delta 0, e.g. ErasePlayoutHistory) still adopt the stored token unchanged.
int pendingDelta = (int)version.CurrentValue! - (int)version.OriginalValue!;
version.OriginalValue = dbVersion;
version.CurrentValue = dbVersion + pendingDelta;
resolvedAny = true;
}
@@ -42,17 +42,28 @@ public class AddArtistToCollectionHandler :
private async Task<Unit> ApplyAddArtistRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Artist);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Artist.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Artist);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -44,17 +44,28 @@ public class AddEpisodeToCollectionHandler :
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Episode);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Episode.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Episode);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -41,17 +41,28 @@ public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection,
private async Task<Unit> ApplyAddImageRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Image);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Image.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Image);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -73,24 +73,35 @@ public class AddItemsToCollectionHandler :
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
// No-op when every requested item is already a member: don't rotate the ETag or fan out
// rebuilds for an idempotent re-add — #269.
if (toAddIds.Count == 0)
{
return Unit.Default;
}
List<MediaItem> toAdd = await dbContext.MediaItems
.Filter(mi => toAddIds.Contains(mi.Id))
.ToListAsync(cancellationToken);
collection.MediaItems.AddRange(toAdd);
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)
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), CancellationToken.None);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a).
collection.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// 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.None);
}
// 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.None);
}
return Unit.Default;
@@ -42,17 +42,28 @@ public class AddMediaItemToCollectionHandler :
private async Task<Unit> ApplyAddMediaItemRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.MediaItem);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.MediaItem.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.MediaItem);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -42,17 +42,28 @@ public class AddMovieToCollectionHandler :
private async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Movie);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Movie.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Movie);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -44,17 +44,28 @@ public class AddMusicVideoToCollectionHandler :
private async Task<Unit> ApplyAddMusicVideoRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.MusicVideo);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.MusicVideo.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.MusicVideo);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -44,17 +44,28 @@ public class AddOtherVideoToCollectionHandler :
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.OtherVideo.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -42,17 +42,28 @@ public class AddSeasonToCollectionHandler :
private async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Season);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Season.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Season);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -42,17 +42,28 @@ public class AddShowToCollectionHandler :
private async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Show);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Show.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Show);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -42,17 +42,28 @@ public class AddSongToCollectionHandler :
private async Task<Unit> ApplyAddSongRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Song);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Song.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Song);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -59,21 +59,30 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
return new NotFoundError("Collection item does not exist.");
}
// No-op when nothing is actually removed: don't rotate the ETag or fan out rebuilds — #269.
if (itemsToRemove.Count == 0)
{
return Unit.Default;
}
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
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.None);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this remove takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a).
collection.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// 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.None);
}
// 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.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.None);
}
return Unit.Default;
@@ -59,17 +59,25 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
c.UseCustomPlaybackOrder = useCustomPlaybackOrder;
}
// Force-write past a concurrent Version bump from the custom-order editor (this name/flag writer
// doesn't participate in If-Match, so the active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0 && request.UseCustomPlaybackOrder.IsSome)
// Only rotate the ETag when the name/flag actually changed — a no-op re-submit must not bump the
// Version (spurious rebuilds / editor invalidation) — #269. When it did change, force-write past a
// concurrent bump from the custom-order editor (this writer takes no If-Match, so a benign race
// must not 500) — #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
// 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))
c.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
if (request.UseCustomPlaybackOrder.IsSome)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
// 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.None);
}
}
}
@@ -40,10 +40,14 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
// Rotate the playout ETag only when the schedule-file actually changed — a no-op re-submit must
// not bump (#269). When it changed, force-write past a concurrent Version bump from a replace-all
// editor (this writer takes no If-Match, so a benign race must not 500) — #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
playout.Version++;
await dbContext.SaveChangesForcingVersion(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);
@@ -35,9 +35,15 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.DailyRebuildTime = dailyRebuildTime;
}
// Force-write past a concurrent Version bump from a replace-all editor (this settings writer
// doesn't participate in If-Match, so a missing token = force-write, not a 500) — #253/#269.
await dbContext.SaveChangesForcingVersion(cancellationToken);
// Rotate the playout ETag only when the daily-rebuild-time actually changed — a no-op re-submit
// must not bump (#269). When it changed, force-write past a concurrent Version bump from a
// replace-all editor (this settings writer takes no If-Match, so a benign race must not 500) —
// #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
playout.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
}
return new PlayoutNameViewModel(
playout.Id,
@@ -35,10 +35,14 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
// Rotate the playout ETag only when the schedule-file actually changed — a no-op re-submit must
// not bump (#269). When it changed, force-write past a concurrent Version bump from a replace-all
// editor (this writer takes no If-Match, so a benign race must not 500) — #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
playout.Version++;
await dbContext.SaveChangesForcingVersion(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);
@@ -40,10 +40,14 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
// Rotate the playout ETag only when the schedule-file actually changed — a no-op re-submit must
// not bump (#269). When it changed, force-write past a concurrent Version bump from a replace-all
// editor (this writer takes no If-Match, so a benign race must not 500) — #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
playout.Version++;
await dbContext.SaveChangesForcingVersion(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);
@@ -0,0 +1,248 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Application.MediaCollections;
/// <summary>
/// #269: the non-If-Match config-sibling writers of a Collection (name/flag edit, membership
/// add/remove) rotate the aggregate ETag by bumping <c>Collection.Version</c>, so an open
/// custom-order editor's stale <c>If-Match</c> invalidates. Two invariants beyond the bump: an
/// idempotent no-op (re-add an existing member / re-submit the same name) must NOT bump the Version
/// or fan out playout rebuilds (the <c>SaveChanges() &gt; 0</c> side-effect gate would otherwise fire
/// spuriously once the bump makes it always-true — Fable D1), and the bump must force-write past a
/// concurrent replace-all bump rather than 500 (these writers take no <c>If-Match</c>).
/// </summary>
[TestFixture]
public class CollectionEtagRotationTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private sealed class PreTrackedFactory(TvContext context) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() => context;
}
private async Task SeedCollection(int id, int version, bool withMovie, string name = "Manual")
{
await using TvContext context = _db.CreateContext();
var library = new LocalLibrary
{
Id = 1,
Name = "Library",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath { Id = 1, Path = "/media", Library = library, LibraryFolders = [], MediaItems = [] };
library.Paths.Add(path);
context.Movies.Add(new Movie
{
Id = 10,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MovieMetadata =
[
new MovieMetadata
{
Title = "Movie", SortTitle = "Movie", Artwork = [], Genres = [], Tags = [], Studios = [],
Actors = [], Guids = [], Subtitles = [], Directors = [], Writers = []
}
],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }]
});
context.Collections.Add(new Collection
{
Id = id,
Name = name,
Version = version,
MediaItems = [],
CollectionItems = withMovie ? [new CollectionItem { MediaItemId = 10, CustomIndex = null }] : []
});
await context.SaveChangesAsync();
}
private async Task<int> ReadVersion(int id)
{
await using TvContext context = _db.CreateContext();
return await context.Collections.Where(c => c.Id == id).Select(c => c.Version).SingleAsync();
}
private static (IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) Deps()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
// A single playout uses the collection, so a genuine mutation enqueues exactly one rebuild.
repo.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([1]);
Channel<IBackgroundServiceRequest> worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
ChannelWriter<ISearchIndexBackgroundServiceRequest> search =
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer;
return (repo, worker, search);
}
[Test]
public async Task AddItem_To_Collection_Should_Bump_Version_And_Enqueue_Rebuild()
{
await SeedCollection(1, version: 1, withMovie: false);
(IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();
var handler = new AddMovieToCollectionHandler(_db.Factory, repo, worker.Writer, search);
Either<BaseError, Unit> result = await handler.Handle(new AddMovieToCollection(1, 10), CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(2);
worker.Reader.Count.ShouldBe(1); // rebuild fanned out to the one playout using the collection
}
[Test]
public async Task Idempotent_ReAdd_Should_Be_A_NoOp_Without_Bump_Or_Rebuild()
{
// The item is already a member — Fable D1: an unconditional bump here would make the
// SaveChanges()>0 gate always-true and fire a spurious rebuild on every duplicate add.
await SeedCollection(1, version: 1, withMovie: true);
(IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();
var handler = new AddMovieToCollectionHandler(_db.Factory, repo, worker.Writer, search);
Either<BaseError, Unit> result = await handler.Handle(new AddMovieToCollection(1, 10), CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(1); // no bump
worker.Reader.Count.ShouldBe(0); // no spurious rebuild
}
[Test]
public async Task AddItem_Should_Force_Write_Past_A_Concurrent_Version_Bump()
{
await SeedCollection(1, version: 1, withMovie: false);
// The handler's context has loaded the collection at version 1 (tracked via identity map).
TvContext handlerContext = _db.CreateContext();
_ = await handlerContext.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1);
// A concurrent replace-all editor bumps the same row to version 2 in the DB.
await using (TvContext other = _db.CreateContext())
{
Collection c = await other.Collections.SingleAsync(x => x.Id == 1);
c.Version++;
await other.SaveChangesAsync();
}
(IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();
var handler = new AddMovieToCollectionHandler(new PreTrackedFactory(handlerContext), repo, worker.Writer, search);
// Must not throw (plain SaveChangesAsync would 500 here); the add force-writes past the bump.
Either<BaseError, Unit> result = await handler.Handle(new AddMovieToCollection(1, 10), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext verify = _db.CreateContext();
(await verify.CollectionItems.AnyAsync(ci => ci.CollectionId == 1 && ci.MediaItemId == 10)).ShouldBeTrue();
// The force-write must rebase our bump ON TOP of the concurrent bump (1 →(other) 2 →(ours) 3), not
// adopt the stored token verbatim — otherwise an editor holding the concurrent writer's ETag ("2")
// is never invalidated by this add and the #269 rotation is silently lost under race.
(await ReadVersion(1)).ShouldBe(3);
}
[Test]
public async Task RemoveItem_From_Collection_Should_Bump_Version()
{
await SeedCollection(1, version: 1, withMovie: true);
(IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();
var handler = new RemoveItemsFromCollectionHandler(_db.Factory, repo, worker.Writer, search);
Either<BaseError, Unit> result = await handler.Handle(
new RemoveItemsFromCollection(1) { MediaItemIds = [10] },
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(2);
worker.Reader.Count.ShouldBe(1);
}
[Test]
public async Task RemoveItem_With_Nothing_To_Remove_Should_Be_A_NoOp_Without_Bump()
{
await SeedCollection(1, version: 1, withMovie: true);
(IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();
var handler = new RemoveItemsFromCollectionHandler(_db.Factory, repo, worker.Writer, search);
Either<BaseError, Unit> result = await handler.Handle(
new RemoveItemsFromCollection(1) { MediaItemIds = [] },
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(1);
worker.Reader.Count.ShouldBe(0);
}
[Test]
public async Task UpdateCollection_Name_Change_Should_Bump_Version()
{
await SeedCollection(1, version: 1, withMovie: false, name: "Before");
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([]);
var handler = new UpdateCollectionHandler(
_db.Factory,
repo,
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer,
Substitute.For<ErsatzTV.Core.Interfaces.Search.ISearchTargets>());
Either<BaseError, Unit> result = await handler.Handle(
new UpdateCollection(1, "After"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(2);
}
[Test]
public async Task UpdateCollection_Same_Name_Should_Be_A_NoOp_Without_Bump()
{
await SeedCollection(1, version: 1, withMovie: false, name: "Same");
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([]);
var handler = new UpdateCollectionHandler(
_db.Factory,
repo,
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer,
Substitute.For<ErsatzTV.Core.Interfaces.Search.ISearchTargets>());
Either<BaseError, Unit> result = await handler.Handle(
new UpdateCollection(1, "Same"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(1);
}
}
@@ -0,0 +1,144 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Playouts;
/// <summary>
/// #269: the Playout schedule-file writer rotates the aggregate ETag (bumps <c>Playout.Version</c>)
/// when the schedule-file actually changes, so an open alt-schedule/template editor's stale
/// <c>If-Match</c> invalidates — but a no-op re-submit (same schedule-file) must NOT bump the Version
/// or enqueue a channel refresh (the <c>SaveChanges() &gt; 0</c> side-effect gate would otherwise fire
/// spuriously once the bump makes it always-true). The bump force-writes past a concurrent replace-all
/// bump rather than 500 (this writer takes no <c>If-Match</c>).
/// </summary>
[TestFixture]
public class PlayoutScheduleFileEtagRotationTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private async Task SeedPlayout(int id, int version, string scheduleFile)
{
await using TvContext context = _db.CreateContext();
var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid())
{
Id = 1,
Number = "1",
Name = "Channel",
Group = "",
Categories = "",
Playouts = [],
Artwork = []
};
context.Channels.Add(channel);
context.Playouts.Add(new Playout
{
Id = id,
ChannelId = 1,
Channel = channel,
Version = version,
ScheduleKind = PlayoutScheduleKind.Sequential,
ScheduleFile = scheduleFile,
Seed = 0,
Items = []
});
await context.SaveChangesAsync();
}
private async Task<int> ReadVersion(int id)
{
await using TvContext context = _db.CreateContext();
return await context.Playouts.Where(p => p.Id == id).Select(p => p.Version).SingleAsync();
}
private static (UpdateSequentialPlayoutHandler handler, Channel<IBackgroundServiceRequest> worker) MakeHandler(
IDbContextFactory<TvContext> factory)
{
Channel<IBackgroundServiceRequest> worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
return (new UpdateSequentialPlayoutHandler(factory, worker.Writer), worker);
}
[Test]
public async Task Changing_The_Schedule_File_Should_Bump_Version_And_Refresh()
{
await SeedPlayout(1, version: 1, scheduleFile: "before.yml");
(UpdateSequentialPlayoutHandler handler, Channel<IBackgroundServiceRequest> worker) = MakeHandler(_db.Factory);
Either<BaseError, PlayoutNameViewModel> result = await handler.Handle(
new UpdateSequentialPlayout(1, "after.yml"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(2);
worker.Reader.Count.ShouldBe(1); // channel refresh enqueued
}
[Test]
public async Task Resubmitting_The_Same_Schedule_File_Should_Be_A_NoOp_Without_Bump_Or_Refresh()
{
await SeedPlayout(1, version: 1, scheduleFile: "same.yml");
(UpdateSequentialPlayoutHandler handler, Channel<IBackgroundServiceRequest> worker) = MakeHandler(_db.Factory);
Either<BaseError, PlayoutNameViewModel> result = await handler.Handle(
new UpdateSequentialPlayout(1, "same.yml"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadVersion(1)).ShouldBe(1); // no bump
worker.Reader.Count.ShouldBe(0); // no spurious refresh
}
private sealed class PreTrackedFactory(TvContext context) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() => context;
}
[Test]
public async Task Schedule_File_Change_Should_Force_Write_Past_A_Concurrent_Version_Bump_And_Still_Rotate()
{
await SeedPlayout(1, version: 1, scheduleFile: "before.yml");
// The handler's context has loaded the playout at version 1 (tracked via identity map).
TvContext handlerContext = _db.CreateContext();
_ = await handlerContext.Playouts.SingleAsync(p => p.Id == 1);
// A concurrent replace-all editor bumps the same row to version 2 in the DB.
await using (TvContext other = _db.CreateContext())
{
Playout p = await other.Playouts.SingleAsync(x => x.Id == 1);
p.Version++;
await other.SaveChangesAsync();
}
(UpdateSequentialPlayoutHandler handler, Channel<IBackgroundServiceRequest> worker) =
MakeHandler(new PreTrackedFactory(handlerContext));
// Must not throw (plain SaveChangesAsync would 500 here) …
Either<BaseError, PlayoutNameViewModel> result = await handler.Handle(
new UpdateSequentialPlayout(1, "after.yml"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
// … and the force-write must rebase our bump ON TOP of the concurrent bump (1 →(other) 2 →(ours) 3):
// adopting the stored token verbatim would leave the concurrent writer's ETag ("2") valid and the
// #269 rotation silently lost under race.
(await ReadVersion(1)).ShouldBe(3);
worker.Reader.Count.ShouldBe(1); // the genuine change still refreshes the channel
}
}
+24 -8
View File
@@ -446,8 +446,10 @@ exception, so it needs no guard).
makes EF append `WHERE Version=@orig` to *every* UPDATE **and DELETE** of the root — so a plain
`SaveChangesAsync` writer that is *not* part of the If-Match contract throws an unhandled
`DbUpdateConcurrencyException`→**500** when a replace-all editor bumps the row in its load→save window. Every
such writer now saves through `SaveChangesForcingVersion(ct)` (Phase-1 force-write: adopt the stored token and
retry; rethrow only if the row was genuinely deleted out from under it). **The exposure filter is "any
such writer now saves through `SaveChangesForcingVersion(ct)` (Phase-1 force-write: rebase onto the stored
token — original = stored, current = stored + the pending delta, so a `Version`-bumper's rotation still
advances the ETag *past* the concurrent writer's value instead of silently adopting it — and retry; rethrow
only if the row was genuinely deleted out from under it). **The exposure filter is "any
handler that leaves a versioned root `Modified` or `Deleted` on plain `SaveChangesAsync`", NOT just
`Version`-bumpers + deletes** — `ErasePlayoutHistoryHandler` modifies Playout scalars *without* bumping and
is exposed too. Covered (18 writers): the **9 aggregate delete handlers** (a delete has no ETag to rotate, so
@@ -467,12 +469,26 @@ config is correct. (2) **Item-add force-write can leave a duplicate/gap `Index`*
Phase-1 effect): the index is computed from the handler's stale child list, so a concurrent replace-all that
grew the list makes the add land at a colliding index (no unique constraint). Non-corrupting, self-correcting
on next edit, strictly better than the pre-#269 500; a reload-and-recompute-on-conflict refinement is a #197
candidate. **Still deferred to #197
(cross-editor ETag rotation only):** the non-bumping config siblings
(`RemoveItemsFromCollectionHandler`, `UpdatePlayoutHandler`'s `DailyRebuildTime`, and the repository-mediated
`Add*ToCollection` family, shared with the scanner hot path) don't bump `Version`, so editing through them
doesn't rotate an open editor's ETag. That is an invalidation-completeness refinement — not a lost-update or a
500; the primary endpoints' own bump+guard fully cover the two-tab lost-update this contract targets.
candidate.
**Cross-editor ETag rotation — completed (#269).** The remaining non-bumping config siblings now rotate the
aggregate ETag so a concurrent editor of the *same root* invalidates on its next save: the Collection
`Add*ToCollection` family (all 11 handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`,
and `UpdateCollectionHandler` (name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three
`ScheduleFile` writers — which already force-wrote — now also bump. All rotate via `SaveChangesForcingVersion`
(they take no `If-Match`, so a concurrent bump force-writes, never 412/500). **Correction:** the
`Add*ToCollection` family is *not* repository-mediated — each handler loads the `Collection` into its own
`dbContext` and writes directly (`IMediaCollectionRepository` is read-only), so the rotation bump is purely an
API-layer concern and the scanner's separate membership-write path is unaffected (a background scan does not
rotate the editor ETag). **No-op idempotence (the trap):** these handlers gate their reindex/`BuildPlayout`
fan-out on `SaveChanges() > 0`; an *unconditional* bump makes that gate always-true, so an idempotent re-add /
same-value re-submit would fire spurious rebuilds. Each therefore short-circuits a genuine no-op **before** the
bump — the Add handlers by an explicit membership check (also fixing the latent duplicate-`CollectionItem`
insert on a *sequential* re-add; two *concurrent* adds of the same item can still both pass the check and the
loser 500s on the composite-PK violation — a narrow, pre-existing race, tracked as #308), the scalar
writers by `ChangeTracker.HasChanges()` — so a no-op neither bumps nor rebuilds. This is an
invalidation-completeness refinement; the primary endpoints' own bump+guard already covered the two-tab
lost-update the contract targets.
## 7b. Post-commit side effects run on `CancellationToken.None`
+56
View File
@@ -75,6 +75,7 @@ keep append-only from accreting stale, contradictory, or unreadably-large histor
- [2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)](#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h45)
- [2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)](#2026-07-12--decisionsmd-is-append-only-enforced-root-screenshot-guard-303-h9h3)
- [2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)](#2026-07-12--review-verdict-merge-gate-latest-commit-must-be-reviewed-303-h10)
- [2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)](#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269)
---
@@ -1409,3 +1410,58 @@ Scope: the Claude PreToolUse gate on the Gitea merge tool only. A direct `git pu
PR comments to check, so the `.husky/pre-push` backstop is not extended for H10 (the merge tool is the
real merge path; docs-only PRs remain exempt via H6's file-set exemption). Rationale, as with the whole
Wave-1/2/3 hook set: make the process rule a derivation/hook, not prose to remember (#303).
---
## 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)
The #253 optimistic-concurrency contract (§7a) had a documented tail: the non-If-Match config-sibling
writers of a versioned root mutated editor-visible state **without** bumping `Version`, so editing through
them did not rotate an open editor's ETag (a cross-editor invalidation gap — never a lost-update or a 500,
which the primary endpoints' bump+guard already cover). #269's first slice (PR #302) removed the 500 exposure
by routing those writers through `SaveChangesForcingVersion`; this slice completes the **rotation**.
Handlers now bumping `Version` (all via `SaveChangesForcingVersion`, since they take no `If-Match` → a
concurrent replace-all bump force-writes, never 412/500): the Collection `Add*ToCollection` family (11
handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`; `UpdateCollectionHandler`
(name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three `ScheduleFile` writers
(`UpdateSequential`/`UpdateScripted`/`UpdateExternalJsonPlayout`) — which already force-wrote — now also bump.
Decisions frozen (ratified with Fable before implementation, feeding the #197 contract freeze):
- **Rotate on every editor-visible config change, no per-aggregate carve-outs.** §7a's config-only boundary
("every mutating handler of a root's editor-visible config bumps `Version`") already held for Playlist
`Add*`/schedule item writers; the Collection/Playout siblings were an inconsistency, not a judgment call. A
membership add rotating an open custom-order editor's ETag (→ 412 → reload) is correct: its list is genuinely
stale. Blast radius of the aggressive-but-safe rotation is a reload, never data loss.
- **No-op idempotence — the trap Fable caught.** These handlers gate their reindex/`BuildPlayout` fan-out on
`SaveChanges() > 0`. An *unconditional* bump makes that gate always-true, so an idempotent re-add / same-value
re-submit would fire spurious rebuilds across every playout using the aggregate. Fix: short-circuit a genuine
no-op **before** the bump — the Add handlers by an explicit membership check (which also fixes the latent
duplicate-`CollectionItem` insert on a *sequential* re-add; two *concurrent* same-item adds can still both
pass the check and the loser 500s on the composite-PK unique violation — `SaveChangesForcingVersion` catches
only `DbUpdateConcurrencyException`, not `DbUpdateException`. That race is narrow and pre-existing, deferred
to #308), the scalar writers (`UpdateCollection`, `UpdatePlayout`, the
three `ScheduleFile` writers) by `ChangeTracker.HasChanges()`. A no-op neither bumps nor rebuilds nor rotates
the ETag — which is itself correct (nothing changed).
- **The `Add*ToCollection` family is not repository-mediated.** #269's original framing ("repository-mediated,
shared with the scanner hot path") was wrong: `IMediaCollectionRepository` is read-only; each handler loads
the `Collection` into its own `dbContext` and writes directly. So the rotation bump is a pure API-layer
concern and the scanner's separate membership-write path is untouched — a background scan does **not** rotate
the editor ETag (correct: background indexing is not an editor action).
- **Force-write rebases the bump, never adopts the stored token verbatim (Codex review of this PR).**
`SaveChangesForcingVersion` originally resolved a conflict by setting current=original=stored — which
silently *discarded* a sibling's pending `Version++` when a versioned writer committed in its load→save
window (sibling loads 1, bumps to pending 2, concurrent PUT commits 2 → retry wrote 2, so the concurrent
writer's ETag "2" stayed valid and the rotation was lost under exactly the race it exists for). Fixed in
this PR (it affects all 25 bumpers routed through the helper, including the pre-existing playlist/schedule
ones): the retry now rebases — original = stored, current = stored + (pending current − pending original) —
so a bumper lands at stored+1 and a non-bumper (delta 0, e.g. `ErasePlayoutHistory`) adopts stored unchanged.
The race tests assert the post-race Version (3, not 2) and fail against the verbatim-adopt implementation.
- **No new status codes.** These endpoints take no `If-Match` and force-write, so they never 412; no
`[ProducesResponseType(...412...)]` and no OpenAPI regen (response types unchanged). Only §7a prose changes.
Tests: `CollectionEtagRotationTests` (rotation + no-op-without-bump-or-rebuild + force-write-past-concurrent-bump
for Add/Remove/Update) and `PlayoutScheduleFileEtagRotationTests` (ScheduleFile rotation + no-op-without-refresh),
the no-op guard proven non-vacuous by inverting the membership check. The `#265` RFC-7232 If-Match parser
refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate** PR (disjoint surface: the shared
parser + `CheckVersion`, not the handler saves). Refs #253 #269 #197 · `api-conventions.md` §7a.