Skip to content

Release v10.0.3

Latest

Choose a tag to compare

@github-actions github-actions released this 14 Apr 04:25
· 1 commit to main since this release
4062398

EntityFrameworkCore.Sqlite.Concurrency — v10.0.3 Release Notes

Fix SQLITE_BUSY, SQLITE_BUSY_SNAPSHOT, and EF Core Thread-Safety in One Update

This release closes the remaining correctness gaps in how the library handles SQLite locking errors, adds the IDbContextFactory<T> registration pattern that EF Core recommends for concurrent workloads, and introduces structured diagnostics for production observability.

If you have hit any of the following in your .NET / EF Core application, this update directly addresses them:

  • Microsoft.Data.Sqlite.SqliteException: SQLite Error 5: 'database is locked'
  • SQLITE_BUSY_SNAPSHOT causing unexpected failures mid-transaction
  • InvalidOperationException: A second operation was started on this context when using Task.WhenAll
  • Retry storms where all threads wake simultaneously and re-contend for the write lock
  • Silent misconfiguration (invalid MaxRetryAttempts, Cache=Shared + WAL conflicts)
  • No visibility into why busy errors occur in production

Bug Fixes

SQLITE_BUSY_SNAPSHOT now correctly restarts the operation (not just retries)

Error extended code: 517 (SQLITE_BUSY | (2 << 8))

This was the most impactful correctness bug. All SQLITE_BUSY variants were previously retried the same way — waiting a backoff delay and calling the same statement again. SQLITE_BUSY_SNAPSHOT has fundamentally different semantics: the connection's WAL read snapshot became stale after another writer committed. Re-executing the same statement produces the same error. The only correct fix is to roll back the entire transaction and restart the operation from scratch so that any data read inside it is re-queried against the current snapshot.

ExecuteWriteAsync and ExecuteWithRetryAsync now correctly distinguish this case using SqliteException.SqliteExtendedErrorCode and restart the full operation lambda.

Full jitter added to exponential backoff (thundering herd prevention)

Pure exponential backoff (100ms × 2^n) causes every contending thread to wake at approximately the same time and immediately re-contend for the write lock — a classic thundering herd. Retry delays are now randomized in [baseDelay, 2×baseDelay] so threads spread out naturally without coordination.

Cache=Shared incompatibility with WAL detected at startup

Cache=Shared in a SQLite connection string enables a shared page cache across connections. This conflicts with WAL mode's snapshot isolation model, where each connection must independently track read snapshot boundaries. The combination silently corrupts WAL semantics and was previously accepted without warning. It now throws a descriptive ArgumentException at startup:

Cache=Shared is incompatible with WAL mode and cannot be used with ThreadSafeEFCore.SQLite.
Remove 'Cache=Shared' from your connection string. Connection pooling (Pooling=true) is
enabled automatically and provides efficient connection reuse without the WAL incompatibility.

Startup validation for SqliteConcurrencyOptions

Invalid option values (e.g. MaxRetryAttempts = 0, negative BusyTimeout) previously produced silent incorrect behavior. Validate() is now called during UseSqliteWithConcurrency and throws ArgumentOutOfRangeException with a descriptive message at startup.


New Features

AddConcurrentSqliteDbContextFactory<T> — correct EF Core pattern for concurrent workloads

A DbContext instance is not thread-safe and must not be shared across concurrent operations. EF Core's recommended pattern for concurrent workloads — background services, Task.WhenAll, Parallel.ForEachAsync, Channel<T> consumers — is IDbContextFactory<T>, which creates an independent context per concurrent flow.

The new registration method wires this up with all concurrency settings and auto-injects ILoggerFactory:

// Program.cs — for concurrent workloads (hosted services, background queues, Task.WhenAll)
builder.Services.AddConcurrentSqliteDbContextFactory<AppDbContext>("Data Source=app.db");

// The existing method remains for request-scoped use (controllers, Razor Pages, Blazor Server)
builder.Services.AddConcurrentSqliteDbContext<AppDbContext>("Data Source=app.db");

Inject IDbContextFactory<AppDbContext> and call CreateDbContext() per concurrent operation:

public class ReportGenerationService
{
    private readonly IDbContextFactory<AppDbContext> _factory;

    public ReportGenerationService(IDbContextFactory<AppDbContext> factory)
        => _factory = factory;

    public async Task GenerateAllReportsAsync(IEnumerable<int> reportIds, CancellationToken ct)
    {
        // Each task gets its own context — no EF thread-safety violation,
        // and ThreadSafeEFCore.SQLite serializes the writes at the SQLite level.
        var tasks = reportIds.Select(async id =>
        {
            await using var db = _factory.CreateDbContext();
            var data = await db.ReportData.Where(r => r.ReportId == id).ToListAsync(ct);
            var result = ComputeReport(data);
            db.Reports.Add(result);
            await db.SaveChangesAsync(ct);
        });

        await Task.WhenAll(tasks);
    }
}

Structured logging for SQLITE_BUSY* events

Pass an ILoggerFactory (or let DI resolve it automatically through AddConcurrentSqliteDbContext/AddConcurrentSqliteDbContextFactory) and the interceptor emits structured log entries:

Event Log Level Message
SQLITE_BUSY / SQLITE_BUSY_RECOVERY Warning Includes command text and retry attempt number
SQLITE_BUSY_SNAPSHOT Warning Identifies stale snapshot, includes command text
SQLITE_LOCKED Error Same-connection conflict — indicates an application bug
BEGIN IMMEDIATE upgrade Debug Logged when a deferred BEGIN is rewritten

