From afffeb953caab49cc8e8ec59ab6bfd12fb48b999 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 22 Oct 2025 09:17:35 -0600 Subject: [PATCH] Enforce sequential access to DbContext. --- Source/ApplicationServices/DbContexts.cs | 2 +- Source/DataLayer/InstanceQueue.cs | 103 +++++++++++++++++++++++ Source/DataLayer/LibationContext.cs | 16 +++- 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 Source/DataLayer/InstanceQueue.cs diff --git a/Source/ApplicationServices/DbContexts.cs b/Source/ApplicationServices/DbContexts.cs index d8f4b487..d1dc5832 100644 --- a/Source/ApplicationServices/DbContexts.cs +++ b/Source/ApplicationServices/DbContexts.cs @@ -9,7 +9,7 @@ namespace ApplicationServices { /// Use for fully functional context, incl. SaveChanges(). For query-only, use the other method public static LibationContext GetContext() - => LibationContext.Create(SqliteStorage.ConnectionString); + => InstanceQueue.WaitToCreateInstance(() => LibationContext.Create(SqliteStorage.ConnectionString)); /// Use for full library querying. No lazy loading public static List GetLibrary_Flat_NoTracking(bool includeParents = false) diff --git a/Source/DataLayer/InstanceQueue.cs b/Source/DataLayer/InstanceQueue.cs new file mode 100644 index 00000000..4bfda40a --- /dev/null +++ b/Source/DataLayer/InstanceQueue.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; +using System.Threading; + +#nullable enable +namespace DataLayer; + +/// Notifies clients that the object is being disposed. +public interface INotifyDisposed : IDisposable +{ + /// Event raised when the object is disposed. + event EventHandler? ObjectDisposed; +} + +/// Creates a single instance of at a time, blocking subsequent creations until the previous creations are disposed. +public static class InstanceQueue where TDisposable : INotifyDisposed +{ + /// Synchronization object for access to "/> + private static Lock Locker { get; } = new(); + /// Ticket holder for the last instance creator in line. + private static Ticket? LastInLine { get; set; } + + /// Waits for all previously created instances of to be disposed, then creates and returns a new instance of using the provided factory. + public static TDisposable WaitToCreateInstance(Func creator) + { + Ticket ticket; + lock (Locker) + { + ticket = LastInLine = new Ticket(creator, LastInLine); + } + + return ticket.Fulfill(); + } + + /// A queue ticket for an instance creator. + /// Factory to create a new instance of + /// The ticket immediately in preceding this new ticket. This new ticket must wait for to signal its instance has been disposed before creating a new instance of + private class Ticket(Func creator, Ticket? inFront) : IDisposable + { + /// Factory to create a new instance of + private Func Creator { get; } = creator; + /// Ticket immediately in front of this one. This instance must wait for to signal its instance has been disposed before creating a new instance of + private Ticket? InFront { get; } = inFront; + /// Wait handle to signal when this ticket's created instance is disposed + private EventWaitHandle WaitHandle { get; } = new(false, EventResetMode.ManualReset); + /// This ticket's created instance of + private TDisposable? CreatedInstance { get; set; } + + /// Disposes of this ticket and every ticket queued in front of it. + public void Dispose() + { + WaitHandle.Dispose(); + InFront?.Dispose(); + } + + /// + /// Waits for the ticket's instance to be disposed, then creates and returns a new instance of using the factory. + /// + public TDisposable Fulfill() + { +#if DEBUG + var sw = Stopwatch.StartNew(); +#endif + //Wait for the previous ticket's instance to be disposed, then dispose of the previous ticket. + InFront?.WaitHandle.WaitOne(Timeout.Infinite); + InFront?.Dispose(); +#if DEBUG + sw.Stop(); + Debug.WriteLine($"Waited {sw.ElapsedMilliseconds}ms to create instance of {typeof(TDisposable).Name}"); +#endif + CreatedInstance = Creator(); + CreatedInstance.ObjectDisposed += CreatedInstance_ObjectDisposed; + return CreatedInstance; + } + + private void CreatedInstance_ObjectDisposed(object? sender, EventArgs e) + { + Debug.WriteLine($"{typeof(TDisposable).Name} Disposed"); + if (CreatedInstance is not null) + { + CreatedInstance.ObjectDisposed -= CreatedInstance_ObjectDisposed; + CreatedInstance = default; + } + + lock (Locker) + { + if (this == LastInLine) + { + //There are no ticket holders waiting after this one. + //This ticket is fulfilled and will never be waited on. + LastInLine = null; + Dispose(); + } + else + { + //Signal the that this ticket has been fulfilled so that + //the next ticket in line may proceed. + WaitHandle.Set(); + } + } + } + } +} diff --git a/Source/DataLayer/LibationContext.cs b/Source/DataLayer/LibationContext.cs index f4e773c3..0ed780b9 100644 --- a/Source/DataLayer/LibationContext.cs +++ b/Source/DataLayer/LibationContext.cs @@ -1,9 +1,11 @@ using DataLayer.Configurations; using Microsoft.EntityFrameworkCore; +using System; +using System.Threading.Tasks; namespace DataLayer { - public class LibationContext : DbContext + public class LibationContext : DbContext, INotifyDisposed { // IMPORTANT: USING DbSet<> // ======================== @@ -25,6 +27,18 @@ namespace DataLayer public DbSet Categories { get; private set; } public DbSet CategoryLadders { get; private set; } + public event EventHandler ObjectDisposed; + public override void Dispose() + { + base.Dispose(); + ObjectDisposed?.Invoke(this, EventArgs.Empty); + } + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + ObjectDisposed?.Invoke(this, EventArgs.Empty); + } + public static LibationContext Create(string connectionString) { var factory = new LibationContextFactory();