Skip to content

Commit 4062398

Browse files
v10 - new features and enhancements
New Features Added fine-grained SQLite concurrency configuration options: SynchronousMode for durability control and UpgradeTransactionsToImmediate for transaction optimization. Introduced factory pattern support via new AddConcurrentSqliteDbContextFactory method for concurrent workload scenarios. Added WAL checkpoint and migration lock management APIs for advanced control. Improvements Enhanced error handling and retry logic with full-jitter exponential backoff and improved error classification. Integrated logging support throughout the concurrency layer. Documentation Updated setup guidance with clearer configuration patterns and concurrent usage best practices.
2 parents 9fb4731 + 94c8790 commit 4062398

14 files changed

Lines changed: 1228 additions & 169 deletions

EntityFrameworkCore.Sqlite.Concurrency/EFCore.Sqlite.Concurrency.csproj

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
<!-- Core Package Identity -->
1313
<PackageId>EntityFrameworkCore.Sqlite.Concurrency</PackageId>
14-
<Version>10.0.2</Version>
14+
<Version>10.0.3</Version>
1515

1616
<!-- Core Metadata for Trust & Recognition -->
1717
<Authors>Mike Gotfryd</Authors>
@@ -34,29 +34,39 @@
3434
<!-- 3. STRUCTURED RELEASE NOTES -->
3535
<PackageReleaseNotes>
3636
<![CDATA[
37-
🚀 **v10.0.2 - Initial Stable Release: Production-Ready SQLite Concurrency & Performance**
38-
39-
This first major release transforms SQLite into a robust database for concurrent .NET applications by fixing core limitations of the standard provider.
40-
41-
**✅ SOLVES: Concurrency & Locking Errors**
42-
• **Eliminates `SQLITE_BUSY` / "database is locked" errors** with automatic, application-level write serialization.
43-
• **Guarantees 100% write reliability** under any multi-threaded load.
44-
45-
**⚡ DELIVERS: Exceptional Performance**
46-
• **Achieves up to 10x faster bulk inserts** vs. standard `SaveChanges()` through intelligent batching.
47-
• **Enables true parallel read scaling** with non-blocking connections.
48-
• **Optimizes all interactions** (connections, transactions, WAL mode) for maximum throughput.
49-
50-
**🧩 PROVIDES: Seamless Developer Experience**
51-
• **Drop-in replacement** – change `UseSqlite()` to `UseSqliteWithConcurrency()`.
52-
• **Full EF Core compatibility** – all existing DbContexts, models, and LINQ queries work unchanged.
53-
• **Simplifies complex logic** – abstracts retry patterns, lock management, and connection pooling.
54-
55-
**🏗️ ENSURES: Enterprise-Grade Robustness**
56-
• Built-in production resilience with exponential backoff retry and crash-safe transactions.
57-
• Targets the modern .NET ecosystem with first-class support for **.NET 10** and **Entity Framework Core 10**.
58-
59-
Get started in one line. Stop compromising on SQLite reliability and speed.
37+
v10.0.3 — SQLITE_BUSY_SNAPSHOT fix, IDbContextFactory support, structured logging
38+
39+
BUGS FIXED
40+
• SQLITE_BUSY_SNAPSHOT (extended code 517) now correctly restarts the full operation lambda
41+
instead of retrying the same statement — the only correct fix for a stale WAL read snapshot.
42+
• Exponential backoff now uses full jitter ([baseDelay, 2×baseDelay]) to prevent thundering
43+
herd when multiple threads contend simultaneously.
44+
• Cache=Shared in the connection string now throws ArgumentException at startup — it silently
45+
broke WAL mode semantics in prior versions.
46+
• Invalid SqliteConcurrencyOptions values (MaxRetryAttempts ≤ 0, negative BusyTimeout, etc.)
47+
now throw ArgumentOutOfRangeException at startup instead of silently misbehaving.
48+
49+
NEW FEATURES
50+
• AddConcurrentSqliteDbContextFactory<T> — registers IDbContextFactory<T> with all concurrency
51+
settings. Use this for Task.WhenAll, background services, Channel<T> consumers, and any
52+
workload that creates concurrent database operations. DbContext is not thread-safe; the factory
53+
pattern gives each concurrent flow its own independent instance.
54+
• Structured logging: pass ILoggerFactory (or let DI resolve it) to get Warning logs for
55+
SQLITE_BUSY/SQLITE_BUSY_SNAPSHOT events, Error logs for SQLITE_LOCKED, and Debug logs for
56+
BEGIN IMMEDIATE upgrades — all through your existing logging pipeline.
57+
• GetWalCheckpointStatusAsync — runs PRAGMA wal_checkpoint(PASSIVE) and returns a typed
58+
WalCheckpointStatus with IsBusy, TotalWalFrames, CheckpointedFrames, and CheckpointProgress.
59+
Call periodically to detect long-running readers blocking WAL reclamation before it degrades
60+
read performance.
61+
• TryReleaseMigrationLockAsync — detects and optionally clears a stale __EFMigrationsLock
62+
row left behind by a crashed migration process. Prevents indefinite blocking on Database.Migrate()
63+
in multi-instance deployments.
64+
• SynchronousMode option — configures PRAGMA synchronous (Off / Normal / Full / Extra).
65+
Default remains Normal (recommended for WAL: safe after app crash, fast writes).
66+
• UpgradeTransactionsToImmediate option — opt out of the BEGIN → BEGIN IMMEDIATE rewrite
67+
if you manage write transactions explicitly yourself. Default remains true.
68+
69+
NO BREAKING CHANGES — all existing call sites compile and behave correctly without modification.
6070
]]>
6171
</PackageReleaseNotes>
6272
<!-- =============================================== -->
@@ -93,9 +103,9 @@ Get started in one line. Stop compromising on SQLite reliability and speed.
93103
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
94104
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
95105
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
96-
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
106+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
97107
<!-- SourceLink for debugging support -->
98-
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
108+
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.201" PrivateAssets="All" />
99109
</ItemGroup>
100110

