Skip to content

Commit f4d8a0a

Browse files
Add SaveChangesSerializedAsync and robust connection preparation
- Introduces `SaveChangesSerializedAsync` to provide an application-level write lock, serializing concurrent writers before they contend in SQLite. This includes retry logic with exponential backoff for `SQLITE_BUSY` errors. - Adds `PrepareForConnectionOpen` to proactively clear `ReadOnly` file attributes from database files (`.db` and `.db-wal`) and delete stale `.db-shm` files before a connection is opened. This addresses common `SQLITE_READONLY` issues, especially on Windows, and ensures proper WAL mode initialization. - Integrates `PrepareForConnectionOpen` into the `SqliteConcurrencyInterceptor` to run automatically during connection opening.
1 parent 2fe8e66 commit f4d8a0a

3 files changed

Lines changed: 190 additions & 11 deletions

File tree

EntityFrameworkCore.Sqlite.Concurrency/src/SqliteConcurrencyExtensions.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using EntityFrameworkCore.Sqlite.Concurrency.Models;
33
using Microsoft.EntityFrameworkCore;
44
using Microsoft.Data.Sqlite;
5+
using Microsoft.EntityFrameworkCore.Infrastructure;
56
using Microsoft.Extensions.DependencyInjection;
67

78
namespace EntityFrameworkCore.Sqlite.Concurrency;
@@ -118,6 +119,80 @@ public static async Task<T> ExecuteWithRetryAsync<T>(
118119
}
119120
}
120121

122+
/// <summary>
123+
/// Saves all changes in the context while holding the shared per-database write lock,
124+
/// serializing concurrent writers at the application level before they contend in SQLite.
125+
/// </summary>
126+
/// <param name="context">The database context.</param>
127+
/// <param name="maxRetries">
128+
/// Maximum number of retry attempts if <c>SQLITE_BUSY</c> is returned even after the
129+
/// write lock is held. Uses exponential backoff starting at 50 ms, capped at 2 000 ms.
130+
/// </param>
131+
/// <param name="cancellationToken">The cancellation token.</param>
132+
/// <returns>The number of state entries written to the database.</returns>
133+
/// <remarks>
134+
/// <para>
135+
/// Use this method instead of <see cref="DbContext.SaveChangesAsync(CancellationToken)"/>
136+
/// in background workers or other scenarios where multiple concurrent callers write to
137+
/// the same SQLite database. The shared lock ensures that at most one writer is active
138+
/// per database file at any point, avoiding SQLite-level busy-wait storms when many
139+
/// writers contend simultaneously.
140+
/// </para>
141+
/// <para>
142+
/// If the calling code is already inside a <see cref="BulkInsertOptimizedAsync{T}"/>
143+
/// (or any other scope that already holds the write lock), the method skips lock
144+
/// acquisition to prevent deadlocks.
145+
/// </para>
146+
/// </remarks>
147+
public static async Task<int> SaveChangesSerializedAsync(
148+
this DbContext context,
149+
int maxRetries = 3,
150+
CancellationToken cancellationToken = default)
151+
{
152+
if (SqliteConnectionEnhancer.IsWriteLockHeld.Value)
153+
return await context.SaveChangesAsync(cancellationToken);
154+
155+
var connectionString = context.Database.GetDbConnection().ConnectionString;
156+
var enhancedConnectionString = SqliteConnectionEnhancer.GetOptimizedConnectionString(connectionString);
157+
var writeLock = SqliteConnectionEnhancer.GetWriteLock(enhancedConnectionString);
158+
159+
await writeLock.WaitAsync(cancellationToken);
160+
SqliteConnectionEnhancer.IsWriteLockHeld.Value = true;
161+
162+
try
163+
{
164+
var delayMs = 50;
165+
for (var attempt = 1; ; attempt++)
166+
{
167+
cancellationToken.ThrowIfCancellationRequested();
168+
try
169+
{
170+
return await context.SaveChangesAsync(cancellationToken);
171+
}
172+
catch (Exception ex) when (attempt < maxRetries && IsRetryableSqliteBusy(ex))
173+
{
174+
await Task.Delay(delayMs, cancellationToken);
175+
delayMs = Math.Min(delayMs * 2, 2000);
176+
}
177+
}
178+
}
179+
finally
180+
{
181+
SqliteConnectionEnhancer.IsWriteLockHeld.Value = false;
182+
writeLock.Release();
183+
}
184+
}
185+
186+
// EF Core wraps SqliteException in DbUpdateException when SaveChangesAsync fails,
187+
// so we need to unwrap one level to classify the error.
188+
private static bool IsRetryableSqliteBusy(Exception ex) =>
189+
ex switch
190+
{
191+
SqliteException se => SqliteErrorCodes.IsRetryableBusy(se),
192+
DbUpdateException { InnerException: SqliteException inner } => SqliteErrorCodes.IsRetryableBusy(inner),
193+
_ => false
194+
};
195+
121196
/// <summary>
122197
/// Performs a bulk insert with optimized settings and optional app-level locking.
123198
/// </summary>

