From 0961b3ed9351fc79b7039dbe1842b20df97bf6a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 15:38:02 -0500 Subject: [PATCH] test(historian): #538 abandonment tests send Good+Bad as one burst (lmxopcua#746) The recorder now appends off its mailbox (9fda2919), so two separate Tells are two bursts the append loop may commit and drain one at a time. The #538 tests told Good.Tag and Bad.Tag separately and relied on both landing in one drain pass; when the loop drained Good.Tag alone, Bad.Tag was left alone at the head, every later pass failed with no sibling success (the outage path), and nothing was ever abandoned. Run in isolation the recorder test class failed 4 of 4 times (QueuedDepth 1, expected 0). The five tests now send the pair as one DependencyValueBatch, and the in-memory outbox appends a batch under one lock so a concurrent peek sees it all-or-nothing, as a group commit is. 6 of 6 isolated runs green. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- .../ContinuousHistorizationRecorderTests.cs | 54 ++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs index 7cccf27d..712db0dc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs @@ -434,8 +434,13 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit drainInterval: TimeSpan.FromMilliseconds(20), maxWriteAttempts: maxAttempts)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Bad.Tag", 2.0, DateTime.UtcNow)); + // ONE burst, so both entries share a drain pass (the #538 discriminator needs the sibling in the same pass): + // two separate Tells race the off-mailbox append loop, which may append and drain the first alone. + rec.Tell(new VirtualTagActor.DependencyValueBatch(new[] + { + new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow), + new VirtualTagActor.DependencyValueChanged("Bad.Tag", 2.0, DateTime.UtcNow), + })); // The head advances: both entries leave the outbox — the good one written, the bad one discarded. await AwaitAssertAsync(async () => @@ -519,8 +524,13 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit drainInterval: TimeSpan.FromMilliseconds(20), maxWriteAttempts: 50)); // a budget the test will not exhaust - rec.Tell(new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Flaky.Tag", 2.0, DateTime.UtcNow)); + // ONE burst, so both entries share a drain pass (the #538 discriminator needs the sibling in the same pass): + // two separate Tells race the off-mailbox append loop, which may append and drain the first alone. + rec.Tell(new VirtualTagActor.DependencyValueBatch(new[] + { + new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow), + new VirtualTagActor.DependencyValueChanged("Flaky.Tag", 2.0, DateTime.UtcNow), + })); // It has genuinely failed a few times before recovering — otherwise this proves nothing. await AwaitAssertAsync( @@ -589,8 +599,13 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit // this test is only meaningful when it is true. drainConcurrency: 1)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Bad.Tag", 2.0, DateTime.UtcNow)); + // ONE burst, so both entries share a drain pass (the #538 discriminator needs the sibling in the same pass): + // two separate Tells race the off-mailbox append loop, which may append and drain the first alone. + rec.Tell(new VirtualTagActor.DependencyValueBatch(new[] + { + new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow), + new VirtualTagActor.DependencyValueChanged("Bad.Tag", 2.0, DateTime.UtcNow), + })); // Presence waits, budgeted from the shared RuntimeActorTestBase constant rather than 10 s: this // one timed out on a loaded Gitea runner (#606). They return the instant the writer signals. @@ -659,8 +674,13 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit drainInterval: TimeSpan.FromMilliseconds(20), maxWriteAttempts: 2)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Bad.Tag", 2.0, DateTime.UtcNow)); + // ONE burst, so both entries share a drain pass (the #538 discriminator needs the sibling in the same pass): + // two separate Tells race the off-mailbox append loop, which may append and drain the first alone. + rec.Tell(new VirtualTagActor.DependencyValueBatch(new[] + { + new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow), + new VirtualTagActor.DependencyValueChanged("Bad.Tag", 2.0, DateTime.UtcNow), + })); await AwaitAssertAsync(async () => { @@ -696,8 +716,13 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit drainInterval: TimeSpan.FromMilliseconds(20), maxWriteAttempts: 2)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow)); - rec.Tell(new VirtualTagActor.DependencyValueChanged("Bad.Tag", 2.0, DateTime.UtcNow)); + // ONE burst, so both entries share a drain pass (the #538 discriminator needs the sibling in the same pass): + // two separate Tells race the off-mailbox append loop, which may append and drain the first alone. + rec.Tell(new VirtualTagActor.DependencyValueBatch(new[] + { + new VirtualTagActor.DependencyValueChanged("Good.Tag", 1.0, DateTime.UtcNow), + new VirtualTagActor.DependencyValueChanged("Bad.Tag", 2.0, DateTime.UtcNow), + })); await AwaitAssertAsync(async () => { @@ -1721,9 +1746,14 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit return AppendBatchYieldingAsync(entries); } - foreach (HistorizationOutboxEntry entry in entries) + // One lock over the whole batch (Monitor is re-entrant; AppendCore takes it too) so a concurrent drain peek + // sees the batch all-or-nothing, as it does a production group commit. + lock (_gate) { - AppendCore(entry); + foreach (HistorizationOutboxEntry entry in entries) + { + AppendCore(entry); + } } return ValueTask.CompletedTask;