feat(localdb): alarm S&F on LocalDb with primary-gated drain (cutover)

Moves the alarm store-and-forward buffer out of its own alarm-historian.db
and into the node's consolidated LocalDb, where it replicates to the
redundant pair peer. A node that dies holding undelivered alarm history no
longer takes it to the grave.

Tasks 2 and 3 land together, as the plan anticipated. They are not separable:
the gate is a constructor argument of the rewritten sink, and a commit that
replicated the queue without gating the drain would be a commit in which both
nodes of a pair deliver every alarm event, continuously.

That drain gate is the load-bearing part of the change, and the recon
explains why it is new work rather than a refinement. Exactly-once delivery
across a pair is enforced today on the ENQUEUE side, by
HistorianAdapterActor: only the Primary enqueues, so the Secondary's queue is
empty and its ungated drain has nothing to send. Replicating the table
destroys that invariant.

The gate is a Func<bool> the caller supplies, because the drain runs on a
timer the sink owns rather than on a mailbox, and Core.AlarmHistorian cannot
reference PrimaryGatePolicy in Runtime. Runtime supplies it via a new
IRedundancyRoleView singleton that DriverHostActor publishes its Primary-gate
verdict to -- the same verdict the inbound-write and native-ack gates use, so
there is no second notion of am-I-the-Primary to drift.

Two failure modes are deliberately closed:

- The view is seeded OPEN, matching the policy's own answer for an unknown
  role with no driver peer. A deployment that runs no redundancy never
  publishes to it, and defaulting closed would silently stop its alarm
  history forever.

- A gate that throws is read as not-now, never as permission, and a closed
  gate reports the new HistorianDrainState.NotPrimary rather than Idle. A
  Secondary's rising queue is supposed to look different from a stalled
  drain, and if BOTH nodes report NotPrimary the pair is misconfigured and
  says so instead of quietly filling toward the capacity ceiling.

