Implement merge-based concurrency conflict resolution in DbContext

This commit is contained in:
2026-07-09 17:16:32 +00:00
parent b04c6c23f7
commit 9081491974
2 changed files with 564 additions and 14 deletions
@@ -353,9 +353,10 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
}
catch (DbUpdateConcurrencyException ex)
{
// Concurrency conflicts can occur when marking items as played in quick succession.
// To resolve this, we'll retry the operation with an exponential backoff strategy.
logger.LogWarning(ex, "Concurrency exception, retrying operation with exponential backoff.");
// Concurrency conflicts can occur when marking items as played/unplayed concurrently.
// Strategy: Three-way merge of original → client → database values for intelligent conflict resolution.
// Reference: https://learn.microsoft.com/en-us/ef/core/saving/concurrency#resolving-concurrency-conflicts
logger.LogWarning(ex, "Concurrency exception detected, attempting three-way merge-based conflict resolution with exponential backoff.");
var maxRetries = 3;
var delay = TimeSpan.FromMilliseconds(100);
@@ -371,37 +372,49 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
{
try
{
// Attempt to reload the entity from the database to get the latest version
// This will fail silently for deleted entities
// Step 1: Capture the client's pending changes before reload
// These are the values the client tried to save
var clientValues = entry.CurrentValues.Clone();
// Step 2: Capture the original values (what client loaded from DB)
// We need this BEFORE reload to see what changed on client side
var originalValues = entry.OriginalValues.Clone();
// Step 3: Reload the entity from database to get latest values
// This updates entry.CurrentValues with current database state
await entry.ReloadAsync(cancellationToken).ConfigureAwait(false);
// Check if the entity still exists in the database
// If it was deleted, it will have a null entity state after reload
if (entry.State == EntityState.Detached)
{
logger.LogInformation("Entity {EntityType} was deleted by another operation, skipping update.", entry.Entity.GetType().Name);
logger.LogInformation("Entity {EntityType} was deleted by another operation, skipping merge.", entry.Entity.GetType().Name);
entriesToRemove.Add(entry);
}
else
{
// Re-mark as Modified since reload resets the state
// Step 4: Perform three-way merge
// For each property, determine: client changed it? DB changed it? Apply accordingly
PerformThreeWayMerge(entry, originalValues, clientValues);
// Step 5: Mark as modified and increment concurrency token
entry.State = EntityState.Modified;
// Re-apply concurrency token update for entities that still exist
if (entry.Entity is IHasConcurrencyToken concurrencyEntity)
{
concurrencyEntity.OnSavingChanges();
}
logger.LogDebug("Three-way merge completed for {EntityType}", entry.Entity.GetType().Name);
}
}
catch (Exception reloadEx)
{
logger.LogWarning(reloadEx, "Failed to reload entity {EntityType}, treating as deleted.", entry.Entity.GetType().Name);
logger.LogWarning(reloadEx, "Failed to merge entity {EntityType}, treating as deleted.", entry.Entity.GetType().Name);
entriesToRemove.Add(entry);
}
}
// Detach entries that were deleted so they're not included in the retry save
// Detach entries that were deleted or couldn't be merged
foreach (var entryToRemove in entriesToRemove)
{
this.Entry(entryToRemove.Entity).State = EntityState.Detached;
@@ -414,18 +427,18 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
return 0; // Return 0 to indicate no rows were affected
}
// Retry saving changes after resolving conflicts
// Retry saving changes after merging conflicts
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false);
if (result >= 0)
{
logger.LogInformation("Concurrency retry {RetryCount} succeeded, saved {RowsAffected} row(s).", i + 1, result);
logger.LogInformation("Concurrency merge retry {RetryCount} succeeded, saved {RowsAffected} row(s).", i + 1, result);
}
return result;
}
catch (DbUpdateConcurrencyException retryEx) when (i < maxRetries - 1)
{
logger.LogWarning(retryEx, "Concurrency exception on retry {RetryCount}, delaying for {Delay}ms.", i + 1, delay.TotalMilliseconds);
logger.LogWarning(retryEx, "Concurrency conflict persisted on merge retry {RetryCount}, retrying with backoff ({Delay}ms).", i + 1, delay.TotalMilliseconds);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
delay *= 2; // Exponential backoff
}
@@ -501,6 +514,120 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
}
}
/// <summary>
/// Performs a three-way merge to intelligently resolve concurrency conflicts.
/// Merges original (loaded) → client (attempted) → database (current) values.
///
/// Algorithm for each property:
/// - If client changed it AND database didn't → use client value
/// - If database changed it AND client didn't → use database value
/// - If both changed it → conflict detected, use database value (safe default)
/// - If neither changed it → no change needed
/// </summary>
/// <param name="entry">The conflicted entry from the change tracker.</param>
/// <param name="originalValues">The original values when client loaded the entity.</param>
/// <param name="clientValues">The values the client attempted to save.</param>
private void PerformThreeWayMerge(
Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry,
Microsoft.EntityFrameworkCore.ChangeTracking.PropertyValues originalValues,
Microsoft.EntityFrameworkCore.ChangeTracking.PropertyValues clientValues)
{
try
{
var properties = entry.Metadata.GetProperties();
var mergedCount = 0;
var conflictCount = 0;
foreach (var property in properties)
{
// Skip concurrency tokens and key properties - these are handled separately
if (property.IsPrimaryKey() || property.Name.Equals("RowVersion", StringComparison.Ordinal))
{
continue;
}
try
{
var originalValue = originalValues[property];
var clientValue = clientValues[property];
var databaseValue = entry.CurrentValues[property];
var originalEqualsClient = ValuesEqual(originalValue, clientValue);
var originalEqualsDatabase = ValuesEqual(originalValue, databaseValue);
var clientEqualsDatabase = ValuesEqual(clientValue, databaseValue);
if (originalEqualsClient && originalEqualsDatabase)
{
// No changes on either side - nothing to do
continue;
}
if (!originalEqualsClient && originalEqualsDatabase)
{
// Client changed, database didn't → Apply client change
entry.CurrentValues[property] = clientValue;
mergedCount++;
logger.LogDebug("Merged {Property}: client changed, database didn't. Applied client value.", property.Name);
}
else if (originalEqualsClient && !originalEqualsDatabase)
{
// Database changed, client didn't → Keep database value (already set)
mergedCount++;
logger.LogDebug("Merged {Property}: database changed, client didn't. Kept database value.", property.Name);
}
else if (!originalEqualsClient && !originalEqualsDatabase && !clientEqualsDatabase)
{
// Both sides changed AND they're different → Conflict!
// Strategy: Keep database value as it's more recent
conflictCount++;
logger.LogWarning("Conflict on {Property}: both client and database changed to different values. Keeping database value.", property.Name);
}
else if (!originalEqualsClient && !originalEqualsDatabase && clientEqualsDatabase)
{
// Both sides changed but ended up at same value → No conflict, already correct
mergedCount++;
logger.LogDebug("Merged {Property}: both changed to same value. No conflict.", property.Name);
}
}
catch (Exception propEx)
{
logger.LogWarning(propEx, "Error merging property {Property}, skipping.", property.Name);
}
}
if (conflictCount > 0)
{
logger.LogInformation("Three-way merge completed with {MergedCount} successful merges and {ConflictCount} conflicts (database values preserved).", mergedCount, conflictCount);
}
else
{
logger.LogInformation("Three-way merge completed successfully: {MergedCount} property merges applied.", mergedCount);
}
}
catch (Exception mergeEx)
{
logger.LogWarning(mergeEx, "Error during three-way merge. Proceeding with current database values.");
}
}
/// <summary>
/// Compares two values for equality, handling nulls correctly.
/// </summary>
private static bool ValuesEqual(object? value1, object? value2)
{
if (ReferenceEquals(value1, value2))
{
return true;
}
if (value1 == null || value2 == null)
{
return false;
}
return value1.Equals(value2);
}
/// <summary>
/// Gets the recommended batch size for bulk operations based on the number of items to process.
/// Smaller batches during library scans reduce dead tuple generation in PostgreSQL.