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_SNAPSHOTcausing unexpected failures mid-transactionInvalidOperationException: A second operation was started on this contextwhen usingTask.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, andExecuteWithRetryAsynccall sites compile and behave correctly without modification. AddConcurrentSqliteDbContextFactoryis additive.Cache=Sharedrejection is technically a new startup error, butCache=Shared+ WAL was already producing incorrect behavior silently — this makes the failure explicit and actionable.SynchronousModedefaults toNormal, which was the hardcoded value in prior versions.UpgradeTransactionsToImmediatedefaults totrue, 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_SNAPSHOTrequire 3.37.0+)