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
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>
71 lines
2.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|