Skip to content

Commit a662da8

Browse files
additional best pratices
What was changed SqliteConcurrencyServiceCollectionExtensions.cs Added AddConcurrentSqliteDbContextFactory<TContext> — registers IDbContextFactory<TContext> with concurrency settings and auto-injected ILoggerFactory. Factory lifetime defaults to Singleton (appropriate since the factory holds no per-request state). Updated AddConcurrentSqliteDbContext XML doc to point users toward the factory overload for concurrent workloads. SqliteConnectionEnhancer.cs ComputeOptimizedConnectionString now throws ArgumentException when Cache=Shared is detected, with a clear message explaining the WAL incompatibility and pointing to connection pooling as the correct alternative. Added TryReleaseMigrationLockAsync — checks for a stale __EFMigrationsLock row and optionally deletes it. Accepts a release: false flag for diagnostic-only checks. Includes full XML docs covering when and why stale locks occur. QUICKSTART.md "Configure" section now shows both registration paths with guidance on when to use each. "Real-World Scenario" corrected: the bad pattern now shows the EF thread-safety violation explicitly, and the good pattern uses IDbContextFactory + CreateDbContext() per task. New "Multi-Instance Deployments and Migration Locks" section with TryReleaseMigrationLockAsync usage and network filesystem warning. Best Practices list updated to 7 items covering factory pattern, Cache=Shared, migration lock, and single-host constraint.
1 parent 87c2176 commit a662da8

3 files changed

Lines changed: 281 additions & 38 deletions

File tree

EntityFrameworkCore.Sqlite.Concurrency/doc/QUICKSTART.md

Lines changed: 106 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -34,29 +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.BusyTimeout = TimeSpan.FromSeconds(30);
56-
sqliteOptions.MaxRetryAttempts = 5;
57-
}));
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+
}
5889
```
5990

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+
6093
## Basic Usage Examples
6194

6295
### Writing Data (Automatically Thread-Safe)
@@ -167,45 +200,60 @@ public class ImportService
167200
Imagine a scenario where multiple background workers are processing tasks:
168201

169202
```csharp
170-
// 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.
171206
public class TaskProcessor
172207
{
208+
private readonly AppDbContext _context; // shared — unsafe for concurrent use
209+
173210
public async Task ProcessTasksConcurrently()
174211
{
175212
var tasks = Enumerable.Range(1, 10)
176213
.Select(i => ProcessSingleTaskAsync(i));
177-
178-
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
179222
}
180223
}
181224

