//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.Persistence.EntityFramework;
using System.Collections;
using System.Reflection;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Composition;
using MUnique.OpenMU.DataModel.Configuration;
using Nito.AsyncEx;
using Nito.Disposables;
///
/// Abstract base class for an which uses an .
///
internal class EntityFrameworkContextBase : IContext
{
private readonly bool _isOwner;
private readonly IConfigurationChangeListener? _changeListener;
private readonly AsyncLock _lock = new();
private readonly ILogger _logger;
private bool _isDisposed;
private int _notificationSuspensions;
///
/// Initializes a new instance of the class.
///
/// The db context.
/// The repository provider.
/// If set to true, this instance owns the . That means it will be disposed when this instance will be disposed.
/// The change listener.
/// The logger.
protected EntityFrameworkContextBase(DbContext context, IContextAwareRepositoryProvider repositoryProvider, bool isOwner, IConfigurationChangeListener? changeListener, ILogger logger)
{
this.Context = context;
this.RepositoryProvider = repositoryProvider;
this._isOwner = isOwner;
this._changeListener = changeListener;
this._logger = logger;
// Ensure that the model is created.
_ = context.Model;
}
///
/// Finalizes an instance of the class.
///
~EntityFrameworkContextBase() => this.Dispose(false);
///
public bool HasChanges => this.Context.ChangeTracker.HasChanges();
///
/// Gets the entity framework context.
///
internal DbContext Context { get; }
///
/// Gets the repository provider.
///
protected IContextAwareRepositoryProvider RepositoryProvider { get; }
///
public async ValueTask SaveChangesAsync(CancellationToken cancellationToken = default)
{
// A player's entities can be mutated by game logic on a flow that is not serialized against
// this save (for example item destruction on an attacker's thread during combat). Such a
// concurrent mutation makes change detection throw while it enumerates a tracked collection.
// The mutation is a single, quick operation, so a bounded retry lands on a stable moment
// instead of failing the whole save - which would otherwise leave the session unpersisted and
// roll the player back on relog.
const int maxAttempts = 3;
var attempt = 0;
while (true)
{
attempt++;
try
{
return await this.SaveChangesCoreAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (attempt < maxAttempts && IsTransientConcurrencyConflict(ex))
{
this._logger.LogWarning(ex, "Transient concurrency conflict while saving (attempt {Attempt}/{MaxAttempts}); retrying.", attempt, maxAttempts);
await Task.Delay(attempt * 10, cancellationToken).ConfigureAwait(false);
}
}
}
///
/// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
/// racing this save, and is therefore worth retrying.
///
/// The exception thrown by the save.
/// true if the save should be retried.
private static bool IsTransientConcurrencyConflict(Exception exception)
{
// A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration.
// Depending on exactly where change detection was, it surfaces as one of several types - a
// modified collection (InvalidOperationException), a transiently-null internal key
// (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient:
// the racing mutation is a single quick operation, so a bounded retry lands on a stable moment.
// A genuinely persistent error of the same type is not masked - it rethrows once the retries
// are exhausted. The deterministic serialization (per-player persistence lock) is the primary
// guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for.
return exception is DbUpdateConcurrencyException
or InvalidOperationException
or ArgumentNullException
or NullReferenceException
or IndexOutOfRangeException
or KeyNotFoundException;
}
private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken)
{
using var l = await this._lock.LockAsync();
// when we have a change publisher attached, we want to get the changed entries before accepting them.
// Otherwise, we can accept them.
var acceptChanges = true;
object? sender = null;
SavedChangesEventArgs? args = null;
if (this._changeListener is { })
{
this.Context.SavedChanges += OnSavedChanges;
acceptChanges = false;
}
try
{
await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false);
if (args is not null)
{
await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false);
}
}
finally
{
this.Context.SavedChanges -= OnSavedChanges;
}
return true;
void OnSavedChanges(object? s, SavedChangesEventArgs e)
{
sender = s;
args = e;
}
}
///
public IDisposable SuspendChangeNotifications()
{
Interlocked.Increment(ref this._notificationSuspensions);
return new Disposable(() => Interlocked.Decrement(ref this._notificationSuspensions));
}
///
public bool Detach(object item)
{
using var l = this._lock.Lock();
return this.DetachInternal(item);
}
///
public void Attach(object item)
{
using var l = this._lock.Lock();
this.Context.Attach(item);
}
///
public T CreateNew(params object?[] args)
where T : class
{
using var l = this._lock.Lock();
var instance = typeof(CachingEntityFrameworkContext).Assembly.CreateNew(args);
this.Context.Add(instance);
return instance;
}
///
public object CreateNew(Type type, params object?[] args)
{
using var l = this._lock.Lock();
var instance = typeof(CachingEntityFrameworkContext).Assembly.CreateNew(type, args);
this.Context.Add(instance);
return instance;
}
///
public async ValueTask DeleteAsync(T obj)
where T : class
{
using var l = await this._lock.LockAsync();
var result = false;
var entry = this.Context.Entry(obj);
if (entry.State == EntityState.Detached)
{
this.Context.Attach(obj);
entry = this.Context.Entry(obj);
}
switch (entry.State)
{
case EntityState.Detached:
return true;
case EntityState.Added:
this.DetachInternal(obj);
break;
default:
this.Context.Remove(obj);
this.ForEachAggregate(obj, a => this.Context.Remove(a));
break;
}
result = true;
return result;
}
///
public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken)
where T : class
{
using var l = await this._lock.LockAsync(cancellationToken);
using var context = this.RepositoryProvider.ContextStack.UseContext(this);
return await this.GetRepository().GetByIdAsync(id, cancellationToken).ConfigureAwait(false);
}
///
public async Task