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;