182-
// 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");
183227
public class TaskProcessor
184228
{
185-
private readonly AppDbContext _context;
186-
229+
private readonly IDbContextFactory<AppDbContext> _factory;
230+
231+
public TaskProcessor(IDbContextFactory<AppDbContext> factory)
232+
=> _factory = factory;
233+
187234
public async Task ProcessTasksConcurrently()
188235
{
189236
var tasks = Enumerable.Range(1, 10)
190237
.Select(i => ProcessSingleTaskAsync(i));
191-
238+
192239
await Task.WhenAll(tasks); // ✅ All tasks complete successfully
193240
}
194-
241+
195242
private async Task ProcessSingleTaskAsync(int taskId)
196243
{
197-
// Each task writes to the database
198244
var result = await PerformWorkAsync(taskId);
199-
200-
// The package automatically queues these writes
201-
_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
202250
{
203251
TaskId = taskId,
204252
Result = result,
205253
CompletedAt = DateTime.UtcNow
206254
});
207-
208-
await _context.SaveChangesAsync();
255+
256+
await db.SaveChangesAsync(); // ✅ Thread-safe — no shared context, writes queued automatically
209257
}
210258
}
211259
```
@@ -259,13 +307,40 @@ public async Task UpdatePostWithRetryAsync(int postId, string newContent)
259307
| `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. |
260308
| `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. |
261309

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.
334+
262335
## Best Practices
263336

264-
1. **Use Dependency Injection** when possible for automatic context management
265-
2. **Keep write transactions short** - queue your data and write quickly
266-
3. **Use `BulkInsertOptimizedAsync`** for importing large amounts of data
267-
4. **Enable WAL mode** (already done by default) for better concurrency
268-
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
269344

270345
## What Makes It Different
271346

EntityFrameworkCore.Sqlite.Concurrency/src/ExtensionMethods/SqliteConcurrencyServiceCollectionExtensions.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,20 @@ public static class SqliteConcurrencyServiceCollectionExtensions
2020
/// <param name="contextLifetime">The lifetime of the DbContext.</param>
2121
/// <returns>The service collection.</returns>
2222
/// <remarks>
23+
/// <para>
2324
/// This overload automatically resolves <see cref="ILoggerFactory"/> from the DI
2425
/// container and injects it into the concurrency options so that <c>SQLITE_BUSY*</c>
2526
/// events and <c>BEGIN IMMEDIATE</c> upgrades are logged through the application's
2627
/// normal logging pipeline.
28+
/// </para>
29+
/// <para>
30+
/// A <see cref="DbContext"/> instance is <b>not thread-safe</b>. For workloads that
31+
/// create concurrent database operations (e.g. <c>Task.WhenAll</c>, background queues,
32+
/// hosted services), use
33+
/// <see cref="AddConcurrentSqliteDbContextFactory{TContext}(IServiceCollection, string, Action{SqliteConcurrencyOptions}?, ServiceLifetime)"/>
34+
/// instead and inject <see cref="Microsoft.EntityFrameworkCore.IDbContextFactory{TContext}"/>
35+
/// to create a separate context per concurrent operation.
36+
/// </para>
2737
/// </remarks>
2838
public static IServiceCollection AddConcurrentSqliteDbContext<TContext>(
2939
this IServiceCollection services,
@@ -47,4 +57,74 @@ public static IServiceCollection AddConcurrentSqliteDbContext<TContext>(
4757

4858
return services;
4959
}
60+
61+
/// <summary>
62+
/// Adds an <see cref="Microsoft.EntityFrameworkCore.IDbContextFactory{TContext}"/>
63+
/// configured with optimized SQLite concurrency and performance settings.
64+
/// </summary>
65+
/// <typeparam name="TContext">The type of the DbContext.</typeparam>
66+
/// <param name="services">The service collection.</param>
67+
/// <param name="connectionString">The SQLite connection string.</param>
68+
/// <param name="configure">An optional action to configure concurrency options.</param>
69+
/// <param name="factoryLifetime">
70+
/// The lifetime of the factory. Defaults to <see cref="ServiceLifetime.Singleton"/>
71+
/// because the factory itself holds no per-request state and is safe to share.
72+
/// </param>
73+
/// <returns>The service collection.</returns>
74+
/// <remarks>
75+
/// <para>
76+
/// Prefer this overload whenever operations execute concurrently — for example inside
77+
/// <c>Task.WhenAll</c>, <c>Channel&lt;T&gt;</c> consumers,
78+
/// <c>IHostedService</c> workers, or
79+
/// <c>Parallel.ForEachAsync</c>. Each concurrent flow should call
80+
/// <see cref="Microsoft.EntityFrameworkCore.IDbContextFactory{TContext}.CreateDbContext"/>
81+
/// to obtain its own independent context instance, which eliminates EF Core's
82+
/// object-level thread-safety restriction entirely.
83+
/// </para>
84+
/// <para>
85+
/// This overload automatically resolves <see cref="ILoggerFactory"/> from the DI
86+
/// container so that <c>SQLITE_BUSY*</c> events and <c>BEGIN IMMEDIATE</c> upgrades
87+
/// are logged through the application's normal logging pipeline.
88+
/// </para>
89+
/// <example>
90+
/// <code>
91+
/// // Registration
92+
/// builder.Services.AddConcurrentSqliteDbContextFactory&lt;AppDbContext&gt;(
93+
/// "Data Source=app.db");
94+
///
95+
/// // Concurrent use — each task gets its own context
96+
/// public async Task ProcessAllAsync(IEnumerable&lt;int&gt; ids, CancellationToken ct)
97+
/// {
98+
/// var tasks = ids.Select(async id =>
99+
/// {
100+
/// await using var db = _factory.CreateDbContext();
101+
/// // ... read and write with db
102+
/// });
103+
/// await Task.WhenAll(tasks);
104+
/// }
105+
/// </code>
106+
/// </example>
107+
/// </remarks>
108+
public static IServiceCollection AddConcurrentSqliteDbContextFactory<TContext>(
109+
this IServiceCollection services,
110+
string connectionString,
111+
Action<SqliteConcurrencyOptions>? configure = null,
112+
ServiceLifetime factoryLifetime = ServiceLifetime.Singleton)
113+
where TContext : DbContext
114+
{
115+
services.AddDbContextFactory<TContext>((provider, options) =>
116+
{
117+
options.UseSqliteWithConcurrency(connectionString, o =>
118+
{
119+
configure?.Invoke(o);
120+
121+
// Inject the singleton ILoggerFactory so the interceptor can emit
122+
// structured logs without the caller having to wire it up manually.
123+
if (o.LoggerFactory is null)
124+
o.LoggerFactory = provider.GetService<ILoggerFactory>();
125+
});
126+
}, factoryLifetime);
127+
128+
return services;
129+
}
50130
}

0 commit comments

Comments
 (0)