Row ids are a hash of the payload rather than fresh GUIDs. Both adapters
accept the same fanned transition in the window before the first redundancy
snapshot arrives, and under last-writer-wins an equal key converges those two
accepts into one row instead of duplicating them.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-21 04:13:00 -04:00
parent 8a9cb40a72
commit 71379816e7
14 changed files with 804 additions and 365 deletions
@@ -1,9 +1,12 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
@@ -14,22 +17,66 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
/// capacity eviction, and retention-based dead-letter purge.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public sealed class LocalDbStoreAndForwardSinkTests : IDisposable
{
private readonly string _dbPath;
private readonly ILogger _log;
private ServiceProvider? _provider;
private ILocalDb? _db;
/// <summary>Initializes a new test instance.</summary>
public SqliteStoreAndForwardSinkTests()
public LocalDbStoreAndForwardSinkTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-historian-{Guid.NewGuid():N}.sqlite");
_log = new LoggerConfiguration().MinimumLevel.Verbose().CreateLogger();
}
/// <summary>
/// A real temp-file local database with the buffer table created and registered, built on
/// first use.
/// </summary>
/// <remarks>
/// A real database rather than a stub. The library has no in-memory mode, and a bare
/// <see cref="SqliteConnection"/> would lack the <c>zb_hlc_next()</c> UDF the capture
/// triggers call — so every write to a registered table would fail closed. Registering here
/// also means these tests exercise the same capture path production replicates through.
/// </remarks>
private ILocalDb Db
{
get
{
if (_db is not null) return _db;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
.Build();
_provider = new ServiceCollection()
.AddZbLocalDb(configuration, db =>
{
using var connection = db.CreateConnection();
AlarmSfSchema.Apply(connection);
db.RegisterReplicated(AlarmSfSchema.EventsTable);
})
.BuildServiceProvider();
return _db = _provider.GetRequiredService<ILocalDb>();
}
}
/// <summary>Cleans up test resources.</summary>
public void Dispose()
{
try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } catch { }
_provider?.Dispose();
// Pooled connections keep a handle open past dispose; clearing the pools first is what
// makes the delete actually succeed.
SqliteConnection.ClearAllPools();
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
}
}
private sealed class FakeWriter : IAlarmHistorianWriter
@@ -81,7 +128,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task EnqueueThenDrain_Ack_removes_row()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
sink.GetStatus().QueueDepth.ShouldBe(1);
@@ -102,7 +149,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Drain_with_empty_queue_is_noop()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.DrainOnceAsync(CancellationToken.None);
@@ -116,7 +163,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
{
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
var before = sink.CurrentBackoff;
@@ -133,7 +180,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
{
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
@@ -153,7 +200,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
await sink.EnqueueAsync(Event("good"), CancellationToken.None);
@@ -169,7 +216,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Writer_exception_treated_as_retry_for_whole_batch()
{
var writer = new FakeWriter { ThrowOnce = new InvalidOperationException("pipe broken") };
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
@@ -189,8 +236,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Capacity_eviction_drops_oldest_nondeadlettered_row()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, batchSize: 100, capacity: 3);
using var sink = new LocalDbStoreAndForwardSink(
Db, writer, _log, batchSize: 100, capacity: 3);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
@@ -217,8 +264,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, deadLetterRetention: TimeSpan.FromDays(30),
using var sink = new LocalDbStoreAndForwardSink(
Db, writer, _log, deadLetterRetention: TimeSpan.FromDays(30),
clock: () => clock);
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
@@ -238,7 +285,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
{
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
@@ -257,7 +304,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Backoff_ladder_caps_at_60s()
{
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
@@ -278,8 +325,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task RetryPlease_dead_letters_row_after_MaxAttempts()
{
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, maxAttempts: 3);
using var sink = new LocalDbStoreAndForwardSink(
Db, writer, _log, maxAttempts: 3);
await sink.EnqueueAsync(Event("poison"), CancellationToken.None);
@@ -316,17 +363,89 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
await NullAlarmHistorianSink.Instance.EnqueueAsync(Event("A1"), CancellationToken.None);
}
/// <summary>
/// A closed drain gate must leave the queue completely alone — no writer call, no attempt
/// bump, no purge.
/// </summary>
/// <remarks>
/// This is the property that keeps alarm delivery exactly-once across a redundant pair.
/// The buffer replicates, so the Secondary holds a full copy of the Primary's queue; a
/// Secondary that drained would re-send every event, continuously.
/// </remarks>
[Fact]
public async Task Drain_does_nothing_while_the_gate_is_closed()
{
var writer = new FakeWriter();
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, drainGate: () => false);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
writer.Batches.Count.ShouldBe(0, "a gated-off node must not deliver");
var status = sink.GetStatus();
status.QueueDepth.ShouldBe(2, "the rows stay queued for whichever node holds the Primary role");
status.DrainState.ShouldBe(
HistorianDrainState.NotPrimary,
"a Secondary's rising queue must be distinguishable from a stalled drain");
}
/// <summary>
/// The positive control for the case above: the same queue, the same sink, drains as soon
/// as the gate opens. Without this, a sink that was simply broken would pass the
/// gated-off assertion.
/// </summary>
[Fact]
public async Task Drain_delivers_the_retained_queue_once_the_gate_opens()
{
var writer = new FakeWriter();
var isPrimary = false;
// ReSharper disable once AccessToModifiedClosure — flipping it mid-test is the point.
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, drainGate: () => isPrimary);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
writer.Batches.Count.ShouldBe(0);
isPrimary = true; // this node is promoted
await sink.DrainOnceAsync(CancellationToken.None);
writer.Batches.Count.ShouldBe(1);
writer.Batches[0].Select(e => e.AlarmId).ShouldBe(["A1"]);
sink.GetStatus().QueueDepth.ShouldBe(0);
}
/// <summary>
/// A gate that throws must be read as "not now", never as permission. Draining on a gate
/// fault would put a pair into dual-delivery precisely when its redundancy signal is sick.
/// </summary>
[Fact]
public async Task Drain_treats_a_throwing_gate_as_closed()
{
var writer = new FakeWriter();
using var sink = new LocalDbStoreAndForwardSink(
Db, writer, _log, drainGate: () => throw new InvalidOperationException("role unavailable"));
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
writer.Batches.Count.ShouldBe(0, "a faulted gate must fail closed");
sink.GetStatus().QueueDepth.ShouldBe(1);
}
/// <summary>Verifies that the constructor rejects invalid arguments.</summary>
[Fact]
public void Ctor_rejects_bad_args()
{
var w = new FakeWriter();
Should.Throw<ArgumentException>(() => new SqliteStoreAndForwardSink("", w, _log));
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, null!, _log));
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, w, null!));
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, batchSize: 0));
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, capacity: 0));
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, maxAttempts: 0));
Should.Throw<ArgumentNullException>(() => new LocalDbStoreAndForwardSink(null!, w, _log));
Should.Throw<ArgumentNullException>(() => new LocalDbStoreAndForwardSink(Db, null!, _log));
Should.Throw<ArgumentNullException>(() => new LocalDbStoreAndForwardSink(Db, w, null!));
Should.Throw<ArgumentOutOfRangeException>(() => new LocalDbStoreAndForwardSink(Db, w, _log, batchSize: 0));
Should.Throw<ArgumentOutOfRangeException>(() => new LocalDbStoreAndForwardSink(Db, w, _log, capacity: 0));
Should.Throw<ArgumentOutOfRangeException>(() => new LocalDbStoreAndForwardSink(Db, w, _log, maxAttempts: 0));
}
/// <summary>Verifies that a disposed sink rejects enqueue operations.</summary>
@@ -334,7 +453,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Disposed_sink_rejects_enqueue()
{
var writer = new FakeWriter();
var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
sink.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(
@@ -352,11 +471,14 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Drain_with_corrupt_payload_row_deadletters_it_and_keeps_good_rows_aligned()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
var clock = new TickingClock();
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, clock: clock.Next);
// Row 1: good. Row 2: corrupt JSON (inserted directly). Row 3: good.
// Row 1: good. Row 2: corrupt JSON (inserted directly). Row 3: good. The shared ticking
// clock is what pins the corrupt row BETWEEN the good ones — which is the whole point of
// this case, as distinct from the corrupt-at-the-head case below.
await sink.EnqueueAsync(Event("good-1"), CancellationToken.None);
InsertCorruptRow("corrupt");
InsertCorruptRow("corrupt", clock.Next());
await sink.EnqueueAsync(Event("good-2"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
@@ -383,9 +505,10 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Drain_with_corrupt_head_row_does_not_stall_queue()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
var clock = new TickingClock();
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, clock: clock.Next);
InsertCorruptRow("corrupt-head");
InsertCorruptRow("corrupt-head", clock.Next());
await sink.EnqueueAsync(Event("good-1"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
@@ -409,7 +532,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task StartDrainLoop_honors_backoff_and_slows_cadence_under_retry()
{
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
@@ -436,7 +559,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task StartDrainLoop_keeps_steady_cadence_when_writer_is_healthy()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
sink.StartDrainLoop(TimeSpan.FromMilliseconds(30));
@@ -465,7 +588,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
// exception... so to exercise the *callback* catch we instead make the
// fault originate from the writer itself but assert the loop self-heals.
var writer = new ThrowingThenHealingWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
sink.StartDrainLoop(TimeSpan.FromMilliseconds(30));
@@ -490,7 +613,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Concurrent_enqueue_and_drain_do_not_throw_sqlite_busy()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
var faults = new List<Exception>();
var enqueuers = Enumerable.Range(0, 4).Select(t => Task.Run(async () =>
@@ -561,7 +684,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Writer_returning_wrong_cardinality_outcomes_sets_backing_off_and_keeps_rows()
{
var writer = new WrongCardinalityWriter(returnExtraOutcome: true);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
@@ -591,8 +714,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Capacity_eviction_increments_evicted_count_on_status()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, batchSize: 100, capacity: 3);
using var sink = new LocalDbStoreAndForwardSink(
Db, writer, _log, batchSize: 100, capacity: 3);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
@@ -616,7 +739,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task GetStatus_snapshot_is_consistent_under_concurrent_drain()
{
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
// Fill the queue.
for (var i = 0; i < 10; i++)
@@ -690,8 +813,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task EnqueueAsync_does_not_count_all_rows_on_every_call_below_capacity()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, batchSize: 100, capacity: 10_000);
using var sink = new LocalDbStoreAndForwardSink(
Db, writer, _log, batchSize: 100, capacity: 10_000);
for (var i = 0; i < 200; i++)
await sink.EnqueueAsync(Event($"E{i}"), CancellationToken.None);
@@ -705,7 +828,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// <summary>
/// Regression for Core.AlarmHistorian-008: across every queue mutation (enqueue,
/// Ack drain, PermanentFail drain, capacity eviction, RetryDeadLettered) the
/// queue depth reported by <see cref="SqliteStoreAndForwardSink.GetStatus"/> must
/// queue depth reported by <see cref="LocalDbStoreAndForwardSink.GetStatus"/> must
/// stay aligned with a fresh <c>COUNT(*)</c> against the database. Catches drift
/// bugs in the in-memory counter introduced by the perf optimisation.
/// </summary>
@@ -713,8 +836,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Enqueue_and_drain_keep_queue_depth_consistent_with_storage()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, batchSize: 5, capacity: 8);
using var sink = new LocalDbStoreAndForwardSink(
Db, writer, _log, batchSize: 5, capacity: 8);
// Burst-enqueue below capacity — the in-memory counter must stay aligned with the
// SELECT COUNT(*) that GetStatus runs.
@@ -760,7 +883,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Counter_remains_consistent_under_concurrent_enqueue_and_drain()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
var enqueuers = Enumerable.Range(0, 3).Select(t => Task.Run(async () =>
{
@@ -790,7 +913,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// Regression for Core.AlarmHistorian-012: when WriteBatchAsync throws
/// <see cref="OperationCanceledException"/> the drain exits via re-throw
/// without resetting <c>_drainState</c>, leaving the status surface permanently
/// showing <c>Draining</c>. The next call to <see cref="SqliteStoreAndForwardSink.GetStatus"/>
/// showing <c>Draining</c>. The next call to <see cref="LocalDbStoreAndForwardSink.GetStatus"/>
/// must reflect <c>Idle</c> or <c>BackingOff</c>, not the stale <c>Draining</c>
/// state that was set at the top of the drain tick.
/// </summary>
@@ -798,7 +921,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
public async Task Drain_cancelled_mid_write_resets_drain_state_to_not_draining()
{
var writer = new CancellableWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
using var cts = new CancellationTokenSource();
@@ -840,49 +963,68 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// COUNT(*) read directly from storage — proves the in-memory counter has not
/// drifted from the persisted truth.
/// </summary>
private void AssertQueueDepthMatchesStorage(SqliteStoreAndForwardSink sink)
private void AssertQueueDepthMatchesStorage(LocalDbStoreAndForwardSink sink)
{
using var conn = new SqliteConnection($"Data Source={_dbPath}");
conn.Open();
using var conn = Db.CreateConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 0";
var live = (long)(cmd.ExecuteScalar() ?? 0L);
sink.GetStatus().QueueDepth.ShouldBe(live, "GetStatus must agree with a fresh COUNT(*)");
}
/// <summary>
/// Regression for Core.AlarmHistorian-014: <see cref="SqliteStoreAndForwardSink.GetStatus"/>
/// and <see cref="SqliteStoreAndForwardSink.RetryDeadLettered"/> must throw
/// <see cref="ObjectDisposedException"/> after <see cref="SqliteStoreAndForwardSink.Dispose"/>
/// Regression for Core.AlarmHistorian-014: <see cref="LocalDbStoreAndForwardSink.GetStatus"/>
/// and <see cref="LocalDbStoreAndForwardSink.RetryDeadLettered"/> must throw
/// <see cref="ObjectDisposedException"/> after <see cref="LocalDbStoreAndForwardSink.Dispose"/>
/// is called, consistent with the guards already present on
/// <see cref="SqliteStoreAndForwardSink.EnqueueAsync"/> and
/// <see cref="SqliteStoreAndForwardSink.StartDrainLoop"/>.
/// <see cref="LocalDbStoreAndForwardSink.EnqueueAsync"/> and
/// <see cref="LocalDbStoreAndForwardSink.StartDrainLoop"/>.
/// </summary>
[Fact]
public void Disposed_sink_throws_ObjectDisposedException_on_GetStatus_and_RetryDeadLettered()
{
var writer = new FakeWriter();
var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
sink.Dispose();
Should.Throw<ObjectDisposedException>(() => sink.GetStatus());
Should.Throw<ObjectDisposedException>(() => sink.RetryDeadLettered());
}
/// <summary>Insert a queue row whose PayloadJson cannot deserialize into an AlarmHistorianEvent.</summary>
private void InsertCorruptRow(string alarmId)
/// <summary>
/// Insert a queue row whose payload cannot deserialize into an AlarmHistorianEvent, stamped
/// at an explicit enqueue time.
/// </summary>
/// <remarks>
/// The timestamp is a parameter rather than <c>DateTime.UtcNow</c> because the drain reads
/// in <c>enqueued_at_utc</c> order, and the tests that use this helper care precisely about
/// where the corrupt row sits in that order. Three unsynchronised clock reads can land on
/// one tick, which would make the interleaving — and therefore the test — a coin flip.
/// </remarks>
private void InsertCorruptRow(string alarmId, DateTime enqueuedAt)
{
using var conn = new SqliteConnection($"Data Source={_dbPath}");
conn.Open();
using var conn = Db.CreateConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Queue (AlarmId, EnqueuedUtc, PayloadJson, AttemptCount)
VALUES ($alarmId, $enqueued, $payload, 0);
INSERT INTO alarm_sf_events (id, alarm_id, enqueued_at_utc, payload_json, attempt_count)
VALUES ($id, $alarmId, $enqueued, $payload, 0);
""";
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("N"));
cmd.Parameters.AddWithValue("$alarmId", alarmId);
cmd.Parameters.AddWithValue("$enqueued", DateTime.UtcNow.ToString("O"));
cmd.Parameters.AddWithValue("$enqueued", enqueuedAt.ToString("O"));
// JSON literal "null" round-trips through Deserialize<T> as a null reference.
cmd.Parameters.AddWithValue("$payload", "null");
cmd.ExecuteNonQuery();
}
/// <summary>
/// Monotonic test clock — every read advances one second, so enqueue order is total and
/// the drain's <c>ORDER BY enqueued_at_utc</c> is deterministic.
/// </summary>
private sealed class TickingClock
{
private DateTime _now = new(2026, 7, 21, 0, 0, 0, DateTimeKind.Utc);
public DateTime Next() => _now = _now.AddSeconds(1);
}
}
@@ -17,6 +17,14 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!--
The sink is built over ILocalDb, whose only construction path is the DI extension — so
these tests need the DI + configuration hosts to stand up a real temp-file database.
The library has no in-memory mode, and a bare SqliteConnection would lack the
zb_hlc_next() UDF the capture triggers call, failing closed on registered tables.
-->
<PackageReference Include="Microsoft.Extensions.DependencyInjection"/>
<PackageReference Include="Microsoft.Extensions.Configuration"/>
</ItemGroup>
<ItemGroup>