fix(api): #269 rotate aggregate ETag on Collection/Playout config siblings #307
@@ -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;
|
||||
|
||||
+21
-12
@@ -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() > 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() > 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
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user