Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs
T
Joseph Doherty 3fdf24659f fix(mqtt): AcceptBirth is NBIRTH-only -- a DBIRTH routed there wipes bdSeq
Self-review of the commit before this one caught a defect in the contract it
published rather than in the code it ran. AcceptBirth's doc said it records
"a birth (NBIRTH/DBIRTH)". That is wrong on both halves of this type, and the
wrong version plants exactly the defect the bdSeq tie exists to prevent.

Only the NBIRTH restarts the Sparkplug sequence; a DBIRTH carries the next
number in the edge node's ongoing stream, so it belongs to Accept. And only
the NBIRTH and NDEATH carry bdSeq at all -- a DBIRTH has none to pass. So a
Task 21 that followed the old doc and routed a DBIRTH to AcceptBirth would do
two wrong things at once: rebase the sequence mid-stream, and wipe the node's
session token to null. A stale Last Will arriving afterwards would then find
nothing to compare against, fall through the deliberate fail-toward-stale
rule in IsDeathForCurrentSession, and kill the live node.

Renamed AcceptBirth -> AcceptNodeBirth so the DBIRTH call site reads wrong at
a glance rather than only in prose, documented the hazard on both methods,
and added DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone
to pin the shape at this type's own boundary -- the ingest state machine now
has something to fail against if it wires the call sites the other way round.

Falsifiability: mutating Accept to clear _birthBdSeq reddens exactly the new
test (1 RED), reverted. 29 tests, 438/438 for the MQTT suite.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:32:44 -04:00

