Releases: CornerstoneCode/EntityFrameworkCore.Sqlite.Concurrency
Release v10.0.3
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 tim... |
Release v10.0.2
v10.0.2 — EF Core SQLite Concurrency Upgrade: Fewer SQLITE_BUSY Locks, Faster Writes, Stronger Bulk Inserts
EntityFrameworkCore.Sqlite.Concurrency v10.0.2 improves SQLite reliability and throughput for .NET 10 / EF Core 10 by hardening write serialization, upgrading transaction behavior under contention, and aligning bulk operations with the same thread-safe lock model.
If you’re seeing SQLITE_BUSY / “database is locked” in a multi-threaded EF Core + SQLite app, this release further strengthens the drop-in fix: UseSqliteWithConcurrency("Data Source=app.db").
Highlights
- More reliable write serialization under contention to reduce
database is lockedfailures in high-concurrency workloads. - Automatic transaction upgrades to
BEGIN IMMEDIATEwhen starting transactions on the queued path to reduce lock escalation surprises. - Improved command classification (including
BEGIN/PRAGMAhandling) to avoid unsafe “fast path” execution and reduce unnecessary serialization. - Bulk insert stability improvements by coordinating optimized bulk operations with the same write-lock model used by the interceptor.
- Simplified configuration defaults by removing legacy toggles and standardizing on production-safe behavior.
Breaking / Behavioral Changes
- Removed options from
SqliteConcurrencyOptions:UseWriteQueueEnableWalCheckpointManagementEnableMemoryPack
- Concurrency behavior is now standardized around safe defaults (write-queue + WAL settings are applied by
UseSqliteWithConcurrency()).
Upgrade
- Update the NuGet package:
dotnet add package EntityFrameworkCore.Sqlite.Concurrency --version 10.0.2
- If you set removed options, delete those assignments and keep your remaining tuning options (e.g.,
BusyTimeout,MaxRetryAttempts,WalAutoCheckpoint,CommandTimeout). - Ensure your DbContext uses:
options.UseSqliteWithConcurrency("Data Source=app.db");
Keywords (for search)
EF Core SQLite concurrency, EntityFrameworkCore.Sqlite, SQLITE_BUSY fix, database is locked, thread-safe DbContext, WAL mode, parallel reads, BEGIN IMMEDIATE, write serialization, bulk insert .NET 10
EntityFrameworkCore.Sqlite.Concurrency v10.0.0
EntityFrameworkCore.Sqlite.Concurrency v10.0.0
Summary
This is the inaugural stable release of EntityFrameworkCore.Sqlite.Concurrency, a high-performance, thread-safe provider that transforms SQLite into a reliable database for concurrent .NET applications. It eliminates SQLITE_BUSY errors and dramatically speeds up data operations.
🚀 What's New in v10.0.0
This first major release provides a complete, production-ready solution to SQLite's concurrency and performance limitations in Entity Framework Core.
✨ Core Features
- Automatic Write Serialization: An application-level queue guarantees 100% write reliability, completely eliminating
database is locked(SQLITE_BUSY) errors in multi-threaded scenarios. - High-Performance Bulk Operations: Intelligent batching and transaction management deliver up to 10x faster bulk inserts compared to standard
SaveChanges(). - True Parallel Read Scaling: Leverages Write-Ahead Logging (WAL) and managed connections to allow unlimited concurrent reads without blocking.
- Seamless Developer Experience: A drop-in replacement for
Microsoft.EntityFrameworkCore.Sqlite. SwapUseSqlite()forUseSqliteWithConcurrency(). - Built-in Production Resilience: Includes exponential backoff retry logic, optimal connection pooling, and crash-safe transaction handling.
📦 Installation
Get started by installing the package from NuGet:
Package Manager:
Install-Package EntityFrameworkCore.Sqlite.Concurrency -Version 10.0.0.NET CLI:
dotnet add package EntityFrameworkCore.Sqlite.Concurrency --version 10.0.0⚡ Quick Upgrade Path
Enable concurrency control and performance gains with a one-line change in your DbContext configuration:
// Before (Standard, prone to errors)
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=app.db"));
// After (Thread-safe and optimized)
services.AddDbContext<AppDbContext>(options =>
options.UseSqliteWithConcurrency("Data Source=app.db"));📊 Performance Highlights
Initial benchmarks demonstrate significant improvements:
| Operation | Standard EF Core SQLite | With This Package | Improvement |
|---|---|---|---|
| Insert 100,000 records | ~42 seconds | ~4.1 seconds | 10.2x faster |
| 50 Concurrent Reads | ~8.7 seconds | ~2.1 seconds | 4.1x faster |
| Memory (100k ops) | ~425 MB | ~285 MB | 33% less |
🛠️ System Requirements & Compatibility
- .NET: Targets .NET 10.0 (.NET 8.0+ compatible)
- Entity Framework Core: 10.0+
- SQLite: 3.35.0+
- Status: Fully compatible with existing EF Core models, DbContext classes, and LINQ queries.
🔗 Links
We are excited for you to try it! This release makes SQLite a first-class citizen for robust, concurrent .NET applications. Please report any issues on the GitHub repository.