101111
<!-- Optional Dependencies (Conditional) -->

EntityFrameworkCore.Sqlite.Concurrency/doc/QUICKSTART.md

Lines changed: 113 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -34,30 +34,62 @@ public class BlogDbContext : DbContext
3434
}
3535
```
3636

37-
### 2. Configure with One Line of Code
37+
### 2. Configure in Program.cs
3838

39-
In your `Program.cs` or startup configuration:
39+
#### Single-threaded / request-scoped use (ASP.NET Core controllers, Razor Pages, Blazor Server)
40+
41+
One context is created per HTTP request through the DI scope. ASP.NET Core processes requests one thread at a time per scope, so sharing a context here is safe.
4042

4143
```csharp
42-
// Simple configuration
43-
builder.Services.AddDbContext<BlogDbContext>(options =>
44-
options.UseSqliteWithConcurrency("Data Source=blog.db"));
44+
builder.Services.AddConcurrentSqliteDbContext<BlogDbContext>("Data Source=blog.db");
4545
```
4646

4747
Or with custom options:
4848

4949
```csharp
50-
builder.Services.AddDbContext<BlogDbContext>(options =>
51-
options.UseSqliteWithConcurrency(
52-
"Data Source=blog.db",
53-
sqliteOptions =>
50+
builder.Services.AddConcurrentSqliteDbContext<BlogDbContext>(
51+
"Data Source=blog.db",
52+
options =>
53+
{
54+
options.BusyTimeout = TimeSpan.FromSeconds(30);
55+
options.MaxRetryAttempts = 5;
56+
});
57+
```
58+
59+
#### Concurrent use (background workers, Task.WhenAll, channels, hosted services)
60+
61+
A `DbContext` is **not thread-safe** — it must not be shared across concurrent operations. Use `IDbContextFactory<T>` instead. Each concurrent flow calls `CreateDbContext()` to get its own independent instance.
62+
63+
```csharp
64+
builder.Services.AddConcurrentSqliteDbContextFactory<BlogDbContext>("Data Source=blog.db");
65+
```
66+
67+
Then inject and use the factory:
68+
69+
```csharp
70+
public class PostImportService
71+
{
72+
private readonly IDbContextFactory<BlogDbContext> _factory;
73+
74+
public PostImportService(IDbContextFactory<BlogDbContext> factory)
75+
=> _factory = factory;
76+
77+
public async Task ImportPostsAsync(IEnumerable<Post> posts, CancellationToken ct)
78+
{
79+
var tasks = posts.Select(async post =>
5480
{
55-
sqliteOptions.UseWriteQueue = true; // Enable write serialization
56-
sqliteOptions.BusyTimeout = TimeSpan.FromSeconds(30);
57-
sqliteOptions.MaxRetryAttempts = 5;
58-
}));
81+
await using var db = _factory.CreateDbContext();
82+
db.Posts.Add(post);
83+
await db.SaveChangesAsync(ct);
84+
});
85+
86+
await Task.WhenAll(tasks); // ✅ Each task has its own context — no EF thread-safety violation
87+
}
88+
}
5989
```
6090

91+
> **Note:** `Cache=Shared` in the connection string is incompatible with WAL mode and will throw an `ArgumentException` at startup. Use the default connection string format (`Data Source=blog.db`) — connection pooling is enabled automatically.
92+
6193
## Basic Usage Examples
6294

6395
### Writing Data (Automatically Thread-Safe)
@@ -168,45 +200,60 @@ public class ImportService
168200
Imagine a scenario where multiple background workers are processing tasks:
169201

170202
```csharp
171-
// WITHOUT ThreadSafeEFCore.SQLite - This would fail with "database is locked"
203+
// ❌ WRONG — sharing one DbContext across concurrent tasks
204+
// EF Core will throw InvalidOperationException about concurrent usage,
205+
// and SQLite returns "database is locked" for simultaneous writers.
172206
public class TaskProcessor
173207
{
208+
private readonly AppDbContext _context; // shared — unsafe for concurrent use
209+
174210
public async Task ProcessTasksConcurrently()
175211
{
176212
var tasks = Enumerable.Range(1, 10)
177213
.Select(i => ProcessSingleTaskAsync(i));
178-
179-
await Task.WhenAll(tasks); // 💥 Database locked errors!
214+
215+
await Task.WhenAll(tasks); // 💥 EF thread-safety violation + database locked
216+
}
217+
218+
private async Task ProcessSingleTaskAsync(int taskId)
219+
{
220+
_context.TaskResults.Add(new TaskResult { TaskId = taskId });
221+
await _context.SaveChangesAsync(); // 💥 concurrent SaveChanges on one context
180222
}
181223
}
182224

