feat(localdb): rewire OperationTrackingStore onto the consolidated site database
Task 4 of the LocalDb Phase 1 adoption plan. OperationTrackingStore took IOptions<OperationTrackingOptions> and opened its own SqliteConnection from ConnectionString. It now takes ILocalDb and gets every connection - writer and the two ad-hoc reader paths - from CreateConnection(). This is not cosmetic. OperationTracking is a RegisterReplicated table, so its capture triggers call zb_hlc_next(). That UDF is registered per connection by ILocalDb and by nothing else, so a raw SqliteConnection would fail closed on every write. Connections from CreateConnection() also arrive already open - calling Open()/OpenAsync() on one throws - hence the removed OpenAsync calls on the reader paths. OperationTrackingOptions.ConnectionString is now vestigial for this store; the database location is LocalDb:Path. The options class stays (retention settings) and the config key stays bound for the site config DB. InitializeSchema is kept but is now always a no-op in the host: onReady runs while ILocalDb is being constructed, strictly before this constructor can receive it. It remains so a directly-constructed store (tests, tooling) is self-sufficient. Tests: the store fixture moves off mode=memory&cache=shared onto a real ILocalDb over a temp file. There is no in-memory mode - LocalDbOptions.Path is a filesystem path - and testing through a raw in-memory connection would no longer resemble the host. Verifier connections stay raw on purpose: this fixture never registers the table, so no trigger fires and the UDF is never reached. Three DI fixtures needed LocalDb:Path added, because resolving IOperationTrackingStore now forces ILocalDb construction: SiteCompositionRootTests, SiteAuditWiringTests, and the AuditLog CombinedTelemetryHarness. Verified: build 0 warnings; Host 294/294, SiteRuntime 530/530, AuditLog 354/354. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
@@ -39,7 +39,7 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
|
||||
// _writeGate. Readers do NOT share this connection or gate; see GetStatusAsync.
|
||||
private readonly SqliteConnection _writeConnection;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private readonly string _connectionString;
|
||||
private readonly ILocalDb _localDb;
|
||||
private readonly ILogger<OperationTrackingStore> _logger;
|
||||
|
||||
// Dispose-once state shared by the sync Dispose and async
|
||||
@@ -51,29 +51,43 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
|
||||
private int _disposeState;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the tracking store, opens the SQLite connection, and applies the schema.
|
||||
/// Initializes the tracking store over the consolidated site database and applies the schema.
|
||||
/// </summary>
|
||||
/// <param name="options">Tracking store configuration (connection string, retention window).</param>
|
||||
/// <param name="localDb">
|
||||
/// The consolidated site database. Every connection it hands out is already open and carries
|
||||
/// the per-connection pragmas plus the <c>zb_hlc_next()</c> UDF that
|
||||
/// <c>OperationTracking</c>'s capture triggers call — which is exactly why the store no longer
|
||||
/// opens its own <see cref="SqliteConnection"/> from a connection string. A raw connection
|
||||
/// would lack the UDF and every write to the replicated table would fail closed.
|
||||
/// </param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
/// <remarks>
|
||||
/// <see cref="OperationTrackingOptions.ConnectionString"/> no longer feeds this store — the
|
||||
/// database location is <c>LocalDb:Path</c>. The option remains bound for the purge/retention
|
||||
/// settings that share the class.
|
||||
/// </remarks>
|
||||
public OperationTrackingStore(
|
||||
IOptions<OperationTrackingOptions> options,
|
||||
ILocalDb localDb,
|
||||
ILogger<OperationTrackingStore> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(localDb);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_logger = logger;
|
||||
_connectionString = options.Value.ConnectionString;
|
||||
_writeConnection = new SqliteConnection(_connectionString);
|
||||
_writeConnection.Open();
|
||||
_localDb = localDb;
|
||||
// Already open — CreateConnection returns a live, pragma-configured,
|
||||
// UDF-registered connection. Calling Open() on it again would throw.
|
||||
_writeConnection = localDb.CreateConnection();
|
||||
InitializeSchema();
|
||||
}
|
||||
|
||||
// Schema lives in OperationTrackingSchema so the Host's AddZbLocalDb onReady
|
||||
// callback can create this table in the consolidated site database before
|
||||
// RegisterReplicated installs its capture triggers. Applying it here too keeps a
|
||||
// directly-constructed store (tests, tooling) self-sufficient; the DDL is
|
||||
// idempotent, so running it from both places is harmless.
|
||||
// RegisterReplicated installs its capture triggers. In the host this call is
|
||||
// therefore always a no-op — onReady runs while ILocalDb is being constructed,
|
||||
// which is strictly before this constructor can receive it. It stays because it
|
||||
// keeps a directly-constructed store (tests, tooling) self-sufficient, and the
|
||||
// DDL is idempotent.
|
||||
private void InitializeSchema() => OperationTrackingSchema.Apply(_writeConnection);
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -225,14 +239,12 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
|
||||
|
||||
// Reads open a fresh, ungated SqliteConnection so a
|
||||
// long-running write doesn't block status queries. The connection
|
||||
// string is shared with the writer; SQLite handles cross-connection
|
||||
// isolation natively (a reader sees a consistent snapshot via the
|
||||
// shared cache lock for in-memory DBs, or a WAL snapshot for file DBs).
|
||||
// Mirrors the SiteStorageService precedent.
|
||||
await using var readConnection = new SqliteConnection(_connectionString);
|
||||
await readConnection.OpenAsync(ct).ConfigureAwait(false);
|
||||
// Reads open a fresh, ungated connection so a long-running write doesn't
|
||||
// block status queries. It comes from the same ILocalDb as the writer;
|
||||
// SQLite handles cross-connection isolation natively (readers see a WAL
|
||||
// snapshot). Mirrors the SiteStorageService precedent. Already open — do
|
||||
// not call OpenAsync on it.
|
||||
await using var readConnection = _localDb.CreateConnection();
|
||||
|
||||
await using var cmd = readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
@@ -312,8 +324,8 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
|
||||
// the standalone IX_OperationTracking_UpdatedAt index — UpdatedAtUtc is
|
||||
// the cursor. (The composite (Status, UpdatedAtUtc) index cannot satisfy a
|
||||
// status-less UpdatedAtUtc range scan; this dedicated index does.)
|
||||
await using var readConnection = new SqliteConnection(_connectionString);
|
||||
await readConnection.OpenAsync(ct).ConfigureAwait(false);
|
||||
// Already open — do not call OpenAsync on it.
|
||||
await using var readConnection = _localDb.CreateConnection();
|
||||
|
||||
await using var cmd = readConnection.CreateCommand();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user