Manual wiring (non-DI scenario):

options.UseSqliteWithConcurrency("Data Source=app.db", o =>
{
    o.LoggerFactory = loggerFactory;
});

WAL checkpoint health monitoring (GetWalCheckpointStatusAsync)

Long-running read transactions block WAL checkpoint completion, causing the WAL file to grow unboundedly and degrade read performance. Call this periodically to detect pressure before it becomes a problem:

var status = await SqliteConnectionEnhancer.GetWalCheckpointStatusAsync(connection);

if (status.IsBusy && status.TotalWalFrames > 5000)
    logger.LogWarning(
        "WAL checkpoint blocked. {Total} frames, {Checkpointed} checkpointed ({Progress:F1}%). " +
        "A long-running read transaction may be preventing WAL reclamation.",
        status.TotalWalFrames, status.CheckpointedFrames, status.CheckpointProgress);

Migration lock recovery (TryReleaseMigrationLockAsync)

EF Core uses a __EFMigrationsLock table to serialize concurrent migrations. If a migration process crashes after acquiring the lock, subsequent calls to Database.Migrate() block indefinitely. The new helper detects and clears stale locks:

// Run once at startup before Database.MigrateAsync()
var connection = db.Database.GetDbConnection();
await connection.OpenAsync();

var wasStale = await SqliteConnectionEnhancer.TryReleaseMigrationLockAsync(connection);
if (wasStale)
    logger.LogWarning("Stale EF migration lock detected and cleared. Proceeding with migration.");

await db.Database.MigrateAsync();

Pass release: false to check without modifying the database (diagnostics only).

Configurable SynchronousMode option

PRAGMA synchronous is now configurable instead of hardcoded. Controls the durability vs. write-speed trade-off:

Mode Durability Use case
Off Lowest — data loss on OS crash Bulk import scratch databases
Normal Default — safe after app crash; last commit may be lost on power loss Most production apps with WAL
Full Safe after power loss — extra fsync on every commit Financial records, audit logs
Extra Strongest — guards against certain filesystem clock skew bugs High-compliance environments
options.UseSqliteWithConcurrency("Data Source=app.db", o =>
{
    o.SynchronousMode = SqliteSynchronousMode.Full; // power-loss safe
});

UpgradeTransactionsToImmediate opt-out

The interceptor rewrites deferred BEGIN to BEGIN IMMEDIATE by default, which prevents SQLITE_BUSY_SNAPSHOT mid-transaction. Power users who manage write transactions explicitly can disable this:

options.UseSqliteWithConcurrency("Data Source=app.db", o =>
{
    o.UpgradeTransactionsToImmediate = false; // you manage BEGIN IMMEDIATE yourself
});

Breaking Changes

None. This release is fully backwards-compatible:

  • All existing UseSqliteWithConcurrency, AddConcurrentSqliteDbContext, and ExecuteWithRetryAsync call sites compile and behave correctly without modification.
  • AddConcurrentSqliteDbContextFactory is additive.
  • Cache=Shared rejection is technically a new startup error, but Cache=Shared + WAL was already producing incorrect behavior silently — this makes the failure explicit and actionable.
  • SynchronousMode defaults to Normal, which was the hardcoded value in prior versions.
  • UpgradeTransactionsToImmediate defaults to true, preserving the prior behavior.

Configuration Reference

Option Default Description
BusyTimeout 30 s PRAGMA busy_timeout — SQLite retries lock acquisition internally for this duration before surfacing SQLITE_BUSY to the application.
MaxRetryAttempts 3 Application-level retry attempts after SQLITE_BUSY*, with exponential backoff and full jitter.
CommandTimeout 300 s EF Core SQL command timeout.
WalAutoCheckpoint 1000 pages PRAGMA wal_autocheckpoint — triggers an automatic passive checkpoint after this many WAL frames (~4 MB at the default 4 096-byte page size). Set to 0 to disable.
SynchronousMode Normal PRAGMA synchronous — durability vs. write-speed trade-off. Normal is recommended for WAL mode.
UpgradeTransactionsToImmediate true Rewrites BEGIN/BEGIN TRANSACTION to BEGIN IMMEDIATE to prevent SQLITE_BUSY_SNAPSHOT mid-transaction.
LoggerFactory null Resolved automatically from DI when using AddConcurrentSqliteDbContext / AddConcurrentSqliteDbContextFactory.

Upgrade Guide

// 1. For request-scoped use (controllers, Razor Pages) — no change needed:
builder.Services.AddConcurrentSqliteDbContext<AppDbContext>("Data Source=app.db");

// 2. For concurrent workloads — switch to the factory:
//    Before:
builder.Services.AddConcurrentSqliteDbContext<AppDbContext>("Data Source=app.db");
//    After:
builder.Services.AddConcurrentSqliteDbContextFactory<AppDbContext>("Data Source=app.db");
//    Then inject IDbContextFactory<AppDbContext> and call CreateDbContext() per task.

// 3. Remove Cache=Shared from any connection string that has it.

// 4. That's it — no other changes required.

System Requirements

  • .NET 10.0+
  • Entity Framework Core 10.0+
  • Microsoft.Data.Sqlite 10.0+
  • SQLite 3.35.0+ (WAL2 and SQLITE_BUSY_SNAPSHOT require 3.37.0+)