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:
Joseph Doherty
2026-07-19 08:59:38 -04:00
parent 2bff527247
commit 0b5e9b44f6
6 changed files with 198 additions and 74 deletions
@@ -70,7 +70,11 @@ public static class ServiceCollectionExtensions
// reconciliation, Tracking.Status audit-only). Central roots never call // reconciliation, Tracking.Status audit-only). Central roots never call
// AddSiteRuntime, so this stays absent on central — matching the SITE-ONLY // AddSiteRuntime, so this stays absent on central — matching the SITE-ONLY
// contract. OperationTrackingOptions is bound + validated by the Host's // contract. OperationTrackingOptions is bound + validated by the Host's
// SiteServiceRegistration site-options block. // SiteServiceRegistration site-options block — but it no longer supplies the
// store's database: the store takes ILocalDb (the consolidated site database
// at LocalDb:Path), so OperationTrackingOptions.ConnectionString is now only
// read by the site config DB. Resolving this store therefore forces ILocalDb
// construction, which is what runs SiteLocalDbSetup.OnReady.
services.AddSingleton<Commons.Interfaces.IOperationTrackingStore, Tracking.OperationTrackingStore>(); services.AddSingleton<Commons.Interfaces.IOperationTrackingStore, Tracking.OperationTrackingStore>();
// Site-local repository implementations backed by SQLite // Site-local repository implementations backed by SQLite
@@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging; 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.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Types; 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. // _writeGate. Readers do NOT share this connection or gate; see GetStatusAsync.
private readonly SqliteConnection _writeConnection; private readonly SqliteConnection _writeConnection;
private readonly SemaphoreSlim _writeGate = new(1, 1); private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly string _connectionString; private readonly ILocalDb _localDb;
private readonly ILogger<OperationTrackingStore> _logger; private readonly ILogger<OperationTrackingStore> _logger;
// Dispose-once state shared by the sync Dispose and async // Dispose-once state shared by the sync Dispose and async
@@ -51,29 +51,43 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
private int _disposeState; private int _disposeState;
/// <summary> /// <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> /// </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> /// <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( public OperationTrackingStore(
IOptions<OperationTrackingOptions> options, ILocalDb localDb,
ILogger<OperationTrackingStore> logger) ILogger<OperationTrackingStore> logger)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(localDb);
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
_logger = logger; _logger = logger;
_connectionString = options.Value.ConnectionString; _localDb = localDb;
_writeConnection = new SqliteConnection(_connectionString); // Already open — CreateConnection returns a live, pragma-configured,
_writeConnection.Open(); // UDF-registered connection. Calling Open() on it again would throw.
_writeConnection = localDb.CreateConnection();
InitializeSchema(); InitializeSchema();
} }
// Schema lives in OperationTrackingSchema so the Host's AddZbLocalDb onReady // Schema lives in OperationTrackingSchema so the Host's AddZbLocalDb onReady
// callback can create this table in the consolidated site database before // callback can create this table in the consolidated site database before
// RegisterReplicated installs its capture triggers. Applying it here too keeps a // RegisterReplicated installs its capture triggers. In the host this call is
// directly-constructed store (tests, tooling) self-sufficient; the DDL is // therefore always a no-op — onReady runs while ILocalDb is being constructed,
// idempotent, so running it from both places is harmless. // 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); private void InitializeSchema() => OperationTrackingSchema.Apply(_writeConnection);
/// <inheritdoc/> /// <inheritdoc/>
@@ -225,14 +239,12 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
{ {
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this); ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
// Reads open a fresh, ungated SqliteConnection so a // Reads open a fresh, ungated connection so a long-running write doesn't
// long-running write doesn't block status queries. The connection // block status queries. It comes from the same ILocalDb as the writer;
// string is shared with the writer; SQLite handles cross-connection // SQLite handles cross-connection isolation natively (readers see a WAL
// isolation natively (a reader sees a consistent snapshot via the // snapshot). Mirrors the SiteStorageService precedent. Already open — do
// shared cache lock for in-memory DBs, or a WAL snapshot for file DBs). // not call OpenAsync on it.
// Mirrors the SiteStorageService precedent. await using var readConnection = _localDb.CreateConnection();
await using var readConnection = new SqliteConnection(_connectionString);
await readConnection.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = readConnection.CreateCommand(); await using var cmd = readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
@@ -312,8 +324,8 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
// the standalone IX_OperationTracking_UpdatedAt index — UpdatedAtUtc is // the standalone IX_OperationTracking_UpdatedAt index — UpdatedAtUtc is
// the cursor. (The composite (Status, UpdatedAtUtc) index cannot satisfy a // the cursor. (The composite (Status, UpdatedAtUtc) index cannot satisfy a
// status-less UpdatedAtUtc range scan; this dedicated index does.) // status-less UpdatedAtUtc range scan; this dedicated index does.)
await using var readConnection = new SqliteConnection(_connectionString); // Already open — do not call OpenAsync on it.
await readConnection.OpenAsync(ct).ConfigureAwait(false); await using var readConnection = _localDb.CreateConnection();
await using var cmd = readConnection.CreateCommand(); await using var cmd = readConnection.CreateCommand();
@@ -1,8 +1,10 @@
using Akka.Actor; using Akka.Actor;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -58,6 +60,8 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
public IServiceProvider ServiceProvider { get; } public IServiceProvider ServiceProvider { get; }
private readonly MsSqlMigrationFixture _fixture; private readonly MsSqlMigrationFixture _fixture;
private readonly string _trackingDbPath;
private readonly ServiceProvider _trackingLocalDbProvider;
private bool _disposed; private bool _disposed;
public CombinedTelemetryHarness( public CombinedTelemetryHarness(
@@ -88,13 +92,23 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
SqliteWriter, Ring, new NoOpAuditWriteFailureCounter(), SqliteWriter, Ring, new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance); NullLogger<FallbackAuditWriter>.Instance);
TrackingStore = new OperationTrackingStore( // The tracking store now runs on the consolidated ILocalDb rather than its own
Options.Create(new OperationTrackingOptions // connection string, so it needs a real database FILE — LocalDbOptions.Path is
// a filesystem path and there is no in-memory mode. Unique per harness, torn
// down in DisposeAsync alongside its WAL sidecars.
_trackingDbPath = Path.Combine(
Path.GetTempPath(), $"tracking-g-{Guid.NewGuid():N}.db");
_trackingLocalDbProvider = new ServiceCollection()
.AddZbLocalDb(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{ {
// Same shared-in-memory pattern as the audit writer. ["LocalDb:Path"] = _trackingDbPath,
ConnectionString = })
$"Data Source=file:tracking-g-{Guid.NewGuid():N}?mode=memory&cache=shared", .Build())
}), .BuildServiceProvider();
TrackingStore = new OperationTrackingStore(
_trackingLocalDbProvider.GetRequiredService<ILocalDb>(),
NullLogger<OperationTrackingStore>.Instance); NullLogger<OperationTrackingStore>.Instance);
// Central wiring: real repositories backed by the MSSQL fixture's DB. // Central wiring: real repositories backed by the MSSQL fixture's DB.
@@ -165,6 +179,15 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
_disposed = true; _disposed = true;
await SqliteWriter.DisposeAsync().ConfigureAwait(false); await SqliteWriter.DisposeAsync().ConfigureAwait(false);
await TrackingStore.DisposeAsync().ConfigureAwait(false); await TrackingStore.DisposeAsync().ConfigureAwait(false);
// Dispose the ILocalDb before deleting the file — it holds an open master
// connection anchoring the WAL.
await _trackingLocalDbProvider.DisposeAsync().ConfigureAwait(false);
foreach (var suffix in new[] { "", "-wal", "-shm" })
{
try { File.Delete(_trackingDbPath + suffix); } catch { /* best effort */ }
}
if (ServiceProvider is IAsyncDisposable asyncSp) if (ServiceProvider is IAsyncDisposable asyncSp)
{ {
await asyncSp.DisposeAsync().ConfigureAwait(false); await asyncSp.DisposeAsync().ConfigureAwait(false);
@@ -290,10 +290,12 @@ public class SiteAuditWiringTests : IDisposable
{ {
private readonly WebApplication _host; private readonly WebApplication _host;
private readonly string _tempDbPath; private readonly string _tempDbPath;
private readonly string _tempLocalDbPath;
public SiteAuditWiringTests() public SiteAuditWiringTests()
{ {
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_audit_wiring_{Guid.NewGuid()}.db"); _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_audit_wiring_{Guid.NewGuid()}.db");
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_audit_wiring_localdb_{Guid.NewGuid()}.db");
var builder = WebApplication.CreateBuilder(); var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear(); builder.Configuration.Sources.Clear();
@@ -312,6 +314,11 @@ public class SiteAuditWiringTests : IDisposable
// resolved; point it at an in-memory connection so the test doesn't // resolved; point it at an in-memory connection so the test doesn't
// pollute the working directory. // pollute the working directory.
["AuditLog:SiteWriter:DatabasePath"] = ":memory:", ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
// The cached-call telemetry forwarder pulls in IOperationTrackingStore,
// which now resolves ILocalDb — so the consolidated site database must be
// configured or the whole graph fails to resolve. There is no in-memory
// mode: LocalDbOptions.Path is a filesystem path.
["LocalDb:Path"] = _tempLocalDbPath,
}); });
builder.Services.AddGrpc(); builder.Services.AddGrpc();
@@ -326,6 +333,7 @@ public class SiteAuditWiringTests : IDisposable
{ {
(_host as IDisposable)?.Dispose(); (_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ } try { File.Delete(_tempDbPath); } catch { /* best effort */ }
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
} }
[Fact] [Fact]
@@ -383,11 +383,13 @@ public class SiteCompositionRootTests : IDisposable
private readonly WebApplication _host; private readonly WebApplication _host;
private readonly string _tempDbPath; private readonly string _tempDbPath;
private readonly string _tempTrackingDbPath; private readonly string _tempTrackingDbPath;
private readonly string _tempLocalDbPath;
public SiteCompositionRootTests() public SiteCompositionRootTests()
{ {
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db"); _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{Guid.NewGuid()}.db"); _tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{Guid.NewGuid()}.db");
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{Guid.NewGuid()}.db");
var builder = WebApplication.CreateBuilder(); var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear(); builder.Configuration.Sources.Clear();
@@ -400,10 +402,13 @@ public class SiteCompositionRootTests : IDisposable
["ScadaBridge:Node:RemotingPort"] = "0", ["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Node:GrpcPort"] = "0", ["ScadaBridge:Node:GrpcPort"] = "0",
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath, ["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
// Point the site-local operation-tracking SQLite store (the ctor opens/creates // Retained for the site config DB; it no longer points the tracking store
// the file eagerly) at a temp path so resolving IOperationTrackingStore in the // anywhere — that store now opens the consolidated database below.
// composition-root test does not litter site-tracking.db in the test cwd.
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}", ["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
// The consolidated site database. IOperationTrackingStore resolves ILocalDb
// and creates the file eagerly in its ctor, so this must be a temp path or
// the composition-root test litters the working directory.
["LocalDb:Path"] = _tempLocalDbPath,
// ClusterOptions requires at least one seed node (ClusterOptionsValidator). // ClusterOptions requires at least one seed node (ClusterOptionsValidator).
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551", ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552", ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
@@ -427,6 +432,7 @@ public class SiteCompositionRootTests : IDisposable
(_host as IDisposable)?.Dispose(); (_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ } try { File.Delete(_tempDbPath); } catch { /* best effort */ }
try { File.Delete(_tempTrackingDbPath); } catch { /* best effort */ } try { File.Delete(_tempTrackingDbPath); } catch { /* best effort */ }
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
} }
[Fact] [Fact]
@@ -1,6 +1,8 @@
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
@@ -9,30 +11,93 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking;
/// <summary> /// <summary>
/// Audit Log #23 (M3 Bundle A — Task A2) — schema + behaviour tests for the /// Audit Log #23 (M3 Bundle A — Task A2) — schema + behaviour tests for the
/// site-local <see cref="OperationTrackingStore"/>. Each test uses a unique /// site-local <see cref="OperationTrackingStore"/>.
/// shared-cache in-memory SQLite database so the store and the verifier share
/// the same store without touching disk.
/// </summary> /// </summary>
public class OperationTrackingStoreTests /// <remarks>
/// The store now takes <see cref="ILocalDb"/> rather than a connection string, so each
/// test gets a real <c>ILocalDb</c> over a unique temp FILE. The previous
/// <c>mode=memory&amp;cache=shared</c> fixture is gone and cannot come back:
/// <c>LocalDbOptions.Path</c> is a filesystem path, and — more importantly — a raw
/// connection to a shared-cache in-memory database would not carry the
/// <c>zb_hlc_next()</c> UDF the capture triggers call, so testing through one would no
/// longer resemble how the store runs in the host.
/// <para>
/// Verifier connections are plain <see cref="SqliteConnection"/>s over the same file.
/// That is deliberate and safe: they only read, or write columns on an unregistered
/// table (this fixture never calls <c>RegisterReplicated</c>), so no trigger fires and
/// the missing UDF is never reached.
/// </para>
/// </remarks>
public class OperationTrackingStoreTests : IDisposable
{ {
private static (OperationTrackingStore store, string dataSource) CreateStore( // Every ILocalDb and temp file created by this fixture, torn down together. The
string testName) // ILocalDb holds an open master connection anchoring the WAL, so it must be
// disposed before the file can be deleted.
private readonly List<ServiceProvider> _providers = [];
private readonly List<string> _tempFiles = [];
public void Dispose()
{ {
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared"; foreach (var provider in _providers)
var connectionString = $"Data Source={dataSource};Cache=Shared";
var options = new OperationTrackingOptions
{ {
ConnectionString = connectionString, try { provider.Dispose(); } catch { /* best effort */ }
};
var store = new OperationTrackingStore(
Options.Create(options),
NullLogger<OperationTrackingStore>.Instance);
return (store, dataSource);
} }
private static SqliteConnection OpenVerifierConnection(string dataSource) foreach (var path in _tempFiles)
{ {
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); foreach (var suffix in new[] { "", "-wal", "-shm" })
{
try { File.Delete(path + suffix); } catch { /* best effort */ }
}
}
GC.SuppressFinalize(this);
}
/// <summary>Allocates a unique temp database path, registered for cleanup.</summary>
private string NewDatabasePath(string testName)
{
var path = Path.Combine(
Path.GetTempPath(), $"optracking-{testName}-{Guid.NewGuid():N}.db");
_tempFiles.Add(path);
return path;
}
/// <summary>
/// Builds a real <see cref="ILocalDb"/> over <paramref name="databasePath"/>. No
/// <c>onReady</c> callback and no <c>RegisterReplicated</c> — the store's own
/// <c>InitializeSchema</c> creates the table, which is what keeps a
/// directly-constructed store self-sufficient.
/// </summary>
private ILocalDb CreateLocalDb(string databasePath)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LocalDb:Path"] = databasePath,
})
.Build();
var provider = new ServiceCollection()
.AddZbLocalDb(configuration)
.BuildServiceProvider();
_providers.Add(provider);
return provider.GetRequiredService<ILocalDb>();
}
private (OperationTrackingStore store, string dataSource) CreateStore(string testName)
{
var databasePath = NewDatabasePath(testName);
var store = new OperationTrackingStore(
CreateLocalDb(databasePath),
NullLogger<OperationTrackingStore>.Instance);
return (store, databasePath);
}
private static SqliteConnection OpenVerifierConnection(string databasePath)
{
var connection = new SqliteConnection($"Data Source={databasePath}");
connection.Open(); connection.Open();
return connection; return connection;
} }
@@ -111,14 +176,19 @@ public class OperationTrackingStoreTests
ON OperationTracking (Status, UpdatedAtUtc); ON OperationTracking (Status, UpdatedAtUtc);
"""; """;
private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource) /// <summary>
/// Writes the pre-SourceNode schema into <paramref name="databasePath"/> and closes
/// the connection again. Unlike the old shared-cache in-memory fixture — where the
/// seeding connection had to stay open or the database evaporated — a file database
/// persists on its own, so nothing is held across the store's construction.
/// </summary>
private static void SeedPreSourceNodeSchemaDatabase(string databasePath)
{ {
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); using var connection = new SqliteConnection($"Data Source={databasePath}");
connection.Open(); connection.Open();
using var cmd = connection.CreateCommand(); using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreSourceNodeSchema; cmd.CommandText = OldPreSourceNodeSchema;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
return connection;
} }
private static bool ColumnExists(SqliteConnection connection, string columnName) private static bool ColumnExists(SqliteConnection connection, string columnName)
@@ -129,36 +199,36 @@ public class OperationTrackingStoreTests
return Convert.ToInt32(cmd.ExecuteScalar()) > 0; return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
} }
private static OperationTrackingStore CreateStoreOver(string dataSource) private OperationTrackingStore CreateStoreOver(string databasePath)
{ => new(CreateLocalDb(databasePath), NullLogger<OperationTrackingStore>.Instance);
var connectionString = $"Data Source={dataSource};Cache=Shared";
var options = new OperationTrackingOptions { ConnectionString = connectionString };
return new OperationTrackingStore(
Options.Create(options),
NullLogger<OperationTrackingStore>.Instance);
}
[Fact] [Fact]
public async Task Initialize_adds_SourceNode_to_pre_existing_schema() public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
{ {
var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared"; var databasePath = NewDatabasePath(nameof(Initialize_adds_SourceNode_to_pre_existing_schema));
// A pre-SourceNode deployment: tracking.db already exists with the // A pre-SourceNode deployment: the tracking database already exists with
// 12-column schema and NO SourceNode column. // the 12-column schema and NO SourceNode column.
using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource); SeedPreSourceNodeSchemaDatabase(databasePath);
Assert.True(ColumnExists(seedConnection, "SourceInstanceId")); using (var seeded = OpenVerifierConnection(databasePath))
Assert.True(ColumnExists(seedConnection, "SourceScript")); {
Assert.False(ColumnExists(seedConnection, "SourceNode")); Assert.True(ColumnExists(seeded, "SourceInstanceId"));
Assert.True(ColumnExists(seeded, "SourceScript"));
Assert.False(ColumnExists(seeded, "SourceNode"));
}
// Upgrade: a post-branch OperationTrackingStore opens the same database. // Upgrade: a post-branch OperationTrackingStore opens the same database.
// Its InitializeSchema must ALTER the missing SourceNode column in — // Its InitializeSchema must ALTER the missing SourceNode column in —
// the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing // the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing
// table. // table.
await using (var store = CreateStoreOver(dataSource)) await using (var store = CreateStoreOver(databasePath))
{
using (var verifier = OpenVerifierConnection(databasePath))
{ {
Assert.True( Assert.True(
ColumnExists(seedConnection, "SourceNode"), ColumnExists(verifier, "SourceNode"),
"OperationTrackingStore must ALTER the SourceNode column into a pre-existing OperationTracking table."); "OperationTrackingStore must ALTER the SourceNode column into a pre-existing OperationTracking table.");
}
// A RecordEnqueueAsync binding $sourceNode must now succeed; without // A RecordEnqueueAsync binding $sourceNode must now succeed; without
// the ALTER it would fail with "no such column: SourceNode". // the ALTER it would fail with "no such column: SourceNode".
@@ -178,9 +248,10 @@ public class OperationTrackingStoreTests
// Idempotency: a second store over the now-upgraded DB must not error // Idempotency: a second store over the now-upgraded DB must not error
// (the probe sees SourceNode already present and skips the ALTER). // (the probe sees SourceNode already present and skips the ALTER).
await using (var storeAgain = CreateStoreOver(dataSource)) await using (var storeAgain = CreateStoreOver(databasePath))
{ {
Assert.True(ColumnExists(seedConnection, "SourceNode")); using var verifier = OpenVerifierConnection(databasePath);
Assert.True(ColumnExists(verifier, "SourceNode"));
} }
} }