feat(localdb): rewire SiteEventLogger onto the consolidated site database

Completes Task 5a of the LocalDb Phase 1 adoption plan. The GUID-id half landed
with Task 2 (727fa48c) because leaving it out would have committed a writer that
could not satisfy site_events.id NOT NULL; this is the connection half, which
was blocked on Task 3.

SiteEventLogger owned a SqliteConnection built from SiteEventLogOptions
.DatabasePath. It now takes ILocalDb and gets that connection from
CreateConnection() - already open, pragma-configured, and carrying the
zb_hlc_next() UDF that site_events' capture triggers call. It still owns and
disposes the connection; WithConnection's signature and locking semantics are
untouched, so EventLogQueryService and EventLogPurgeService are unaffected.

The connectionStringOverride seam is deleted rather than preserved. site_events
is a replicated table, so a raw connection lacks the UDF and fails closed on
every insert - an override could only produce a logger that cannot write. It had
no callers outside this class (the connectionStringOverride hits elsewhere in the
tree belong to SqliteAuditWriter, a different type).

Tests: a shared TestLocalDb helper gives each fixture a real ILocalDb over a temp
file. Real rather than stubbed on purpose - a stub handing back a bare
SqliteConnection would pass today and fail closed the moment the table is
registered, which is exactly the silent-wiring failure mode this family has hit
three times. ServiceWiringTests' DI path also needed AddZbLocalDb, mirroring
SiteServiceRegistration.