EntityFrameworkCore.Sqlite.Concurrency/src/SqliteConcurrencyInterceptor.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,16 @@ public void ReleasedSavepoint(DbTransaction transaction, TransactionEventData ev
234234

235235
// --- Connection Management (IConnectionInterceptor) ---
236236
/// <inheritdoc />
237-
public void ConnectionOpening(DbConnection connection, ConnectionEventData eventData) { }
237+
public void ConnectionOpening(DbConnection connection, ConnectionEventData eventData)
238+
{
239+
SqliteConnectionEnhancer.PrepareForConnectionOpen(connection);
240+
}
238241
/// <inheritdoc />
239-
public Task ConnectionOpeningAsync(DbConnection connection, ConnectionEventData eventData, CancellationToken cancellationToken = default) => Task.CompletedTask;
242+
public Task ConnectionOpeningAsync(DbConnection connection, ConnectionEventData eventData, CancellationToken cancellationToken = default)
243+
{
244+
SqliteConnectionEnhancer.PrepareForConnectionOpen(connection);
245+
return Task.CompletedTask;
246+
}
240247
/// <inheritdoc />
241248
public void ConnectionClosed(DbConnection connection, ConnectionEndEventData eventData) { }
242249
/// <inheritdoc />

EntityFrameworkCore.Sqlite.Concurrency/src/SqliteConnectionEnhancer.cs

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ public static class SqliteConnectionEnhancer
3131

3232
private static readonly ConcurrentDictionary<string, bool> _initializedDatabases = new();
3333

34+
// Tracks databases that have had pre-open file preparation (ReadOnly clearing, stale shm
35+
// deletion) applied. Keyed by the resolved absolute path so that relative and absolute
36+
// forms of the same path map to the same entry.
37+
private static readonly ConcurrentDictionary<string, bool> _preparedDatabases = new();
38+
3439
/// <summary>
3540
/// Gets an optimized version of the provided connection string.
3641
/// </summary>
@@ -122,6 +127,94 @@ private static string ComputeOptimizedConnectionString(string originalConnection
122127
return builder.ToString();
123128
}
124129

130+
/// <summary>
131+
/// Prepares the database file for opening: clears the read-only file attribute and removes
132+
/// any stale <c>.db-shm</c> file. Must be called <em>before</em> the connection is opened
133+
/// so that SQLite sees a writable file and does not fall back to a read-only pager.
134+
/// </summary>
135+
/// <remarks>
136+
/// <para>
137+
/// On Windows, MSBuild sometimes stamps the database file with <see cref="FileAttributes.ReadOnly"/>
138+
/// when it is included in the project as <c>Content</c> with <c>CopyToOutputDirectory</c>.
139+
/// SQLite's <c>sqlite3_open_v2</c> attempts <c>O_RDWR</c>; if the OS returns
140+
/// <c>EACCES</c>, it silently falls back to <c>O_RDONLY</c>, which permanently marks the
141+
/// pager as read-only. Any subsequent write attempt — including
142+
/// <c>PRAGMA journal_mode = WAL</c> — then returns <c>SQLITE_READONLY</c>. Clearing the
143+
/// attribute before the call to <c>Open()</c> prevents this fallback.
144+
/// </para>
145+
/// <para>
146+
/// The <c>.db-shm</c> file is SQLite's shared-memory hash table that indexes WAL frames.
147+
/// It contains no committed data and is always recreated on the next WAL open. A stale
148+
/// copy left by a previous crashed process can cause <c>SQLITE_READONLY_CANTINIT</c>
149+
/// because SQLite detects a header mismatch.
150+
/// </para>
151+
/// <para>
152+
/// This method runs at most once per database file per process (subsequent calls are
153+
/// no-ops). It is safe to call from multiple threads; an internal lock serializes the
154+
/// first call.
155+
/// </para>
156+
/// </remarks>
157+
/// <param name="connection">The connection that is about to be opened.</param>
158+
public static void PrepareForConnectionOpen(DbConnection connection)
159+
{
160+
if (connection is not SqliteConnection sqliteConnection)
161+
return;
162+
163+
var builder = new SqliteConnectionStringBuilder(sqliteConnection.ConnectionString);
164+
var dataSource = builder.DataSource;
165+
166+
if (string.IsNullOrEmpty(dataSource))
167+
return;
168+
169+
// Resolve to an absolute path using the same working directory that SQLite will use,
170+
// so that relative forms (e.g. "Data Source=app.db") and absolute forms map to the
171+
// same key. Path.GetFullPath is a no-op when the path is already absolute.
172+
var dbFullPath = Path.GetFullPath(dataSource);
173+
174+
if (_preparedDatabases.ContainsKey(dbFullPath))
175+
return;
176+
177+
var lockObj = _pragmaLocks.GetOrAdd(dbFullPath, _ => new object());
178+
lock (lockObj)
179+
{
180+
if (_preparedDatabases.ContainsKey(dbFullPath))
181+
return;
182+
183+
// Clear the read-only attribute from the database file so SQLite can open
184+
// it in read-write mode. This must happen before Open() is called.
185+
TryClearReadOnly(dbFullPath);
186+
187+
// Clear the read-only attribute from the WAL file so that WAL recovery can
188+
// complete. If the WAL file is read-only, SQLite falls back to a read-only
189+
// pager during recovery, which then blocks PRAGMA journal_mode = WAL.
190+
TryClearReadOnly(dbFullPath + "-wal");
191+
192+
// Delete the stale shm file. If another process currently has the database
193+
// open the file will be locked on Windows and File.Delete will throw — we
194+
// leave it untouched in that case and let SQLite sort it out.
195+
var shmPath = dbFullPath + "-shm";
196+
if (File.Exists(shmPath))
197+
{
198+
try { File.Delete(shmPath); }
199+
catch { /* locked by live process — leave it alone */ }
200+
}
201+
202+
_preparedDatabases.TryAdd(dbFullPath, true);
203+
}
204+
}
205+
206+
private static void TryClearReadOnly(string path)
207+
{
208+
try
209+
{
210+
if (!File.Exists(path)) return;
211+
var attrs = File.GetAttributes(path);
212+
if ((attrs & FileAttributes.ReadOnly) != 0)
213+
File.SetAttributes(path, attrs & ~FileAttributes.ReadOnly);
214+
}
215+
catch { /* best-effort */ }
216+
}
217+
125218
/// <summary>
126219
/// Applies runtime PRAGMAs to the specified connection using default options.
127220
/// </summary>
@@ -144,24 +237,28 @@ public static void ApplyRuntimePragmas(DbConnection connection, SqliteConcurrenc
144237
var builder = new SqliteConnectionStringBuilder(sqliteConnection.ConnectionString);
145238
var dataSource = builder.DataSource;
146239

240+
// Resolve to an absolute path so that relative forms ("Data Source=app.db") and
241+
// absolute forms of the same file map to the same _initializedDatabases key.
242+
var dbKey = string.IsNullOrEmpty(dataSource) ? dataSource : Path.GetFullPath(dataSource);
243+
147244
// 1. Database-scoped PRAGMAs — executed once per process per database file.
148245
// These settings are persistent (stored in the database header) and affect all
149246
// connections to the same file.
150-
if (!_initializedDatabases.ContainsKey(dataSource))
247+
if (!_initializedDatabases.ContainsKey(dbKey))
151248
{
152-
var lockObj = _pragmaLocks.GetOrAdd(dataSource, _ => new object());
249+
var lockObj = _pragmaLocks.GetOrAdd(dbKey, _ => new object());
153250
lock (lockObj)
154251
{
155-
if (!_initializedDatabases.ContainsKey(dataSource))
252+
if (!_initializedDatabases.ContainsKey(dbKey))
156253
{
157254
var logger = options.LoggerFactory?.CreateLogger(nameof(SqliteConnectionEnhancer));
158255

159256
// WAL mode is initialized first and in isolation so that a SQLITE_READONLY
160-
// failure (most commonly SQLITE_READONLY_CANTINIT on Windows when the .db-shm
161-
// file cannot be created or has wrong permissions) is caught and logged as a
162-
// warning rather than crashing the entire interceptor. Without WAL the database
163-
// still functions; concurrent write performance is reduced because reads and
164-
// writes cannot proceed simultaneously.
257+
// failure is logged as a warning rather than crashing the entire interceptor.
258+
// PrepareForConnectionOpen (called from ConnectionOpening before sqlite3_open_v2)
259+
// handles the common root causes proactively. If WAL still fails here, the
260+
// database falls back to the default journal mode — concurrent read/write
261+
// performance is reduced but the database remains functional.
165262
try
166263
{
167264
using var walCommand = sqliteConnection.CreateCommand();
@@ -216,7 +313,7 @@ public static void ApplyRuntimePragmas(DbConnection connection, SqliteConcurrenc
216313

217314
// Mark as initialized regardless of partial failures above so that the
218315
// failed PRAGMAs are not retried on every subsequent connection open.
219-
_initializedDatabases.TryAdd(dataSource, true);
316+
_initializedDatabases.TryAdd(dbKey, true);
220317
}
221318
}
222319
}

0 commit comments

Comments
 (0)