Files
ersatztv/ErsatzTV.Application/Playouts/Commands/UpdateOnDemandCheckpointHandler.cs
T
timothyandClaude Opus 4.8 e61f58cd99
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 2m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(253): force-write non-participating root writers past a concurrent Version bump (PR3 review)
Adversarial review caught a HIGH the fan-out introduced: activating Version as an
IsConcurrencyToken on Playout/Collection makes EF append `WHERE Version=@orig` to
EVERY root UPDATE, so a non-If-Match writer that saves via plain SaveChangesAsync
now throws DbUpdateConcurrencyException → 500 when a replace-all editor bumps the
row between its load and save. Realistic two-tab trigger (edit playout settings while
editing its alt-schedules; edit a collection's name while reordering) — a new crash,
previously silent last-write-wins.

Fix: shared ConcurrencyExtensions.SaveChangesForcingVersion — on a concurrency
failure it adopts the stored token as original+current (client-wins merge scoped to
the token, never reverting the concurrent bump) and retries, i.e. Phase-1 force-write
semantics for a missing If-Match. Applied to the exposed UPDATE writers:
UpdatePlayout, Update{Sequential,Scripted,ExternalJson}Playout, UpdateOnDemandCheckpoint,
UpdateCollection. Non-vacuous test proves the write lands and the bump survives.

Deletes + repo-mediated Add* writers (rarer / join-rows-only) re-scoped onto #269.

Refs #253 #269

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:36:57 +02:00

71 lines
2.8 KiB
C#

using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Playouts;
public class UpdateOnDemandCheckpointHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ILogger<UpdateOnDemandCheckpointHandler> logger)
: IRequestHandler<UpdateOnDemandCheckpoint>
{
public async Task Handle(UpdateOnDemandCheckpoint request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> maybePlayout = await dbContext.Playouts
.Include(p => p.Channel)
.Include(p => p.Items)
.SelectOneAsync(p => p.Channel.Number, p => p.Channel.Number == request.ChannelNumber, cancellationToken);
foreach (Playout playout in maybePlayout)
{
if (playout.Channel.PlayoutMode is not ChannelPlayoutMode.OnDemand)
{
return;
}
playout.OnDemandCheckpoint ??= SystemTime.MinValueUtc;
int timeout = await (await configElementRepository.GetValue<int>(
ConfigElementKey.FFmpegSegmenterTimeout,
cancellationToken))
.IfNoneAsync(60);
// don't move checkpoint back in time
DateTimeOffset newCheckpoint = request.Checkpoint - TimeSpan.FromSeconds(timeout);
if (newCheckpoint > playout.OnDemandCheckpoint)
{
playout.OnDemandCheckpoint = newCheckpoint;
}
// don't checkpoint before the first item
// this could happen if you watch a new playout for less time than the segmenter timeout
if (playout.Items.Count > 0)
{
DateTimeOffset minStart = playout.Items.Min(p => p.StartOffset);
if (playout.OnDemandCheckpoint < minStart)
{
playout.OnDemandCheckpoint = minStart;
}
}
logger.LogDebug(
"Updating on demand checkpoint for channel {Number} - {Name} to {Checkpoint}",
playout.Channel.Number,
playout.Channel.Name,
playout.OnDemandCheckpoint);
// Force-write past a concurrent Version bump from a replace-all editor: the on-demand
// checkpoint writer touches the Playout root but doesn't participate in If-Match, so the
// active concurrency token must not turn a benign race into a 500 (#253/#269).
await dbContext.SaveChangesForcingVersion(cancellationToken);
}
}
}