using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace ErsatzTV.Application;
public static class ConcurrencyExtensions
{
///
/// Persist changes that touch a versioned root but do not participate in the If-Match
/// contract (e.g. a playout's settings/schedule-file/on-demand-checkpoint writer, a collection's
/// name edit). Because the root's Version is an IsConcurrencyToken, EF guards every
/// UPDATE of that row with WHERE Version=@orig, so a concurrent bump from a replace-all
/// editor would otherwise surface as an unhandled →
/// 500 (issue #253 / #269). Phase-1 semantics for a missing If-Match is force-write,
/// 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.
///
public static async Task SaveChangesForcingVersion(
this DbContext dbContext,
CancellationToken cancellationToken)
{
for (var attempt = 0; ; attempt++)
{
try
{
return await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException ex) when (attempt < 5)
{
var resolvedAny = false;
foreach (EntityEntry entry in ex.Entries)
{
if (entry.Entity is not IVersionedAggregate)
{
continue;
}
PropertyValues databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken);
if (databaseValues is null)
{
// The row was deleted out from under us — a genuine conflict, not a token race.
throw;
}
PropertyEntry version = entry.Property(nameof(IVersionedAggregate.Version));
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;
}
if (!resolvedAny)
{
throw;
}
}
}
}
///
/// Like , but additionally treats a unique / primary-key
/// constraint violation as an idempotent no-op: returns false instead of throwing when the
/// save fails because a concurrent request inserted a row we had membership-checked absent (the
/// composite-PK race on CollectionItem — issue #308). A false means "the desired row
/// already exists because a racing writer won; the winner ran the ETag rotation + fan-out, so skip
/// ours." true means our own change committed. Every other
/// (and the genuine deleted-row concurrency conflict rethrown by )
/// still propagates. The only insert these callers stage is the CollectionItem join row, so the
/// sole unique/PK constraint that can fire here is that composite key.
///
public static async Task TrySaveChangesForcingVersion(
this DbContext dbContext,
CancellationToken cancellationToken)
{
try
{
await dbContext.SaveChangesForcingVersion(cancellationToken);
return true;
}
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
{
return false;
}
}
///
/// Persist pending changes, mapping the EF optimistic-concurrency failure to
/// (→ 412). When a versioned root carries an
/// IsConcurrencyToken column and its Version is bumped before saving, EF emits
/// UPDATE … WHERE Id=@id AND Version=@original; a zero-row result (another writer won
/// the race between our load and save) throws .
/// This is the backstop that closes the load→save TOCTOU the handler pre-check cannot.
/// Issue #253.
///
public static async Task> SaveChangesWithConcurrencyGuard(
this DbContext dbContext,
CancellationToken cancellationToken)
{
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return new PreconditionFailedError(
"The resource was modified by another request. Reload and try again.");
}
}
}