diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2ad7e..24da656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added a `SaveChangesSerializedAsync` overload that accepts a save delegate, enabling safe serialized `DbContext.SaveChangesAsync` overrides without recursion. + --- ## [10.1.0] - 2026-07-05 diff --git a/EFCore.Sqlite.Concurrency.Test/ConcurrencyStressTests.cs b/EFCore.Sqlite.Concurrency.Test/ConcurrencyStressTests.cs index ecfab84..1593766 100644 --- a/EFCore.Sqlite.Concurrency.Test/ConcurrencyStressTests.cs +++ b/EFCore.Sqlite.Concurrency.Test/ConcurrencyStressTests.cs @@ -48,6 +48,40 @@ public async Task SaveChangesSerializedAsync_WritesAllRows() Assert.Equal(expected, ids.Count); // no duplicate IDs } + [Fact] + public async Task SaveChangesSerializedAsync_AllowsDbContextOverride() + { + const int writers = 100; + const int rowsPerWriter = 10; + const int expected = writers * rowsPerWriter; + + using var db = new TempDatabase(); + + var tasks = Enumerable.Range(0, writers).Select(async writerIdx => + { + await using var ctx = new OverrideSaveChangesDbContext(db.ConnectionString); + + var entities = Enumerable.Range(0, rowsPerWriter) + .Select(i => new StressEntity + { + Id = Guid.NewGuid(), + WriterIndex = writerIdx, + Payload = $"override-w{writerIdx}-r{i}" + }) + .ToList(); + + ctx.Entities.AddRange(entities); + await ctx.SaveChangesAsync(); + }); + + await Task.WhenAll(tasks); + + await using var verify = db.CreateContext(); + var count = await verify.Entities.CountAsync(); + + Assert.Equal(expected, count); + } + // ── Test 2: BulkInsertOptimizedAsync ───────────────────────────────────── [Fact] diff --git a/EFCore.Sqlite.Concurrency.Test/OverrideSaveChangesDbContext.cs b/EFCore.Sqlite.Concurrency.Test/OverrideSaveChangesDbContext.cs new file mode 100644 index 0000000..ec5de03 --- /dev/null +++ b/EFCore.Sqlite.Concurrency.Test/OverrideSaveChangesDbContext.cs @@ -0,0 +1,11 @@ +using EntityFrameworkCore.Sqlite.Concurrency; + +namespace EFCore.Sqlite.Concurrency.Test; + +public sealed class OverrideSaveChangesDbContext(string connectionString) : StressDbContext(connectionString) +{ + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) => + this.SaveChangesSerializedAsync( + ct => base.SaveChangesAsync(ct), + cancellationToken: cancellationToken); +} diff --git a/EntityFrameworkCore.Sqlite.Concurrency/src/SqliteConcurrencyExtensions.cs b/EntityFrameworkCore.Sqlite.Concurrency/src/SqliteConcurrencyExtensions.cs index c6efa52..84b097b 100644 --- a/EntityFrameworkCore.Sqlite.Concurrency/src/SqliteConcurrencyExtensions.cs +++ b/EntityFrameworkCore.Sqlite.Concurrency/src/SqliteConcurrencyExtensions.cs @@ -144,13 +144,46 @@ public static async Task ExecuteWithRetryAsync( /// acquisition to prevent deadlocks. /// /// + public static Task SaveChangesSerializedAsync( + this DbContext context, + int maxRetries = 3, + CancellationToken cancellationToken = default) => + context.SaveChangesSerializedAsync( + context.SaveChangesAsync, + maxRetries, + cancellationToken); + + /// + /// Saves all changes in the context while holding the shared per-database write lock, + /// using the supplied save delegate for the actual EF Core save operation. + /// + /// The database context. + /// + /// The save operation to execute while the write lock is held. Pass a base-save delegate + /// from overrides to avoid + /// recursively calling the override. + /// + /// + /// Maximum number of retry attempts if SQLITE_BUSY is returned even after the + /// write lock is held. Uses exponential backoff starting at 50 ms, capped at 2 000 ms. + /// + /// The cancellation token. + /// The number of state entries written to the database. + /// + /// Use this overload when serializing saves from a + /// override. The default overload calls context.SaveChangesAsync, which would re-enter + /// that override. + /// public static async Task SaveChangesSerializedAsync( this DbContext context, + Func> saveChangesAsync, int maxRetries = 3, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(saveChangesAsync); + if (SqliteConnectionEnhancer.IsWriteLockHeld.Value) - return await context.SaveChangesAsync(cancellationToken); + return await saveChangesAsync(cancellationToken); var connectionString = context.Database.GetDbConnection().ConnectionString; var enhancedConnectionString = SqliteConnectionEnhancer.GetOptimizedConnectionString(connectionString); @@ -164,7 +197,7 @@ public static async Task SaveChangesSerializedAsync( cancellationToken.ThrowIfCancellationRequested(); try { - return await context.SaveChangesAsync(cancellationToken); + return await saveChangesAsync(cancellationToken); } catch (Exception ex) when (attempt < maxRetries && IsRetryableSqliteBusy(ex)) {