|
| 1 | +# EntityFrameworkCore.Sqlite.Concurrency — v10.0.3 Release Notes |
| 2 | + |
| 3 | +## Fix `SQLITE_BUSY`, `SQLITE_BUSY_SNAPSHOT`, and EF Core Thread-Safety in One Update |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +If you have hit any of the following in your .NET / EF Core application, this update directly addresses them: |
| 8 | + |
| 9 | +- `Microsoft.Data.Sqlite.SqliteException: SQLite Error 5: 'database is locked'` |
| 10 | +- `SQLITE_BUSY_SNAPSHOT` causing unexpected failures mid-transaction |
| 11 | +- `InvalidOperationException: A second operation was started on this context` when using `Task.WhenAll` |
| 12 | +- Retry storms where all threads wake simultaneously and re-contend for the write lock |
| 13 | +- Silent misconfiguration (invalid `MaxRetryAttempts`, `Cache=Shared` + WAL conflicts) |
| 14 | +- No visibility into why busy errors occur in production |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Bug Fixes |
| 19 | + |
| 20 | +### SQLITE_BUSY_SNAPSHOT now correctly restarts the operation (not just retries) |
| 21 | + |
| 22 | +**Error extended code:** `517` (`SQLITE_BUSY | (2 << 8)`) |
| 23 | + |
| 24 | +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. |
| 25 | + |
| 26 | +`ExecuteWriteAsync` and `ExecuteWithRetryAsync` now correctly distinguish this case using `SqliteException.SqliteExtendedErrorCode` and restart the full operation lambda. |
| 27 | + |
| 28 | +### Full jitter added to exponential backoff (thundering herd prevention) |
| 29 | + |
| 30 | +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. |
| 31 | + |
| 32 | +### `Cache=Shared` incompatibility with WAL detected at startup |
| 33 | + |
| 34 | +`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: |
| 35 | + |
| 36 | +``` |
| 37 | +Cache=Shared is incompatible with WAL mode and cannot be used with ThreadSafeEFCore.SQLite. |
| 38 | +Remove 'Cache=Shared' from your connection string. Connection pooling (Pooling=true) is |
| 39 | +enabled automatically and provides efficient connection reuse without the WAL incompatibility. |
| 40 | +``` |
| 41 | + |
| 42 | +### Startup validation for `SqliteConcurrencyOptions` |
| 43 | + |
| 44 | +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. |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## New Features |
| 49 | + |
| 50 | +### `AddConcurrentSqliteDbContextFactory<T>` — correct EF Core pattern for concurrent workloads |
| 51 | + |
| 52 | +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. |
| 53 | + |
| 54 | +The new registration method wires this up with all concurrency settings and auto-injects `ILoggerFactory`: |
| 55 | + |
| 56 | +```csharp |
| 57 | +// Program.cs — for concurrent workloads (hosted services, background queues, Task.WhenAll) |
| 58 | +builder.Services.AddConcurrentSqliteDbContextFactory<AppDbContext>("Data Source=app.db"); |
| 59 | + |
| 60 | +// The existing method remains for request-scoped use (controllers, Razor Pages, Blazor Server) |
| 61 | +builder.Services.AddConcurrentSqliteDbContext<AppDbContext>("Data Source=app.db"); |
| 62 | +``` |
| 63 | + |
| 64 | +Inject `IDbContextFactory<AppDbContext>` and call `CreateDbContext()` per concurrent operation: |
| 65 | + |
| 66 | +```csharp |
| 67 | +public class ReportGenerationService |
| 68 | +{ |
| 69 | + private readonly IDbContextFactory<AppDbContext> _factory; |
| 70 | + |
| 71 | + public ReportGenerationService(IDbContextFactory<AppDbContext> factory) |
| 72 | + => _factory = factory; |
| 73 | + |
| 74 | + public async Task GenerateAllReportsAsync(IEnumerable<int> reportIds, CancellationToken ct) |
| 75 | + { |
| 76 | + // Each task gets its own context — no EF thread-safety violation, |
| 77 | + // and ThreadSafeEFCore.SQLite serializes the writes at the SQLite level. |
| 78 | + var tasks = reportIds.Select(async id => |
| 79 | + { |
| 80 | + await using var db = _factory.CreateDbContext(); |
| 81 | + var data = await db.ReportData.Where(r => r.ReportId == id).ToListAsync(ct); |
| 82 | + var result = ComputeReport(data); |
| 83 | + db.Reports.Add(result); |
| 84 | + await db.SaveChangesAsync(ct); |
| 85 | + }); |
| 86 | + |
| 87 | + await Task.WhenAll(tasks); |
| 88 | + } |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +### Structured logging for `SQLITE_BUSY*` events |
| 93 | + |
| 94 | +Pass an `ILoggerFactory` (or let DI resolve it automatically through `AddConcurrentSqliteDbContext`/`AddConcurrentSqliteDbContextFactory`) and the interceptor emits structured log entries: |
| 95 | + |
| 96 | +| Event | Log Level | Message | |
| 97 | +|---|---|---| |
| 98 | +| `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` | `Warning` | Includes command text and retry attempt number | |
| 99 | +| `SQLITE_BUSY_SNAPSHOT` | `Warning` | Identifies stale snapshot, includes command text | |
| 100 | +| `SQLITE_LOCKED` | `Error` | Same-connection conflict — indicates an application bug | |
| 101 | +| `BEGIN IMMEDIATE` upgrade | `Debug` | Logged when a deferred `BEGIN` is rewritten | |
| 102 | + |
| 103 | +Manual wiring (non-DI scenario): |
| 104 | + |
| 105 | +```csharp |
| 106 | +options.UseSqliteWithConcurrency("Data Source=app.db", o => |
| 107 | +{ |
| 108 | + o.LoggerFactory = loggerFactory; |
| 109 | +}); |
| 110 | +``` |
| 111 | + |
| 112 | +### WAL checkpoint health monitoring (`GetWalCheckpointStatusAsync`) |
| 113 | + |
| 114 | +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: |
| 115 | + |
| 116 | +```csharp |
| 117 | +var status = await SqliteConnectionEnhancer.GetWalCheckpointStatusAsync(connection); |
| 118 | + |
| 119 | +if (status.IsBusy && status.TotalWalFrames > 5000) |
| 120 | + logger.LogWarning( |
| 121 | + "WAL checkpoint blocked. {Total} frames, {Checkpointed} checkpointed ({Progress:F1}%). " + |
| 122 | + "A long-running read transaction may be preventing WAL reclamation.", |
| 123 | + status.TotalWalFrames, status.CheckpointedFrames, status.CheckpointProgress); |
| 124 | +``` |
| 125 | + |
| 126 | +### Migration lock recovery (`TryReleaseMigrationLockAsync`) |
| 127 | + |
| 128 | +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: |
| 129 | + |
| 130 | +```csharp |
| 131 | +// Run once at startup before Database.MigrateAsync() |
| 132 | +var connection = db.Database.GetDbConnection(); |
| 133 | +await connection.OpenAsync(); |
| 134 | + |
| 135 | +var wasStale = await SqliteConnectionEnhancer.TryReleaseMigrationLockAsync(connection); |
| 136 | +if (wasStale) |
| 137 | + logger.LogWarning("Stale EF migration lock detected and cleared. Proceeding with migration."); |
| 138 | + |
| 139 | +await db.Database.MigrateAsync(); |
| 140 | +``` |
| 141 | + |
| 142 | +Pass `release: false` to check without modifying the database (diagnostics only). |
| 143 | + |
| 144 | +### Configurable `SynchronousMode` option |
| 145 | + |
| 146 | +`PRAGMA synchronous` is now configurable instead of hardcoded. Controls the durability vs. write-speed trade-off: |
| 147 | + |
| 148 | +| Mode | Durability | Use case | |
| 149 | +|---|---|---| |
| 150 | +| `Off` | Lowest — data loss on OS crash | Bulk import scratch databases | |
| 151 | +| `Normal` | **Default** — safe after app crash; last commit may be lost on power loss | Most production apps with WAL | |
| 152 | +| `Full` | Safe after power loss — extra `fsync` on every commit | Financial records, audit logs | |
| 153 | +| `Extra` | Strongest — guards against certain filesystem clock skew bugs | High-compliance environments | |
| 154 | + |
| 155 | +```csharp |
| 156 | +options.UseSqliteWithConcurrency("Data Source=app.db", o => |
| 157 | +{ |
| 158 | + o.SynchronousMode = SqliteSynchronousMode.Full; // power-loss safe |
| 159 | +}); |
| 160 | +``` |
| 161 | + |
| 162 | +### `UpgradeTransactionsToImmediate` opt-out |
| 163 | + |
| 164 | +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: |
| 165 | + |
| 166 | +```csharp |
| 167 | +options.UseSqliteWithConcurrency("Data Source=app.db", o => |
| 168 | +{ |
| 169 | + o.UpgradeTransactionsToImmediate = false; // you manage BEGIN IMMEDIATE yourself |
| 170 | +}); |
| 171 | +``` |
| 172 | + |
| 173 | +--- |
| 174 | + |
| 175 | +## Breaking Changes |
| 176 | + |
| 177 | +None. This release is fully backwards-compatible: |
| 178 | + |
| 179 | +- All existing `UseSqliteWithConcurrency`, `AddConcurrentSqliteDbContext`, and `ExecuteWithRetryAsync` call sites compile and behave correctly without modification. |
| 180 | +- `AddConcurrentSqliteDbContextFactory` is additive. |
| 181 | +- `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. |
| 182 | +- `SynchronousMode` defaults to `Normal`, which was the hardcoded value in prior versions. |
| 183 | +- `UpgradeTransactionsToImmediate` defaults to `true`, preserving the prior behavior. |
| 184 | + |
| 185 | +--- |
| 186 | + |
| 187 | +## Configuration Reference |
| 188 | + |
| 189 | +| Option | Default | Description | |
| 190 | +|--------|---------|-------------| |
| 191 | +| `BusyTimeout` | 30 s | `PRAGMA busy_timeout` — SQLite retries lock acquisition internally for this duration before surfacing `SQLITE_BUSY` to the application. | |
| 192 | +| `MaxRetryAttempts` | 3 | Application-level retry attempts after `SQLITE_BUSY*`, with exponential backoff and full jitter. | |
| 193 | +| `CommandTimeout` | 300 s | EF Core SQL command timeout. | |
| 194 | +| `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. | |
| 195 | +| `SynchronousMode` | `Normal` | `PRAGMA synchronous` — durability vs. write-speed trade-off. `Normal` is recommended for WAL mode. | |
| 196 | +| `UpgradeTransactionsToImmediate` | `true` | Rewrites `BEGIN`/`BEGIN TRANSACTION` to `BEGIN IMMEDIATE` to prevent `SQLITE_BUSY_SNAPSHOT` mid-transaction. | |
| 197 | +| `LoggerFactory` | `null` | Resolved automatically from DI when using `AddConcurrentSqliteDbContext` / `AddConcurrentSqliteDbContextFactory`. | |
| 198 | + |
| 199 | +--- |
| 200 | + |
| 201 | +## Upgrade Guide |
| 202 | + |
| 203 | +```csharp |
| 204 | +// 1. For request-scoped use (controllers, Razor Pages) — no change needed: |
| 205 | +builder.Services.AddConcurrentSqliteDbContext<AppDbContext>("Data Source=app.db"); |
| 206 | + |
| 207 | +// 2. For concurrent workloads — switch to the factory: |
| 208 | +// Before: |
| 209 | +builder.Services.AddConcurrentSqliteDbContext<AppDbContext>("Data Source=app.db"); |
| 210 | +// After: |
| 211 | +builder.Services.AddConcurrentSqliteDbContextFactory<AppDbContext>("Data Source=app.db"); |
| 212 | +// Then inject IDbContextFactory<AppDbContext> and call CreateDbContext() per task. |
| 213 | +
|
| 214 | +// 3. Remove Cache=Shared from any connection string that has it. |
| 215 | +
|
| 216 | +// 4. That's it — no other changes required. |
| 217 | +``` |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## System Requirements |
| 222 | + |
| 223 | +- .NET 10.0+ |
| 224 | +- Entity Framework Core 10.0+ |
| 225 | +- Microsoft.Data.Sqlite 10.0+ |
| 226 | +- SQLite 3.35.0+ (WAL2 and `SQLITE_BUSY_SNAPSHOT` require 3.37.0+) |
0 commit comments