Verified: build 0 warnings; SiteEventLogging 70/70, Host 294/294,
SiteRuntime 530/530.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-19 09:07:49 -04:00
parent 0b5e9b44f6
commit 0d8a80aa27
9 changed files with 154 additions and 36 deletions
@@ -2,6 +2,7 @@ using System.Threading.Channels;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging;
@@ -40,29 +41,36 @@ public class SiteEventLogger : ISiteEventLogger, IDisposable
private bool _disposed; private bool _disposed;
/// <summary> /// <summary>
/// Initializes the event logger, opens the SQLite connection, and starts the background writer loop. /// Initializes the event logger over the consolidated site database and starts the
/// background writer loop.
/// </summary> /// </summary>
/// <param name="options">Site event log configuration (database path, retention settings).</param> /// <param name="options">Site event log configuration (retention settings, queue capacity).</param>
/// <param name="logger">Logger for write-failure diagnostics.</param> /// <param name="logger">Logger for write-failure diagnostics.</param>
/// <param name="connectionStringOverride">Optional connection string override; uses the configured path when null.</param> /// <param name="localDb">
/// The consolidated site database. The connection it hands out is already open and
/// carries the <c>zb_hlc_next()</c> UDF that <c>site_events</c>' capture triggers
/// call. This logger owns the connection for its lifetime and disposes it.
/// </param>
/// <remarks>
/// The former <c>connectionStringOverride</c> seam is deliberately gone rather than
/// preserved. <c>site_events</c> is a replicated table, so a raw connection lacking
/// the UDF would fail closed on every insert — an override would only let a caller
/// build a logger that cannot write. <c>SiteEventLogOptions.DatabasePath</c> is
/// likewise no longer read: the location is <c>LocalDb:Path</c>.
/// </remarks>
public SiteEventLogger( public SiteEventLogger(
IOptions<SiteEventLogOptions> options, IOptions<SiteEventLogOptions> options,
ILogger<SiteEventLogger> logger, ILogger<SiteEventLogger> logger,
string? connectionStringOverride = null) ILocalDb localDb)
{ {
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(localDb);
_logger = logger; _logger = logger;
// Cache=Shared is a cross-connection optimisation // Already open — CreateConnection returns a live, pragma-configured,
// that lets multiple SqliteConnections share an in-process page cache. // UDF-registered connection. Calling Open() on it again would throw.
// This logger owns exactly one SqliteConnection and serialises all _connection = localDb.CreateConnection();
// access through _writeLock, so the mode is dormant — at best dead
// configuration, at worst a small future foot-gun for any second
// connection opened to the same file. A test path that genuinely
// needs Cache=Shared can still inject it via connectionStringOverride.
var connectionString = connectionStringOverride
?? $"Data Source={options.Value.DatabasePath}";
_connection = new SqliteConnection(connectionString);
_connection.Open();
InitializeSchema(); InitializeSchema();
@@ -11,20 +11,27 @@ namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
public class EventLogCoverageTests : IDisposable public class EventLogCoverageTests : IDisposable
{ {
private readonly string _dbPath; private readonly string _dbPath;
private readonly TestLocalDb _localDb;
public EventLogCoverageTests() public EventLogCoverageTests()
{ {
_dbPath = Path.Combine(Path.GetTempPath(), $"test_coverage_{Guid.NewGuid()}.db"); _dbPath = Path.Combine(Path.GetTempPath(), $"test_coverage_{Guid.NewGuid()}.db");
_localDb = TestLocalDb.Create(_dbPath);
} }
public void Dispose() public void Dispose()
{ {
if (File.Exists(_dbPath)) File.Delete(_dbPath); _localDb.Dispose();
TestLocalDb.DeleteFiles(_dbPath);
GC.SuppressFinalize(this);
} }
// Each call gets its own connection from the one shared local database, so the
// loggers these tests dispose mid-test do not tear down the database itself.
private SiteEventLogger NewLogger() => new( private SiteEventLogger NewLogger() => new(
Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }), Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }),
NullLogger<SiteEventLogger>.Instance); NullLogger<SiteEventLogger>.Instance,
_localDb.Db);
private static EventLogQueryRequest MakeRequest() => new( private static EventLogQueryRequest MakeRequest() => new(
CorrelationId: "corr-err", CorrelationId: "corr-err",
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
public class EventLogPurgeServiceTests : IDisposable public class EventLogPurgeServiceTests : IDisposable
{ {
private readonly SiteEventLogger _eventLogger; private readonly SiteEventLogger _eventLogger;
private readonly TestLocalDb _localDb;
private readonly string _dbPath; private readonly string _dbPath;
private readonly SiteEventLogOptions _options; private readonly SiteEventLogOptions _options;
@@ -27,15 +28,19 @@ public class EventLogPurgeServiceTests : IDisposable
RetentionDays = 30, RetentionDays = 30,
MaxStorageMb = 1024 MaxStorageMb = 1024
}; };
_localDb = TestLocalDb.Create(_dbPath);
_eventLogger = new SiteEventLogger( _eventLogger = new SiteEventLogger(
Options.Create(_options), Options.Create(_options),
NullLogger<SiteEventLogger>.Instance); NullLogger<SiteEventLogger>.Instance,
_localDb.Db);
} }
public void Dispose() public void Dispose()
{ {
_eventLogger.Dispose(); _eventLogger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath); _localDb.Dispose();
TestLocalDb.DeleteFiles(_dbPath);
GC.SuppressFinalize(this);
} }
private EventLogPurgeService CreatePurgeService( private EventLogPurgeService CreatePurgeService(
@@ -8,6 +8,7 @@ public class EventLogQueryServiceTests : IDisposable
{ {
private readonly SiteEventLogger _eventLogger; private readonly SiteEventLogger _eventLogger;
private readonly EventLogQueryService _queryService; private readonly EventLogQueryService _queryService;
private readonly TestLocalDb _localDb;
private readonly string _dbPath; private readonly string _dbPath;
public EventLogQueryServiceTests() public EventLogQueryServiceTests()
@@ -18,7 +19,8 @@ public class EventLogQueryServiceTests : IDisposable
DatabasePath = _dbPath, DatabasePath = _dbPath,
QueryPageSize = 500 QueryPageSize = 500
}); });
_eventLogger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance); _localDb = TestLocalDb.Create(_dbPath);
_eventLogger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db);
_queryService = new EventLogQueryService( _queryService = new EventLogQueryService(
_eventLogger, _eventLogger,
options, options,
@@ -28,7 +30,9 @@ public class EventLogQueryServiceTests : IDisposable
public void Dispose() public void Dispose()
{ {
_eventLogger.Dispose(); _eventLogger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath); _localDb.Dispose();
TestLocalDb.DeleteFiles(_dbPath);
GC.SuppressFinalize(this);
} }
private async Task SeedEvents() private async Task SeedEvents()
@@ -367,7 +371,8 @@ public class EventLogQueryServiceTests : IDisposable
QueryPageSize = 500, QueryPageSize = 500,
MaxQueryPageSize = 3, MaxQueryPageSize = 3,
}); });
using var logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance); using var localDb = TestLocalDb.Create(dbPath);
using var logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, localDb.Db);
var query = new EventLogQueryService(logger, options, NullLogger<EventLogQueryService>.Instance); var query = new EventLogQueryService(logger, options, NullLogger<EventLogQueryService>.Instance);
try try
@@ -391,7 +396,8 @@ public class EventLogQueryServiceTests : IDisposable
finally finally
{ {
logger.Dispose(); logger.Dispose();
if (File.Exists(dbPath)) File.Delete(dbPath); localDb.Dispose();
TestLocalDb.DeleteFiles(dbPath);
} }
} }
} }
@@ -13,12 +13,14 @@ public class SchemaIndexTests : IDisposable
private readonly SiteEventLogger _logger; private readonly SiteEventLogger _logger;
private readonly SqliteConnection _verifyConnection; private readonly SqliteConnection _verifyConnection;
private readonly string _dbPath; private readonly string _dbPath;
private readonly TestLocalDb _localDb;
public SchemaIndexTests() public SchemaIndexTests()
{ {
_dbPath = Path.Combine(Path.GetTempPath(), $"test_index_{Guid.NewGuid()}.db"); _dbPath = Path.Combine(Path.GetTempPath(), $"test_index_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }); var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath });
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance); _localDb = TestLocalDb.Create(_dbPath);
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db);
_verifyConnection = new SqliteConnection($"Data Source={_dbPath}"); _verifyConnection = new SqliteConnection($"Data Source={_dbPath}");
_verifyConnection.Open(); _verifyConnection.Open();
@@ -28,7 +30,8 @@ public class SchemaIndexTests : IDisposable
{ {
_verifyConnection.Dispose(); _verifyConnection.Dispose();
_logger.Dispose(); _logger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath); _localDb.Dispose();
TestLocalDb.DeleteFiles(_dbPath);
} }
[Fact] [Fact]
@@ -1,6 +1,8 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
@@ -23,7 +25,7 @@ public class ServiceWiringTests : IDisposable
public void Dispose() public void Dispose()
{ {
_provider?.Dispose(); _provider?.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath); TestLocalDb.DeleteFiles(_dbPath);
} }
[Fact] [Fact]
@@ -32,6 +34,15 @@ public class ServiceWiringTests : IDisposable
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
services.Configure<SiteEventLogOptions>(o => o.DatabasePath = _dbPath); services.Configure<SiteEventLogOptions>(o => o.DatabasePath = _dbPath);
// SiteEventLogger now takes ILocalDb, so the consolidated site database has to
// be in the graph for AddSiteEventLogging to resolve at all. In the host this
// comes from SiteServiceRegistration's AddZbLocalDb.
services.AddZbLocalDb(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LocalDb:Path"] = _dbPath,
})
.Build());
services.AddSiteEventLogging(); services.AddSiteEventLogging();
_provider = services.BuildServiceProvider(); _provider = services.BuildServiceProvider();
@@ -57,8 +68,9 @@ public class ServiceWiringTests : IDisposable
// concrete SiteEventLogger. Constructing them directly must compile and work. // concrete SiteEventLogger. Constructing them directly must compile and work.
var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }); var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath });
using var loggerFactory = LoggerFactory.Create(_ => { }); using var loggerFactory = LoggerFactory.Create(_ => { });
using var localDb = TestLocalDb.Create(_dbPath);
using var recorder = new SiteEventLogger( using var recorder = new SiteEventLogger(
options, loggerFactory.CreateLogger<SiteEventLogger>()); options, loggerFactory.CreateLogger<SiteEventLogger>(), localDb.Db);
var purge = new EventLogPurgeService( var purge = new EventLogPurgeService(
recorder, options, loggerFactory.CreateLogger<EventLogPurgeService>()); recorder, options, loggerFactory.CreateLogger<EventLogPurgeService>());
@@ -13,18 +13,21 @@ public class SiteEventLoggerAsyncTests : IDisposable
{ {
private readonly SiteEventLogger _logger; private readonly SiteEventLogger _logger;
private readonly string _dbPath; private readonly string _dbPath;
private readonly TestLocalDb _localDb;
public SiteEventLoggerAsyncTests() public SiteEventLoggerAsyncTests()
{ {
_dbPath = Path.Combine(Path.GetTempPath(), $"test_async_{Guid.NewGuid()}.db"); _dbPath = Path.Combine(Path.GetTempPath(), $"test_async_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }); var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath });
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance); _localDb = TestLocalDb.Create(_dbPath);
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db);
} }
public void Dispose() public void Dispose()
{ {
_logger.Dispose(); _logger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath); _localDb.Dispose();
TestLocalDb.DeleteFiles(_dbPath);
} }
[Fact] [Fact]
@@ -91,11 +94,10 @@ public class SiteEventLoggerAsyncTests : IDisposable
// Health Monitoring can surface a logging outage. // Health Monitoring can surface a logging outage.
using var failingConnection = new SqliteConnection("Data Source=:memory:"); using var failingConnection = new SqliteConnection("Data Source=:memory:");
failingConnection.Open(); failingConnection.Open();
var options = Options.Create(new SiteEventLogOptions var failDbPath = Path.Combine(Path.GetTempPath(), $"test_fail_{Guid.NewGuid()}.db");
{ var options = Options.Create(new SiteEventLogOptions { DatabasePath = failDbPath });
DatabasePath = Path.Combine(Path.GetTempPath(), $"test_fail_{Guid.NewGuid()}.db") using var failLocalDb = TestLocalDb.Create(failDbPath);
}); var logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, failLocalDb.Db);
var logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
// Drop the table so every write fails with "no such table". // Drop the table so every write fails with "no such table".
logger.WithConnection(connection => logger.WithConnection(connection =>
@@ -112,5 +114,7 @@ public class SiteEventLoggerAsyncTests : IDisposable
"FailedWriteCount must increment when an event write fails."); "FailedWriteCount must increment when an event write fails.");
logger.Dispose(); logger.Dispose();
failLocalDb.Dispose();
TestLocalDb.DeleteFiles(failDbPath);
} }
} }
@@ -9,12 +9,14 @@ public class SiteEventLoggerTests : IDisposable
private readonly SiteEventLogger _logger; private readonly SiteEventLogger _logger;
private readonly SqliteConnection _verifyConnection; private readonly SqliteConnection _verifyConnection;
private readonly string _dbPath; private readonly string _dbPath;
private readonly TestLocalDb _localDb;
public SiteEventLoggerTests() public SiteEventLoggerTests()
{ {
_dbPath = Path.Combine(Path.GetTempPath(), $"test_events_{Guid.NewGuid()}.db"); _dbPath = Path.Combine(Path.GetTempPath(), $"test_events_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }); var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath });
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance); _localDb = TestLocalDb.Create(_dbPath);
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db);
// Separate connection for verification queries // Separate connection for verification queries
_verifyConnection = new SqliteConnection($"Data Source={_dbPath}"); _verifyConnection = new SqliteConnection($"Data Source={_dbPath}");
@@ -25,7 +27,8 @@ public class SiteEventLoggerTests : IDisposable
{ {
_verifyConnection.Dispose(); _verifyConnection.Dispose();
_logger.Dispose(); _logger.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath); _localDb.Dispose();
TestLocalDb.DeleteFiles(_dbPath);
} }
[Fact] [Fact]
@@ -0,0 +1,70 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// A real <see cref="ILocalDb"/> over a temp file, for tests that construct a
/// <see cref="SiteEventLogger"/> directly.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="SiteEventLogger"/> takes <see cref="ILocalDb"/> rather than a connection
/// string, and there is no in-memory mode — <c>LocalDbOptions.Path</c> is a filesystem
/// path. A real one is used rather than a stub on purpose: connections handed out by
/// <c>ILocalDb</c> carry the <c>zb_hlc_next()</c> UDF that <c>site_events</c>' capture
/// triggers call, so a stub returning a bare <c>SqliteConnection</c> would pass these
/// tests while failing closed the moment the table is registered for replication.
/// </para>
/// <para>
/// No <c>onReady</c> callback and no <c>RegisterReplicated</c>: the logger's own
/// <c>InitializeSchema</c> creates the table, which is what keeps a directly-constructed
/// logger self-sufficient. Registration is the host's job
/// (<c>SiteLocalDbSetup.OnReady</c>).
/// </para>
/// </remarks>
public sealed class TestLocalDb : IDisposable
{
private readonly ServiceProvider _provider;
private TestLocalDb(ServiceProvider provider, ILocalDb db)
{
_provider = provider;
Db = db;
}
/// <summary>The live local database.</summary>
public ILocalDb Db { get; }
/// <summary>Opens a local database at <paramref name="databasePath"/>.</summary>
public static TestLocalDb Create(string databasePath)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LocalDb:Path"] = databasePath,
})
.Build();
var provider = new ServiceCollection()
.AddZbLocalDb(configuration)
.BuildServiceProvider();
return new TestLocalDb(provider, provider.GetRequiredService<ILocalDb>());
}
/// <summary>
/// Deletes <paramref name="databasePath"/> and its WAL sidecars. Call only after the
/// owning <see cref="TestLocalDb"/> is disposed — the master connection anchors the WAL.
/// </summary>
public static void DeleteFiles(string databasePath)
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
{
try { File.Delete(databasePath + suffix); } catch { /* best effort */ }
}
}
public void Dispose() => _provider.Dispose();
}