diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs index de559034..7391a057 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs @@ -20,15 +20,32 @@ public class SiteStorageService /// Logger instance for diagnostic messages. public SiteStorageService(string connectionString, ILogger 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; } + /// Busy-timeout floor in seconds applied to the site connection string (S8). + private const int BusyTimeoutFloorSeconds = 5; + /// /// 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. + /// + /// The database runs in WAL journal mode (set once in ) 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. + /// /// /// A new, unopened against the site database. 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 ( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs index 8adcb42d..549a73ad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs @@ -42,6 +42,19 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable 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] public async Task StoreAndRetrieve_DeployedConfig_RoundTrips() {