// // 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 GetByIdAsync(Guid id, Type type, CancellationToken cancellationToken) { using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false); using var context = this.RepositoryProvider.ContextStack.UseContext(this); return await this.GetRepository(type).GetByIdAsync(id, cancellationToken).ConfigureAwait(false); } /// public async ValueTask> GetAsync(CancellationToken cancellationToken) where T : class { using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false); using var context = this.RepositoryProvider.ContextStack.UseContext(this); return await this.GetRepository().GetAllAsync(cancellationToken).ConfigureAwait(false); } /// public async ValueTask GetAsync(Type type, CancellationToken cancellationToken) { using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false); using var context = this.RepositoryProvider.ContextStack.UseContext(this); return await this.GetRepository(type).GetAllAsync(cancellationToken).ConfigureAwait(false); } /// public bool IsSupporting(Type type) { var currentSearchType = type; do { if (currentSearchType is null) { break; } if (this.Context.Model.FindLeastDerivedEntityTypes(currentSearchType).FirstOrDefault() is not null) { return true; } if (currentSearchType.Name != currentSearchType.BaseType?.Name) { break; } currentSearchType = currentSearchType.BaseType; } while (currentSearchType != typeof(object)); return false; } /// public void Dispose() { if (!this._isDisposed) { this.Dispose(true); } this._isDisposed = true; GC.SuppressFinalize(this); } /// /// Determines whether changes of an entity type are published as configuration changes. /// /// The entity type. /// when the entity belongs to the configuration schema. internal static bool PublishesConfigurationChanges(IReadOnlyEntityType entityType) => entityType.GetSchema() == SchemaNames.Configuration; /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool dispose) { if (!dispose || !this._isOwner) { return; } this.Context.Dispose(); } private bool DetachInternal(object item) { var entry = this.Context.Entry(item); if (entry is null) { return false; } var previousState = entry.State; entry.State = EntityState.Detached; this.ForEachAggregate(item, obj => this.DetachInternal(obj)); return previousState != EntityState.Added; } private IRepository GetRepository() where T : class { if (this.RepositoryProvider.GetRepository() is { } repository) { return repository; } throw new RepositoryNotFoundException(typeof(T)); } private IRepository GetRepository(Type type) { if (this.RepositoryProvider.GetRepository(type) is { } repository) { return repository; } throw new RepositoryNotFoundException(type); } private void ForEachAggregate(object obj, Action action) { var aggregateProperties = obj.GetType() .GetProperties(BindingFlags.FlattenHierarchy | BindingFlags.Public | BindingFlags.Instance) .Where(p => p.GetCustomAttribute() is { } || p.Name.StartsWith("Joined")); foreach (var propertyInfo in aggregateProperties) { var propertyValue = propertyInfo.GetMethod?.Invoke(obj, []); if (propertyValue is IEnumerable enumerable) { foreach (var value in enumerable) { action(value); } } else if (propertyValue is { }) { action(propertyValue); } } } [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] private async ValueTask OnSavedChangesAsync(object? sender, SavedChangesEventArgs e) { try { if (this._changeListener is null || this._notificationSuspensions > 0) { // should never be the case return; } if (e.EntitiesSavedCount == 0) { // why are we getting this event then anyway? return; } var changedEntries = this.Context.ChangeTracker.Entries() .Where(entity => entity.State != EntityState.Unchanged && PublishesConfigurationChanges(entity.Metadata)) .ToList(); foreach (var entry in changedEntries) { var (parent, parentCollectionNavigation) = this.GetParentInformation(entry); switch (entry.State) { case EntityState.Added: await this._changeListener.ConfigurationAddedAsync(entry.Metadata.ClrType, entry.Entity.GetId(), entry.Entity, parent, parentCollectionNavigation).ConfigureAwait(false); break; case EntityState.Deleted: await this._changeListener.ConfigurationRemovedAsync(entry.Metadata.ClrType, entry.Entity.GetId(), parent, parentCollectionNavigation).ConfigureAwait(false); break; case EntityState.Modified: await this._changeListener.ConfigurationChangedAsync(entry.Metadata.ClrType, entry.Entity.GetId(), entry.Entity, parent).ConfigureAwait(false); break; default: // no change publishing required. break; } if (parent is not null && parent is not GameConfiguration && parent is not Guid) { await this._changeListener.ConfigurationChangedAsync(parent.GetType(), parent.GetId(), parent, null).ConfigureAwait(false); } } } catch (Exception ex) { this._logger.LogError(ex, "Unexpected error publishing changes."); } finally { try { this.Context.ChangeTracker.AcceptAllChanges(); } catch (Exception ex) { this._logger.LogError(ex, "Unexpected error when accepting all saved changes."); } } } private (object? Parent, INavigationBase? ParentCollectionNavigation) GetParentInformation(EntityEntry entry) { var propertyToParent = entry.Properties .FirstOrDefault(p => p.Metadata.IsForeignKey() && (p.Metadata.IsShadowProperty() || p.Metadata.PropertyInfo?.GetCustomAttribute() is not null)); var parentId = (Guid?)(propertyToParent?.CurrentValue ?? propertyToParent?.OriginalValue); if (parentId is null) { return (null, null); } object? parent = null; INavigationBase? parentCollectionNavigation = null; var parentEntry = this.Context.ChangeTracker.Entries().FirstOrDefault(e => e.Entity.GetId() == parentId); if (parentEntry is not null && propertyToParent is not null) { parent = parentEntry.Entity; var parentCollection = parentEntry.Collections .FirstOrDefault(c => c.Metadata.IsCollection && (c.Metadata as INavigation)?.ForeignKey == propertyToParent.Metadata.GetContainingForeignKeys().FirstOrDefault()); parentCollectionNavigation = parentCollection?.Metadata; } return (parent ?? parentId, parentCollectionNavigation); } }