475 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="SequenceTracker"/> — design doc §3.6 invariant #3. Two halves, each with a
/// distinct failure mode the tests below pin from both directions:
/// <list type="bullet">
/// <item>
/// <b><c>seq</c> gap detection across the 0255 wrap.</b> Reading 255→0 as a gap
/// rebirth-storms every edge node once per 256 messages; missing a real gap serves stale
/// data at Good quality forever. Both directions are asserted.
/// </item>
/// <item>
/// <b><c>bdSeq</c> birth↔death tie.</b> A late Last-Will NDEATH from a previous session
/// arriving after the new NBIRTH must not kill the freshly-born node.
/// </item>
/// </list>
/// </summary>
public sealed class SequenceTrackerTests
{
// ---------------------------------------------------------------------------------------
// seq — the wrap boundary
// ---------------------------------------------------------------------------------------
/// <summary>
/// The plan's headline case: 255 → 0 is the sequence continuing, not a gap. A tracker that
/// reads the wrap as a gap requests a rebirth every 256 messages, and a busy edge node then
/// spends its life republishing births instead of data.
/// </summary>
[Fact]
public void Sequence_WrapAt255IsContiguous_NotAGap()
{
var s = new SequenceTracker();
s.Accept(254).ShouldBeTrue();
s.Accept(255).ShouldBeTrue();
s.Accept(0).ShouldBeTrue(); // wrap
}
/// <summary>
/// The plan's mirror case: a skipped value is a genuine gap. Missing it means the driver
/// silently keeps serving values it knows are behind the edge node's real state.
/// </summary>
[Fact]
public void Sequence_SkippedValue_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
}
/// <summary>
/// Two-and-a-bit full cycles of the 0255 range report no gap at any point. The pair of
/// single-step tests above can both pass on an off-by-one wrap that only misfires at one
/// specific value; walking every value in the range leaves nowhere for that to hide.
/// </summary>
[Fact]
public void Sequence_FullWrapCycles_NeverReportAGap()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0).ShouldBeTrue();
for (var i = 1; i <= 600; i++)
{
var seq = (ulong)(i % 256);
s.Accept(seq).ShouldBeTrue($"seq {seq} (message {i}) was reported as a gap");
}
}
/// <summary>
/// A gap is reported <b>once</b> and the tracker then resynchronizes onto the observed
/// sequence. Leaving the baseline pinned at the pre-gap value would make every subsequent
/// message report a gap too, turning one lost message into a permanent rebirth storm — the
/// same pathology as a wrong wrap boundary, reached by a different route.
/// </summary>
[Fact]
public void Sequence_GapReportedOnce_ThenResynchronizes()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(13).ShouldBeFalse(); // three messages lost
s.Accept(14).ShouldBeTrue(); // resynchronized onto 13
s.Accept(15).ShouldBeTrue();
}
/// <summary>
/// A repeated sequence number is not contiguous. Sparkplug publishes DATA at QoS 0, so a
/// duplicate is not a broker redelivery — it is a publisher whose stream no longer matches
/// what the driver can reason about, and a rebirth is the cheap correct answer.
/// </summary>
[Fact]
public void Sequence_RepeatedValue_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(10).ShouldBeFalse();
}
/// <summary>A sequence number going backwards is a gap, not a wrap.</summary>
[Fact]
public void Sequence_BackwardJump_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(9).ShouldBeFalse();
}
/// <summary>
/// The first message cannot be a gap — there is no baseline to measure it against — but the
/// tracker must still report that it is <b>not</b> birth-synchronized, because a first
/// message that was not a birth means the driver joined mid-stream and has no alias table.
/// Conflating the two would let a late join look like a healthy synchronized node.
/// </summary>
[Fact]
public void Sequence_FirstMessage_IsNotAGap_ButIsNotBirthSynchronized()
{
var s = new SequenceTracker();
s.IsBirthSynchronized.ShouldBeFalse();
s.LastSeq.ShouldBeNull();
s.Accept(137).ShouldBeTrue();
s.LastSeq.ShouldBe((byte)137);
s.IsBirthSynchronized.ShouldBeFalse("a mid-stream first message is a late join, not a birth");
}
/// <summary>
/// <b>The aliasing test.</b> The codec hands over <c>seq</c> as a <see cref="ulong"/> and
/// deliberately declines to truncate it, so an out-of-range value reaches this type intact. A
/// tracker that masks it into range turns 256 into a perfectly plausible 0 — and with the
/// baseline sitting at 255, that is exactly the value the wrap expects next, so the bogus
/// message would be accepted as contiguous.
/// </summary>
[Fact]
public void Sequence_OutOfRangeSeq_IsRejected_AndDoesNotAliasOntoTheExpectedValue()
{
var s = new SequenceTracker();
s.Accept(255).ShouldBeTrue();
// 256 & 0xFF == 0, and 0 is precisely what follows 255. Masking would return true here.
s.Accept(256).ShouldBeFalse();
}
/// <summary>
/// A rejected out-of-range value does not become the baseline either: the tracker keeps the
/// last credible sequence number, so the following genuine message is still measured against
/// something real.
/// </summary>
[Fact]
public void Sequence_OutOfRangeSeq_DoesNotBecomeTheBaseline()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(ulong.MaxValue).ShouldBeFalse();
s.LastSeq.ShouldBe((byte)10);
s.Accept(11).ShouldBeTrue("the baseline must still be the last credible seq, 10");
}
/// <summary>
/// A message carrying no sequence number at all is a spec violation the tracker cannot place
/// in the stream. It reports a gap (the safe direction — a rebirth resynchronizes everything)
/// and leaves the baseline alone.
/// </summary>
[Fact]
public void Sequence_AbsentSeq_IsGap_AndDoesNotAdvanceTheBaseline()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(null).ShouldBeFalse();
s.LastSeq.ShouldBe((byte)10);
s.Accept(11).ShouldBeTrue();
}
// ---------------------------------------------------------------------------------------
// seq — births restart the sequence
// ---------------------------------------------------------------------------------------
/// <summary>
/// A birth restarts the sequence at 0, so it is never itself a gap however far the previous
/// session had counted. A tracker that ran the birth through the ordinary contiguity check
/// would flag a gap on the very message that just fixed everything, and request a rebirth in
/// response to a birth.
/// </summary>
[Fact]
public void Birth_RestartsTheSequence_SoABirthIsNeverAGap()
{
var s = new SequenceTracker();
s.Accept(200).ShouldBeTrue();
s.AcceptNodeBirth(0).ShouldBeTrue();
s.LastSeq.ShouldBe((byte)0);
s.IsBirthSynchronized.ShouldBeTrue();
s.Accept(1).ShouldBeTrue();
}
/// <summary>
/// A gap detected <i>after</i> a birth is genuine — the birth resets the baseline, it does not
/// suppress detection. Without this the previous test could be satisfied by a tracker that
/// simply stopped checking once a birth arrived.
/// </summary>
[Fact]
public void Birth_ThenSkippedValue_IsStillAGap()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0).ShouldBeTrue();
s.Accept(1).ShouldBeTrue();
s.Accept(3).ShouldBeFalse();
}
/// <summary>
/// A birth whose <c>seq</c> is in range but not the spec-required 0 is adopted as the
/// baseline rather than rejected. Refusing it would leave the tracker unsynchronized against
/// a publisher whose stream is otherwise perfectly followable, and every following message
/// would demand another rebirth.
/// </summary>
[Fact]
public void Birth_WithNonZeroInRangeSeq_IsAdoptedAsTheBaseline()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(7).ShouldBeTrue();
s.LastSeq.ShouldBe((byte)7);
s.IsBirthSynchronized.ShouldBeTrue();
s.Accept(8).ShouldBeTrue();
}
/// <summary>
/// A birth whose <c>seq</c> is out of range establishes no baseline — the same
/// no-masking rule as the data path, applied where it matters most.
/// </summary>
[Fact]
public void Birth_WithOutOfRangeSeq_EstablishesNoBaseline()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(256).ShouldBeFalse();
s.LastSeq.ShouldBeNull();
s.IsBirthSynchronized.ShouldBeFalse();
}
/// <summary>
/// <b>A DBIRTH continues the edge node's sequence — it does not restart it, and it must not be
/// routed through <see cref="SequenceTracker.AcceptNodeBirth"/>.</b> Only the NBIRTH resets
/// <c>seq</c>, and only the NBIRTH/NDEATH carry <c>bdSeq</c>. Sending a DBIRTH to the node-birth
/// entry point would rebase the sequence mid-stream <i>and</i> — having no <c>bdSeq</c> to pass
/// — wipe the session token, at which point a stale Last Will finds nothing to be compared
/// against and kills the live node. This test pins the shape at the tracker's own boundary so
/// the ingest state machine has something to fail against if it wires the call sites the other
/// way round.
/// </summary>
[Fact]
public void DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 7).ShouldBeTrue();
s.Accept(1).ShouldBeTrue(); // the DBIRTH — next in the node's stream, not a restart
s.Accept(2).ShouldBeTrue(); // first DDATA
s.BirthBdSeq.ShouldBe(7UL, "a device birth must not disturb the node's session token");
s.IsDeathForCurrentSession(6).ShouldBeFalse("the stale-will guard must still have a token to compare");
}
/// <summary>
/// <see cref="SequenceTracker.Reset"/> returns the tracker to its virgin state. This is the
/// reconnect seam: after a dropped connection the driver has missed an unknown number of
/// messages, so the baseline it held is no longer evidence of anything.
/// </summary>
[Fact]
public void Reset_ClearsTheBaselineAndTheBirthSynchronizedFlag()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 4).ShouldBeTrue();
s.Accept(1).ShouldBeTrue();
s.Reset();
s.LastSeq.ShouldBeNull();
s.IsBirthSynchronized.ShouldBeFalse();
s.BirthBdSeq.ShouldBeNull();
s.Accept(99).ShouldBeTrue("a virgin tracker cannot detect a gap on its first message");
}
// ---------------------------------------------------------------------------------------
// bdSeq — the birth↔death tie
// ---------------------------------------------------------------------------------------
/// <summary>
/// <b>The case the tie exists for.</b> A node's connection drops, it reconnects and publishes
/// a fresh NBIRTH — and only then does the broker notice the old connection died and deliver
/// its Last Will NDEATH, carrying the <i>previous</i> session's bdSeq. Without the compare,
/// that stale will marks a live, freshly-born node dead and every one of its tags goes Bad.
/// </summary>
[Fact]
public void Death_WithStaleBdSeqArrivingAfterARebirth_DoesNotApply()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 7); // session 7 — the one that is about to die
s.AcceptNodeBirth(0, bdSeq: 8); // session 8 — the node is back, and alive
s.IsDeathForCurrentSession(7).ShouldBeFalse("session 7's will must not kill session 8");
}
/// <summary>A death whose bdSeq matches the current birth is the real thing and applies.</summary>
[Fact]
public void Death_WithMatchingBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 8);
s.IsDeathForCurrentSession(8).ShouldBeTrue();
}
/// <summary>
/// A death arriving before any birth was ever seen applies. The tracker has nothing to
/// disprove it with, and the two failure directions are not symmetric: acting on a death
/// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4), while
/// ignoring a real death leaves a dead node's last values flowing at Good quality forever.
/// </summary>
[Fact]
public void Death_BeforeAnyBirth_Applies()
{
var s = new SequenceTracker();
s.IsDeathForCurrentSession(7).ShouldBeTrue();
}
/// <summary>
/// A death carrying no bdSeq metric cannot be tied to anything, so it applies — same
/// fail-toward-stale reasoning as above.
/// </summary>
[Fact]
public void Death_WithNoBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 8);
s.IsDeathForCurrentSession(null).ShouldBeTrue();
}
/// <summary>A death applies when the <i>birth</i> was the one that carried no bdSeq.</summary>
[Fact]
public void Death_WhenTheBirthCarriedNoBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: null);
s.IsDeathForCurrentSession(7).ShouldBeTrue();
}
/// <summary>
/// A birth records its bdSeq even when its <c>seq</c> was unusable. Session identity and
/// sequence position are independent facts; discarding the tie because the sequence number
/// was garbled would leave the next death unmatchable.
/// </summary>
[Fact]
public void Birth_WithUnusableSeq_StillRecordsItsBdSeq()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(256, bdSeq: 8).ShouldBeFalse();
s.BirthBdSeq.ShouldBe(8UL);
s.IsDeathForCurrentSession(7).ShouldBeFalse();
}
// ---------------------------------------------------------------------------------------
// bdSeq — reading it out of a payload
// ---------------------------------------------------------------------------------------
/// <summary>
/// <c>bdSeq</c> is not a top-level payload field: it rides as a metric named <c>bdSeq</c> in
/// the NBIRTH and the NDEATH.
/// </summary>
[Fact]
public void TryReadBdSeq_ReadsTheBdSeqMetric()
{
var payload = PayloadWith(Metric("bdSeq", DataType.Int64, 8UL));
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
bdSeq.ShouldBe(8UL);
}
/// <summary>
/// Sparkplug carries <c>Int64</c> as two's complement in an unsigned field, so a negative
/// bdSeq reaches the projection as an enormous <see cref="ulong"/>. Reading it raw would
/// mint a plausible-looking session token out of nonsense; the reinterpretation is what
/// exposes it as negative so it can be refused.
/// </summary>
[Fact]
public void TryReadBdSeq_NegativeSignedValue_IsRefused_NotReadAsAHugeToken()
{
var payload = PayloadWith(Metric("bdSeq", DataType.Int64, unchecked((ulong)-1L)));
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
bdSeq.ShouldBe(0UL);
}
/// <summary>
/// An unsigned bdSeq is read as-is — no reinterpretation applies. (The generated enum member
/// is <c>Uint64</c>, not <c>UInt64</c>: protobuf's C# codegen lowercases the interior of the
/// proto's <c>UInt64</c>.)
/// </summary>
[Fact]
public void TryReadBdSeq_UnsignedValue_IsReadAsIs()
{
var payload = PayloadWith(Metric("bdSeq", DataType.Uint64, 9UL));
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
bdSeq.ShouldBe(9UL);
}
/// <summary>A payload that carries no bdSeq metric reports absence rather than a default.</summary>
[Fact]
public void TryReadBdSeq_PayloadWithoutTheMetric_ReportsAbsence()
{
var payload = PayloadWith(Metric("Temperature", DataType.Float, 21.5f));
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
bdSeq.ShouldBe(0UL);
}
/// <summary>
/// An undecodable payload yields no bdSeq. It presents as metric-less exactly like a valid
/// but empty one, and only <see cref="SparkplugPayload.IsValid"/> tells them apart.
/// </summary>
[Fact]
public void TryReadBdSeq_InvalidPayload_ReportsAbsence()
{
SequenceTracker.TryReadBdSeq(SparkplugPayload.Invalid, out _).ShouldBeFalse();
}
/// <summary>A null payload is a verdict, not an exception — this runs on the dispatcher thread.</summary>
[Fact]
public void TryReadBdSeq_NullPayload_ReportsAbsence_DoesNotThrow()
{
Should.NotThrow(() => SequenceTracker.TryReadBdSeq(null, out _).ShouldBeFalse());
}
/// <summary>
/// A bdSeq metric with no scalar value (explicitly null, or a kind v1 does not support) is
/// not a session token.
/// </summary>
[Fact]
public void TryReadBdSeq_NonScalarMetric_ReportsAbsence()
{
var payload = PayloadWith(new SparkplugMetric("bdSeq", null, DataType.Int64, null, SparkplugValueKind.Null, null));
SequenceTracker.TryReadBdSeq(payload, out _).ShouldBeFalse();
}
private static SparkplugPayload PayloadWith(params SparkplugMetric[] metrics) =>
new(IsValid: true, Seq: 0UL, TimestampMs: 1UL, Metrics: metrics);
private static SparkplugMetric Metric(string name, DataType dataType, object value) =>
new(name, Alias: null, dataType, TimestampMs: null, SparkplugValueKind.Scalar, value);
}