fix(site-runtime): site SQLite runs WAL with an explicit busy-timeout floor (S8) — concurrent writers stop serializing on the rollback journal

This commit is contained in:
Joseph Doherty
2026-07-09 01:00:12 -04:00
parent dd16cd52b2
commit d2feb92bfd
2 changed files with 46 additions and 1 deletions
@@ -20,15 +20,32 @@ public class SiteStorageService
/// <param name="logger">Logger instance for diagnostic messages.</param>
public SiteStorageService(string connectionString, ILogger<SiteStorageService> logger)
{
_connectionString = connectionString;
// Normalize the connection string and pin a busy-timeout floor (S8). WAL lets a reader
// and a writer proceed concurrently, but two writers still contend; Microsoft.Data.Sqlite
// drives its busy handler off the command timeout, so a floor of 5 s means a briefly
// busy database is waited-on rather than failing fast with SQLITE_BUSY. Only raise a
// caller-supplied value that is lower than the floor (the library default of 30 s stays).
var builder = new SqliteConnectionStringBuilder(connectionString);
if (builder.DefaultTimeout < BusyTimeoutFloorSeconds)
builder.DefaultTimeout = BusyTimeoutFloorSeconds;
_connectionString = builder.ToString();
_logger = logger;
}
/// <summary>Busy-timeout floor in seconds applied to the site connection string (S8).</summary>
private const int BusyTimeoutFloorSeconds = 5;
/// <summary>
/// Creates a new (unopened) SQLite connection against the site database.
/// Exposed so site-local repositories can open their own connections without
/// reaching into private state via reflection. The caller owns
/// the connection and is responsible for opening and disposing it.
/// <para>
/// The database runs in WAL journal mode (set once in <see cref="InitializeAsync"/>) with a
/// busy-timeout floor (see the constructor), so connections handed out here inherit
/// concurrent-reader/writer behavior and wait out a briefly-busy database instead of
/// failing with SQLITE_BUSY.
/// </para>
/// </summary>
/// <returns>A new, unopened <see cref="SqliteConnection"/> against the site database.</returns>
public SqliteConnection CreateConnection() => new(_connectionString);
@@ -43,6 +60,21 @@ public class SiteStorageService
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
// Switch to WAL journal mode (S8). WAL is persistent (database-level, survives across
// connections) so this one-time set is enough. It lets readers and a writer proceed
// concurrently instead of every access serializing on the rollback journal. A
// :memory: database or a filesystem that cannot support WAL falls back to its prior
// mode — we log the result rather than throwing.
await using (var walCommand = connection.CreateCommand())
{
walCommand.CommandText = "PRAGMA journal_mode=WAL;";
var resultingMode = (await walCommand.ExecuteScalarAsync()) as string ?? "unknown";
if (!string.Equals(resultingMode, "wal", StringComparison.OrdinalIgnoreCase))
_logger.LogWarning(
"Site SQLite could not enable WAL journal mode (got '{Mode}') — concurrent access may serialize",
resultingMode);
}
await using var command = connection.CreateCommand();
command.CommandText = @"
CREATE TABLE IF NOT EXISTS deployed_configurations (