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:
+204
-62
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user