Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions EFCore.Sqlite.Concurrency.Test/ConcurrencyStressTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
11 changes: 11 additions & 0 deletions EFCore.Sqlite.Concurrency.Test/OverrideSaveChangesDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using EntityFrameworkCore.Sqlite.Concurrency;

namespace EFCore.Sqlite.Concurrency.Test;

public sealed class OverrideSaveChangesDbContext(string connectionString) : StressDbContext(connectionString)
{
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) =>
this.SaveChangesSerializedAsync(
ct => base.SaveChangesAsync(ct),
cancellationToken: cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,46 @@ public static async Task<T> ExecuteWithRetryAsync<T>(
/// acquisition to prevent deadlocks.
/// </para>
/// </remarks>
public static Task<int> SaveChangesSerializedAsync(
this DbContext context,
int maxRetries = 3,
CancellationToken cancellationToken = default) =>
context.SaveChangesSerializedAsync(
context.SaveChangesAsync,
maxRetries,
cancellationToken);

/// <summary>
/// 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.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="saveChangesAsync">
/// The save operation to execute while the write lock is held. Pass a base-save delegate
/// from <see cref="DbContext.SaveChangesAsync(CancellationToken)"/> overrides to avoid
/// recursively calling the override.
/// </param>
/// <param name="maxRetries">
/// Maximum number of retry attempts if <c>SQLITE_BUSY</c> is returned even after the
/// write lock is held. Uses exponential backoff starting at 50 ms, capped at 2 000 ms.
/// </param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of state entries written to the database.</returns>
/// <remarks>
/// Use this overload when serializing saves from a <see cref="DbContext.SaveChangesAsync(CancellationToken)"/>
/// override. The default overload calls <c>context.SaveChangesAsync</c>, which would re-enter
/// that override.
/// </remarks>
public static async Task<int> SaveChangesSerializedAsync(
this DbContext context,
Func<CancellationToken, Task<int>> 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);
Expand All @@ -164,7 +197,7 @@ public static async Task<int> SaveChangesSerializedAsync(
cancellationToken.ThrowIfCancellationRequested();
try
{
return await context.SaveChangesAsync(cancellationToken);
return await saveChangesAsync(cancellationToken);
}
catch (Exception ex) when (attempt < maxRetries && IsRetryableSqliteBusy(ex))
{
Expand Down
Loading