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
This commit is contained in:
Joseph Doherty
2026-11-09 15:38:02 -05:00
parent 4e76eeb57f
commit 0961b3ed93
@@ -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;