feat(253): PR3 optimistic-concurrency fan-out — Diff + Scalar aggregates
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Fans the frozen Block recipe (api-conventions §7a) across the five Diff/Scalar
replace-all endpoints, completing the #253 PR2→PR4 arc's implementable core:

- #6 Collection custom-order, #7 Playout alternate-schedules, #8 Playout templates
  (shared Playout.Version), #9 MultiCollection, #10 RerunCollection — each: pre-check
  412 as a standalone Either after validation (H2, subtype survives the Join flatten),
  unconditional Version++ (M1), guarded save, controller If-Match/ETag/400/412, SPA
  editor ETag round-trip + 412 conflict dialog.
- H1: the two Playout handlers' catch(Exception)→422 restructured so the guard's
  PreconditionFailedError returns before the catch (412, not 422).
- M2: RerunCollection/Collection refresh runs unconditionally on save; MultiCollection
  keeps its name-only→no-rebuild optimization by bumping on the first (name) save.
- H3: UpdateDefaultDecoHandler bulk-bumps Playout.Version via .SetProperty.
- Shared ConcurrencyHeaders.MalformedIfMatchProblem() for the 400 guard.
- Deferred (→ #269): same-root non-bulk sibling config writers' ETag rotation.

Tests: per-handler pre-check/bump concurrency tests (Playout ×2 incl. non-vacuous
racing-save backstop, Rerun, Multi incl. name-only-no-rebuild M2, Collection);
controller tests get a DefaultHttpContext for the header read/write. Docs:
api-conventions §7a fan-out status, spa-conventions §4a list-editor note, decisions.

Refs #253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 19:23:48 +02:00
co-authored by Claude Opus 4.8
parent 13cd00c8fe
commit c40ffefa99
56 changed files with 1349 additions and 130 deletions
@@ -49,6 +49,15 @@ public class CollectionController(IMediator mediator) : ControllerBase
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, 100);
// The items GET is the reorder editor's load endpoint: emit the collection's version as the
// concurrency ETag (issue #253). The response carries children, so read the root separately.
Option<MediaCollectionViewModel> maybeCollection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
foreach (MediaCollectionViewModel collection in maybeCollection)
{
ConcurrencyHeaders.SetETag(Response, collection.Version);
}
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result = await mediator.Send(
new GetCollectionItems(id, clampedPageNum, clampedPageSize),
cancellationToken);
@@ -105,13 +114,21 @@ public class CollectionController(IMediator mediator) : ControllerBase
"UseCustomPlaybackOrder on the collection for this order to take effect.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateCustomOrder(
int id,
[Required] [FromBody] UpdateCollectionCustomOrderRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return ConcurrencyHeaders.MalformedIfMatchProblem();
}
Option<MediaCollectionViewModel> maybeCollection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
if (maybeCollection.IsNone)
@@ -119,8 +136,18 @@ public class CollectionController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToDeletedResult();
Either<BaseError, Unit> result =
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
// Emit the new ETag on the bodyless 204 so a same-tab second save doesn't 412 (#253).
Option<MediaCollectionViewModel> refreshed =
await mediator.Send(new GetCollectionById(id), cancellationToken);
ConcurrencyHeaders.SetETag(Response, refreshed.Map(c => c.Version).IfNone(0));
return (IActionResult)new NoContentResult();
});
}
[HttpDelete("/api/collections/{id:int}")]
@@ -48,6 +48,13 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
{
Option<MultiCollectionViewModel> result =
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
// This by-id GET is the editor's load endpoint: emit the concurrency ETag (issue #253).
foreach (MultiCollectionViewModel vm in result)
{
ConcurrencyHeaders.SetETag(Response, vm.Version);
}
return result.Map(ProjectToResponseModel).ToGetResult();
}
@@ -74,14 +81,23 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
[EndpointSummary("Update a multi collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateMultiCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return ConcurrencyHeaders.MalformedIfMatchProblem();
}
Either<BaseError, Unit> result =
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
@@ -89,7 +105,12 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
Option<MultiCollectionViewModel> multiCollection =
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
return multiCollection.Match(
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
Some: vm =>
{
// Emit the new ETag so a same-tab second save doesn't 412 against its own write (#253).
ConcurrencyHeaders.SetETag(Response, vm.Version);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(vm));
},
None: () => ApiResults.NotFoundProblem());
});
}
+35 -2
View File
@@ -292,6 +292,9 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
}
}
// The GET returns children, so read the playout's version for the concurrency ETag (issue #253).
ConcurrencyHeaders.SetETag(Response, maybePlayout.Map(p => p.Version).IfNone(0));
List<PlayoutAlternateScheduleViewModel> items =
await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken);
return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList());
@@ -307,14 +310,22 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
"at least one item, and every ProgramScheduleId must exist. Only valid for Classic playouts.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceAlternateSchedules(
int id,
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return ConcurrencyHeaders.MalformedIfMatchProblem();
}
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
@@ -355,11 +366,17 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
return BaseError.New($"[ProgramScheduleId] {missingScheduleIds[0]} does not exist").ToErrorResult();
}
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
Either<BaseError, Unit> result =
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
// Emit the new ETag (post-bump) so a same-tab second save doesn't 412 against its own write (#253).
Option<PlayoutNameViewModel> refreshedPlayout =
await mediator.Send(new GetPlayoutById(id), cancellationToken);
ConcurrencyHeaders.SetETag(Response, refreshedPlayout.Map(p => p.Version).IfNone(0));
List<PlayoutAlternateScheduleViewModel> refreshed =
await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken);
return (IActionResult)new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList());
@@ -392,6 +409,9 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
}
}
// The GET returns children, so read the playout's version for the concurrency ETag (issue #253).
ConcurrencyHeaders.SetETag(Response, maybePlayout.Map(p => p.Version).IfNone(0));
List<PlayoutTemplateViewModel> items = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken);
return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList());
}
@@ -405,14 +425,22 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
"supplied DecoTemplateId must exist. An empty list clears all templates. Only valid for Block playouts.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceTemplates(
int id,
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return ConcurrencyHeaders.MalformedIfMatchProblem();
}
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
@@ -460,12 +488,17 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
}
}
Option<BaseError> result = await mediator.Send(request.ToCommand(id), cancellationToken);
Option<BaseError> result =
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
foreach (BaseError error in result)
{
return error.ToErrorResult();
}
// Emit the new ETag (post-bump) so a same-tab second save doesn't 412 against its own write (#253).
Option<PlayoutNameViewModel> refreshedPlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
ConcurrencyHeaders.SetETag(Response, refreshedPlayout.Map(p => p.Version).IfNone(0));
List<PlayoutTemplateViewModel> refreshed = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken);
return new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList());
}
@@ -8,10 +8,11 @@ public record ReplacePlayoutAlternateSchedulesRequest(List<PlayoutAlternateSched
// is the lowest priority (the catch-all default whose schedule becomes the playout's default
// schedule). This mirrors the Blazor editor, which lists items top-to-bottom in priority order
// and writes the highest-Index item's schedule as the playout default.
public ReplacePlayoutAlternateScheduleItems ToCommand(int playoutId) =>
public ReplacePlayoutAlternateScheduleItems ToCommand(int playoutId, Option<int> expectedVersion = default) =>
new(
playoutId,
(Items ?? [])
.Select((item, index) => item.ToReplaceItem(index))
.ToList());
.ToList(),
expectedVersion);
}
@@ -5,10 +5,11 @@ namespace ErsatzTV.Controllers.Api.Requests;
public record ReplacePlayoutTemplatesRequest(List<PlayoutTemplateItemRequest> Items)
{
// Index is assigned from array order (top-to-bottom priority), mirroring the Blazor editor.
public ReplacePlayoutTemplateItems ToCommand(int playoutId) =>
public ReplacePlayoutTemplateItems ToCommand(int playoutId, Option<int> expectedVersion = default) =>
new(
playoutId,
(Items ?? [])
.Select((item, index) => item.ToReplaceItem(index))
.ToList());
.ToList(),
expectedVersion);
}
@@ -4,10 +4,11 @@ namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateCollectionCustomOrderRequest(List<int> MediaItemIds)
{
public UpdateCollectionCustomOrder ToCommand(int collectionId) =>
public UpdateCollectionCustomOrder ToCommand(int collectionId, Option<int> expectedVersion = default) =>
new(
collectionId,
(MediaItemIds ?? [])
.Select((mediaItemId, index) => new MediaItemCustomOrder(mediaItemId, index))
.ToList());
.ToList(),
expectedVersion);
}
@@ -6,7 +6,7 @@ namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
{
public UpdateMultiCollection ToCommand(int id) =>
public UpdateMultiCollection ToCommand(int id, Option<int> expectedVersion = default) =>
new(
id,
Name,
@@ -16,5 +16,6 @@ public record UpdateMultiCollectionRequest(string Name, List<MultiCollectionItem
i.SmartCollectionId,
i.ScheduleAsGroup,
i.PlaybackOrder))
.ToList());
.ToList(),
expectedVersion);
}
@@ -11,7 +11,7 @@ public record UpdateRerunCollectionRequest(
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder)
{
public UpdateRerunCollection ToCommand(int id)
public UpdateRerunCollection ToCommand(int id, Option<int> expectedVersion = default)
{
(MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
@@ -28,6 +28,7 @@ public record UpdateRerunCollectionRequest(
smartCollection,
mediaItem,
FirstRunPlaybackOrder,
RerunPlaybackOrder);
RerunPlaybackOrder,
expectedVersion);
}
}
@@ -48,6 +48,13 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
{
Option<RerunCollectionViewModel> result =
await mediator.Send(new GetRerunCollectionById(id), cancellationToken);
// This by-id GET is the editor's load endpoint: emit the concurrency ETag (issue #253).
foreach (RerunCollectionViewModel vm in result)
{
ConcurrencyHeaders.SetETag(Response, vm.Version);
}
return result.Map(ProjectToResponseModel).ToGetResult();
}
@@ -81,13 +88,21 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
[EndpointSummary("Update a rerun collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateRerunCollectionRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return ConcurrencyHeaders.MalformedIfMatchProblem();
}
if (!RerunCollectionRequestMapping.IsSupportedSelectionType(request.CollectionType))
{
return BaseError.New(
@@ -95,7 +110,8 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
.ToErrorResult();
}
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
Either<BaseError, Unit> result =
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
@@ -103,7 +119,12 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
Option<RerunCollectionViewModel> rerunCollection =
await mediator.Send(new GetRerunCollectionById(id), cancellationToken);
return rerunCollection.Match(
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
Some: vm =>
{
// Emit the new ETag so a same-tab second save doesn't 412 against its own write (#253).
ConcurrencyHeaders.SetETag(Response, vm.Version);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(vm));
},
None: () => ApiResults.NotFoundProblem());
});
}