183-
// WITH ThreadSafeEFCore.SQLite - Just works!
225+
// ✅ CORRECT — one context per concurrent flow via IDbContextFactory
226+
// Register with: builder.Services.AddConcurrentSqliteDbContextFactory<AppDbContext>("Data Source=app.db");
184227
public class TaskProcessor
185228
{
186-
private readonly AppDbContext _context;
187-
229+
private readonly IDbContextFactory<AppDbContext> _factory;
230+
231+
public TaskProcessor(IDbContextFactory<AppDbContext> factory)
232+
=> _factory = factory;
233+
188234
public async Task ProcessTasksConcurrently()
189235
{
190236
var tasks = Enumerable.Range(1, 10)
191237
.Select(i => ProcessSingleTaskAsync(i));
192-
238+
193239
await Task.WhenAll(tasks); // ✅ All tasks complete successfully
194240
}
195-
241+
196242
private async Task ProcessSingleTaskAsync(int taskId)
197243
{
198-
// Each task writes to the database
199244
var result = await PerformWorkAsync(taskId);
200-
201-
// The package automatically queues these writes
202-
_context.TaskResults.Add(new TaskResult
245+
246+
// Each concurrent flow creates and disposes its own context.
247+
// ThreadSafeEFCore.SQLite serializes the actual writes at the SQLite level.
248+
await using var db = _factory.CreateDbContext();
249+
db.TaskResults.Add(new TaskResult
203250
{
204251
TaskId = taskId,
205252
Result = result,
206253
CompletedAt = DateTime.UtcNow
207254
});
208-
209-
await _context.SaveChangesAsync();
255+
256+
await db.SaveChangesAsync(); // ✅ Thread-safe — no shared context, writes queued automatically
210257
}
211258
}
212259
```
@@ -216,8 +263,7 @@ public class TaskProcessor
216263
```csharp
217264
// Create contexts manually when needed
218265
var dbContext = ThreadSafeFactory.CreateContext<BlogDbContext>(
219-
"Data Source=blog.db",
220-
options => options.UseWriteQueue = true);
266+
"Data Source=blog.db");
221267

