3fdf24659f
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
475 lines
19 KiB
C#
475 lines
19 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.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);
|
||
}
|