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."); } } }