222268
// Use it
223269
await dbContext.Posts.AddAsync(new Post { Title = "Hello World" });
@@ -254,19 +300,47 @@ public async Task UpdatePostWithRetryAsync(int postId, string newContent)
254300

255301
| Option | Default | Description |
256302
|--------|---------|-------------|
257-
| `UseWriteQueue` | `true` | Automatically queue write operations |
258-
| `BusyTimeout` | 30 seconds | How long to wait if database is busy |
259-
| `MaxRetryAttempts` | 3 | Number of retries for busy errors |
260-
| `CommandTimeout` | 300 seconds | SQL command timeout |
261-
| `EnableWalCheckpointManagement` | `true` | Automatically manage WAL checkpoints |
303+
| `BusyTimeout` | 30 seconds | Per-connection `PRAGMA busy_timeout`. First layer of busy handling; SQLite retries lock acquisition internally for up to this duration. |
304+
| `MaxRetryAttempts` | 3 | Application-level retry attempts for `SQLITE_BUSY*` errors, with exponential backoff and jitter. |
305+
| `CommandTimeout` | 300 seconds | EF Core SQL command timeout in seconds. |
306+
| `WalAutoCheckpoint` | 1000 pages | WAL auto-checkpoint interval (`PRAGMA wal_autocheckpoint`). Each page is 4 096 bytes by default (~4 MB). Set to `0` to disable. |
307+
| `SynchronousMode` | `Normal` | Durability vs. performance trade-off (`PRAGMA synchronous`). `Normal` is recommended for WAL mode: safe against application crashes; a power loss or OS crash may roll back the last commit(s) not yet checkpointed. Use `Full` or `Extra` for stronger durability guarantees. |
308+
| `UpgradeTransactionsToImmediate` | `true` | Rewrites `BEGIN`/`BEGIN TRANSACTION` to `BEGIN IMMEDIATE` to prevent `SQLITE_BUSY_SNAPSHOT` mid-transaction. Disable only if you manage write transactions explicitly yourself. |
309+
310+
## Multi-Instance Deployments and Migration Locks
311+
312+
EF Core uses a `__EFMigrationsLock` table to serialize concurrent migrations. If a migration process crashes after acquiring the lock but before releasing it, subsequent calls to `Database.Migrate()` will block indefinitely.
313+
314+
**Recommended approach:** run migrations once as a controlled startup step rather than calling `Database.Migrate()` from every app instance simultaneously.
315+
316+
If a stale lock does occur, use the built-in helper to detect and clear it:
317+
318+
```csharp
319+
// In your startup or migration runner:
320+
using var db = factory.CreateDbContext();
321+
var connection = db.Database.GetDbConnection();
322+
await connection.OpenAsync();
323+
324+
var wasStale = await SqliteConnectionEnhancer.TryReleaseMigrationLockAsync(connection);
325+
if (wasStale)
326+
logger.LogWarning("Stale EF migration lock found and released. Proceeding with migration.");
327+
328+
await db.Database.MigrateAsync();
329+
```
330+
331+
Pass `release: false` to check for a stale lock without removing it (useful for diagnostics).
332+
333+
> **Network filesystem warning:** SQLite WAL mode requires all connections to be on the **same physical host**. Do not point the database at an NFS, SMB, or other network-mounted path. If your app runs across multiple machines or containers, use a client/server database instead.
262334
263335
## Best Practices
264336

