e4e313cf1c
SequenceTracker (Driver.Mqtt/Sparkplug/) holds one edge node's Sparkplug stream-continuity state -- design doc SS3.6 invariant #3. Two independent halves: the wrapping 0-255 seq counter that reports whether a message was missed, and the bdSeq session token that ties an NDEATH to the NBIRTH it belongs to. Next-expected is (last + 1) & 0xFF, so 255 -> 0 is the sequence continuing. The boundary is the whole task because it fails differently in each direction: read the wrap as a gap and every edge node is asked to rebirth once per 256 messages, so a busy node spends its life republishing births instead of data; miss a real gap and the driver serves values it knows are behind the device, at Good quality, indefinitely. Both directions are asserted, plus two full wrap cycles so an off-by-one that only misfires at one value has nowhere to hide. A gap is reported once and the tracker then resynchronizes onto the observed value -- holding the baseline at the pre-gap number would make every following message report a gap too, so one lost message would become a permanent rebirth storm, the same pathology as a wrong wrap reached by a different route. An out-of-range seq is refused, never masked. The codec hands seq over as a ulong precisely so a spec-violating publisher's value is not truncated into a plausible-looking one, and masking would finish the job it declined to do: with the baseline at 255, a seq of 256 masks to 0 -- exactly what the wrap expects next -- and the bogus message would be accepted as contiguous. Refused values do not become the baseline either, so the next genuine message is still measured against the last credible one. A birth restarts the sequence via AcceptBirth rather than Accept, so it is never itself a gap; a gap after a birth is still detected. An in-range but non-zero birth seq is adopted rather than refused -- refusing it would demand a fresh rebirth for every message from an otherwise-followable publisher. The first message on a virgin tracker cannot be a gap (nothing to measure it against) but leaves IsBirthSynchronized false, which is how Task 21 tells a mid-stream late join from a synchronized node. bdSeq exists to stop a late Last Will from killing a live node: 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 will carrying the PREVIOUS session's bdSeq. Without the compare that stale NDEATH drives every tag of a freshly-born healthy node to Bad. Unknowns fail toward stale deliberately -- only a positive mismatch of two known tokens discards a death, because acting on a death wrongly self-corrects at the next birth (invariant #4) while ignoring a real one leaves a dead node's values flowing Good forever. bdSeq is not a top-level field, so TryReadBdSeq extracts it from the metric named bdSeq and runs it through ReinterpretSigned first: read raw, a negative Int64 bdSeq becomes an enormous ulong -- a plausible-looking session token minted out of nonsense. Falsifiability verified, each mutation reverted after observing RED: (a) no-wrap expected -> 2 RED; (b) Accept always true -> 5 RED; (c) bdSeq compare always matches -> 2 RED; (d) mask out-of-range seq into range -> 2 RED. 28 tests, 437/437 for the MQTT suite. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
452 lines
17 KiB
C#
452 lines
17 KiB
C#
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 0–255 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 0–255 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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(256).ShouldBeFalse();
|
||
|
||
s.LastSeq.ShouldBeNull();
|
||
s.IsBirthSynchronized.ShouldBeFalse();
|
||
}
|
||
|
||
/// <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.AcceptBirth(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.AcceptBirth(0, bdSeq: 7); // session 7 — the one that is about to die
|
||
s.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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);
|
||
}
|