Add concurrency token to UserData and enhance retry logic for DbUpdateConcurrencyException handling

This commit is contained in:
2026-07-09 16:18:14 +00:00
parent e80dbd757b
commit 0f5015bc63
4 changed files with 855 additions and 4 deletions
@@ -7,11 +7,13 @@
namespace Jellyfin.Database.Implementations.Entities
{
using System;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Database.Implementations.Interfaces;
/// <summary>
/// Provides <see cref="BaseItemEntity"/> and <see cref="User"/> related data.
/// </summary>
public class UserData
public class UserData : IHasConcurrencyToken
{
/// <summary>
/// Gets or sets the custom data key.
@@ -99,5 +101,19 @@ namespace Jellyfin.Database.Implementations.Entities
/// Gets or Sets the User.
/// </summary>
public required User? User { get; set; }
/// <summary>
/// Gets the concurrency token used for optimistic concurrency control.
/// </summary>
[ConcurrencyCheck]
public uint RowVersion { get; private set; }
/// <summary>
/// Updates the concurrency token before saving changes.
/// </summary>
public void OnSavingChanges()
{
this.RowVersion++;
}
}
}
@@ -7,6 +7,7 @@
namespace Jellyfin.Database.Implementations;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
@@ -363,14 +364,63 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
{
try
{
// Process each conflicted entry
var entriesToRemove = new List<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry>();
foreach (var entry in ex.Entries)
{
// Reload the entity from the database to get the latest version
await entry.ReloadAsync(cancellationToken).ConfigureAwait(false);
try
{
// Attempt to reload the entity from the database to get the latest version
// This will fail silently for deleted entities
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);
entriesToRemove.Add(entry);
}
else
{
// Re-mark as Modified since reload resets the state
entry.State = EntityState.Modified;
// Re-apply concurrency token update for entities that still exist
if (entry.Entity is IHasConcurrencyToken concurrencyEntity)
{
concurrencyEntity.OnSavingChanges();
}
}
}
catch (Exception reloadEx)
{
logger.LogWarning(reloadEx, "Failed to reload 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
foreach (var entryToRemove in entriesToRemove)
{
this.Entry(entryToRemove.Entity).State = EntityState.Detached;
}
// If all entries were deleted, no point in retrying
if (entriesToRemove.Count == ex.Entries.Count && ex.Entries.Count > 0)
{
logger.LogInformation("All {Count} conflicted entities were deleted by other operations. Continuing without them.", ex.Entries.Count);
return 0; // Return 0 to indicate no rows were affected
}
// Retry saving changes after resolving conflicts
return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false);
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);
}
return result;
}
catch (DbUpdateConcurrencyException retryEx) when (i < maxRetries - 1)
{