265-
1. **Use Dependency Injection** when possible for automatic context management
266-
2. **Keep write transactions short** - queue your data and write quickly
267-
3. **Use `BulkInsertOptimizedAsync`** for importing large amounts of data
268-
4. **Enable WAL mode** (already done by default) for better concurrency
269-
5. **Monitor performance** with the built-in diagnostics when needed
337+
1. **Use `IDbContextFactory<T>` for concurrent workloads** — inject the factory and call `CreateDbContext()` per concurrent operation; never share a single `DbContext` instance across concurrent tasks
338+
2. **Use `AddConcurrentSqliteDbContext<T>` for request-scoped workloads** — standard ASP.NET Core controllers and Razor Pages where one request = one thread = one context
339+
3. **Keep write transactions short** — acquire the write slot, write, commit; long-held write transactions block all other writers
340+
4. **Use `BulkInsertOptimizedAsync`** for importing large amounts of data
341+
5. **WAL mode is enabled automatically** — do not add `Cache=Shared` to the connection string; it is incompatible with WAL
342+
6. **Run migrations from a single process** — avoid calling `Database.Migrate()` concurrently from multiple instances; use `TryReleaseMigrationLockAsync` if a stale lock occurs
343+
7. **Stay on local disk** — WAL mode does not work over network filesystems (NFS, SMB); use a client/server database for multi-host deployments
270344

271345
## What Makes It Different
272346

EntityFrameworkCore.Sqlite.Concurrency/doc/v10_0_0.md

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ options.UseSqlite("Data Source=app.db");
1919
// With this:
2020
options.UseSqliteWithConcurrency("Data Source=app.db");
2121
```
22-
Guaranteed 100% write reliability and up to 10x faster bulk operations.
22+
Eliminates write contention errors and provides up to 10x faster bulk operations.
2323

2424
---
2525

@@ -70,7 +70,7 @@ Next, explore high-performance bulk inserts or fine-tune the configuration.
7070
| **Mixed Read/Write Workload** | ~15.3 seconds | ~3.8 seconds | **4.0x faster** |
7171
| **Memory Usage (100k operations)** | ~425 MB | ~285 MB | **33% less memory** |
7272

73-
*Benchmark environment: .NET 10, Windows 11, Intel i7-13700K, 32GB RAM*
73+
**Benchmark environment:** .NET 10, Windows 11, Intel i7-13700K, 32GB RAM*
7474

7575
---
7676

@@ -82,13 +82,7 @@ public async Task PerformDataMigrationAsync(List<LegacyData> legacyRecords)
8282
{
8383
var modernRecords = legacyRecords.Select(ConvertToModernFormat);
8484

85-
await _context.BulkInsertSafeAsync(modernRecords, new BulkConfig
86-
{
87-
BatchSize = 5000,
88-
PreserveInsertOrder = true,
89-
EnableStreaming = true,
90-
UseOptimalTransactionSize = true
91-
});
85+
await _context.BulkInsertSafeAsyncmodernRecords);
9286
}
9387
```
9488

@@ -114,8 +108,7 @@ public async Task<TResult> ExecuteHighPerformanceOperationAsync<TResult>(
114108
Func<DbContext, Task<TResult>> operation)
115109
{
116110
using var context = ThreadSafeFactory.CreateContext<AppDbContext>(
117-
"Data Source=app.db",
118-
options => options.EnablePerformanceOptimizations = true);
111+
"Data Source=app.db");
119112

120113
return await context.ExecuteWithRetryAsync(operation, maxRetries: 2);
121114
}
@@ -136,7 +129,6 @@ services.AddDbContext<AppDbContext>(options =>
136129
concurrencyOptions.BusyTimeout = TimeSpan.FromSeconds(30);
137130
concurrencyOptions.MaxRetryAttempts = 3; // Performance-focused retry logic
138131
concurrencyOptions.CommandTimeout = 180; // 3-minute timeout for large operations
139-
concurrencyOptions.EnablePerformanceOptimizations = true; // Additional speed boosts
140132
}));
141133
```
142134

0 commit comments

Comments
 (0)