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:
@@ -20,15 +20,32 @@ public class SiteStorageService
|
|||||||
/// <param name="logger">Logger instance for diagnostic messages.</param>
|
/// <param name="logger">Logger instance for diagnostic messages.</param>
|
||||||
public SiteStorageService(string connectionString, ILogger<SiteStorageService> logger)
|
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;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Busy-timeout floor in seconds applied to the site connection string (S8).</summary>
|
||||||
|
private const int BusyTimeoutFloorSeconds = 5;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new (unopened) SQLite connection against the site database.
|
/// Creates a new (unopened) SQLite connection against the site database.
|
||||||
/// Exposed so site-local repositories can open their own connections without
|
/// Exposed so site-local repositories can open their own connections without
|
||||||
/// reaching into private state via reflection. The caller owns
|
/// reaching into private state via reflection. The caller owns
|
||||||
/// the connection and is responsible for opening and disposing it.
|
/// 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>
|
/// </summary>
|
||||||
/// <returns>A new, unopened <see cref="SqliteConnection"/> against the site database.</returns>
|
/// <returns>A new, unopened <see cref="SqliteConnection"/> against the site database.</returns>
|
||||||
public SqliteConnection CreateConnection() => new(_connectionString);
|
public SqliteConnection CreateConnection() => new(_connectionString);
|
||||||
@@ -43,6 +60,21 @@ public class SiteStorageService
|
|||||||
await using var connection = new SqliteConnection(_connectionString);
|
await using var connection = new SqliteConnection(_connectionString);
|
||||||
await connection.OpenAsync();
|
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();
|
await using var command = connection.CreateCommand();
|
||||||
command.CommandText = @"
|
command.CommandText = @"
|
||||||
CREATE TABLE IF NOT EXISTS deployed_configurations (
|
CREATE TABLE IF NOT EXISTS deployed_configurations (
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
|
|||||||
await _storage.InitializeAsync();
|
await _storage.InitializeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_EnablesWalJournalMode()
|
||||||
|
{
|
||||||
|
// WAL is set once at InitializeAsync (persistent, database-level). A file-backed DB
|
||||||
|
// is required — WAL is not available for :memory: databases.
|
||||||
|
await using var conn = _storage.CreateConnection();
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA journal_mode;";
|
||||||
|
var mode = (string)(await cmd.ExecuteScalarAsync())!;
|
||||||
|
Assert.Equal("wal", mode.ToLowerInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StoreAndRetrieve_DeployedConfig_RoundTrips()
|
public async Task StoreAndRetrieve_DeployedConfig_RoundTrips()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user