diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs new file mode 100644 index 00000000..13c91bb3 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs @@ -0,0 +1,375 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; + +/// +/// Per-edge-node Sparkplug B stream-continuity state: the wrapping seq counter that says +/// whether the driver has missed a message, and the bdSeq session token that ties an +/// NDEATH to the NBIRTH it belongs to. Design doc §3.6 invariant #3. +/// +/// +/// +/// One instance per edge node — this object is the edge node's stream state. It +/// holds no key and no map. Sparkplug numbers messages per edge node (an edge node's devices +/// share its counter, so a DDATA advances the same sequence an NDATA does), and the ingest +/// state machine already keeps per-scope state for the alias table and birth cache; giving +/// this type a second internal dictionary would mean two maps whose lifetimes must be kept in +/// step by hand, and a tracker that outlived a removed edge node would go on answering +/// questions about a stream nobody is reading. +/// +/// +/// This type detects; it does not react. It never publishes, never requests a rebirth +/// and never emits a quality change — the ingest state machine reads its verdicts and decides. +/// The split matters because "was a message lost" is a fact about the wire, while "request a +/// rebirth now" is a policy gated on requestRebirthOnGap. +/// +/// +/// Thread-safe under a plain monitor. Sparkplug messages arrive on MQTTnet's shared +/// dispatcher thread while the reconnect path can call from another, and +/// every operation here is a read-modify-write of two coupled fields — the immutable-snapshot +/// discipline MqttSubscriptionManager uses fits a set that is swapped wholesale, not a +/// counter that is advanced. The critical sections are a handful of instructions each and +/// nothing inside them can block, so the simple lock this repo uses elsewhere +/// (MqttDriver._hostLock) is the right shape. +/// +/// +/// Nothing here throws, for any input. Same reasoning as +/// : this sits behind an unauthenticated firehose on the +/// dispatcher thread, so a spec-violating publisher gets a verdict, never an exception that +/// would stall delivery for every subscription on the connection. +/// +/// +public sealed class SequenceTracker +{ + /// + /// The largest legal Sparkplug seq. The counter is a byte on the wire in spirit but a + /// uint64 in the schema, so the range check lives here — see . + /// + public const ulong MaxSeq = 255; + + private readonly object _gate = new(); + + /// The last credible sequence number, meaningful only when . + private byte _lastSeq; + + /// Whether any credible sequence number has been observed since the last reset. + private bool _hasLastSeq; + + /// Whether the current baseline was established by a birth rather than adopted mid-stream. + private bool _birthSynchronized; + + /// The current session's bdSeq, or null when no birth has supplied one. + private ulong? _birthBdSeq; + + /// + /// Whether the current sequence baseline came from a birth. also after + /// a first message that was not a birth — the driver joined mid-stream and holds no + /// alias table, which is a different situation from a gap even though both call for a rebirth. + /// + public bool IsBirthSynchronized + { + get { lock (_gate) return _birthSynchronized; } + } + + /// + /// The last credible sequence number observed, or when none has been — + /// before the first message, or after . + /// + public byte? LastSeq + { + get { lock (_gate) return _hasLastSeq ? _lastSeq : null; } + } + + /// + /// The bdSeq of the most recent birth, or when no birth has + /// supplied one. This is the token matches against. + /// + public ulong? BirthBdSeq + { + get { lock (_gate) return _birthBdSeq; } + } + + /// + /// Records a birth (NBIRTH/DBIRTH): restarts the sequence at the birth's seq and adopts + /// its bdSeq as the current session token. + /// + /// + /// The birth payload's seq, which is + /// when the message carried none. + /// + /// + /// The birth's session token, read from its bdSeq metric with + /// ; when the birth carried none. + /// + /// + /// when the birth established a usable sequence baseline; + /// when its seq was absent or out of range. + /// + /// + /// + /// A birth is never a gap. It restarts the sequence by definition, so it must not + /// be run through — doing so would flag a gap on the very message + /// that just resynchronized everything, and answer a birth with a request for another one. + /// + /// + /// An in-range seq other than the spec-required 0 is adopted, not refused. + /// Refusing it would leave the tracker unsynchronized against a publisher whose stream is + /// otherwise perfectly followable, and every message that followed would demand a fresh + /// rebirth — the storm this type exists to prevent, arrived at by being strict. + /// + /// + /// The bdSeq is recorded even when the seq was unusable. Session + /// identity and position-in-stream are independent facts; discarding the tie because the + /// sequence number was garbled would leave the next NDEATH unmatchable, which is the one + /// thing that lets a stale will kill a live node. + /// + /// + public bool AcceptBirth(ulong? seq, ulong? bdSeq = null) + { + lock (_gate) + { + _birthBdSeq = bdSeq; + + if (seq is not { } value || value > MaxSeq) + { + _hasLastSeq = false; + _birthSynchronized = false; + return false; + } + + _lastSeq = (byte)value; + _hasLastSeq = true; + _birthSynchronized = true; + return true; + } + } + + /// + /// Offers the next sequenced message's seq (NDATA/DDATA/DDEATH) and reports whether the + /// stream is still contiguous. + /// + /// + /// The payload's seq, which is + /// when the message carried none. + /// + /// + /// when this seq is the one that follows the last; + /// when a message was missed, when the value is out of range, or when + /// it is absent. A is the caller's cue to request a rebirth. + /// + /// + /// + /// The wrap is the whole point: next-expected is (last + 1) & 0xFF, so + /// 255 → 0 is the sequence continuing, not a gap. Getting the boundary wrong fails + /// loudly in one direction and silently in the other. 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 publishing data. Miss a real gap and the driver serves + /// values it knows are behind the device, at Good quality, indefinitely. + /// + /// + /// A gap is reported once, then the tracker resynchronizes onto the observed value. + /// Holding the baseline at the pre-gap number would make every following message report a + /// gap too, so a single lost message would become a permanent rebirth storm. + /// + /// + /// An out-of-range seq is refused, never masked into range. The codec hands + /// this over as a precisely so a spec-violating publisher's value is + /// not silently 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 + /// the value 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. + /// + /// + /// The first message is not a gap — there is nothing to measure it against. It is + /// adopted as the baseline, and stays + /// to record that the driver joined mid-stream. + /// + /// + /// NDEATH must not be offered here. An NDEATH is the broker-published Last Will and + /// carries no seq at all; feeding it in would report a gap on a message that never + /// had a place in the sequence. It is tied to its birth by + /// instead. A DDEATH is published by the edge node + /// and is sequenced, so it does belong here. + /// + /// + public bool Accept(ulong? seq) + { + lock (_gate) + { + // Refused without becoming the baseline: see the out-of-range remarks above. + if (seq is not { } value || value > MaxSeq) + { + return false; + } + + var current = (byte)value; + + if (!_hasLastSeq) + { + _lastSeq = current; + _hasLastSeq = true; + return true; + } + + var expected = (byte)((_lastSeq + 1) & 0xFF); + + // Resynchronize even on a gap — one lost message must not become a permanent storm. + _lastSeq = current; + + return current == expected; + } + } + + /// + /// Reports whether an NDEATH belongs to the session currently believed alive — the + /// bdSeq tie of §3.6 invariant #3. + /// + /// + /// The death payload's bdSeq, read with ; + /// when it carried none. + /// + /// + /// when the death applies and the node's metrics should go stale; + /// when it is a previous session's will and must be ignored. + /// + /// + /// + /// What this prevents. An edge 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 retained Last Will. The NDEATH arrives after the NBIRTH, carrying the + /// previous session's bdSeq. Without this comparison that stale will marks a live, + /// freshly-born node dead and drives every one of its tags to Bad quality, with no further + /// message coming to correct it until the node happens to rebirth again. + /// + /// + /// Unknowns fail toward stale, deliberately. A death arriving before any birth was + /// seen, a death carrying no bdSeq, or a birth that carried none all resolve to + /// . The two error directions are not symmetric: acting on a death + /// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4, a + /// self-correcting error an operator can see), while ignoring a real death leaves a dead + /// node's last values flowing at Good quality forever. Only a positive mismatch — + /// two known, different tokens — is evidence enough to discard a death. + /// + /// + /// Pure. It answers a question and changes nothing, so the caller can ask it while + /// logging or deciding. A death that is acted on needs no reset here: the node is + /// offline, and the next birth calls which restarts the sequence + /// anyway. + /// + /// + public bool IsDeathForCurrentSession(ulong? deathBdSeq) + { + lock (_gate) + { + if (_birthBdSeq is not { } birth || deathBdSeq is not { } death) + { + return true; + } + + return birth == death; + } + } + + /// + /// Returns the tracker to its virgin state: no sequence baseline, not birth-synchronized, no + /// session token. + /// + /// + /// The reconnect seam (§3.6 invariant #5 — late join). After a dropped connection the driver + /// has missed an unknown number of messages, so the baseline it was holding is no longer + /// evidence of anything: carrying it across would let the stream look contiguous purely + /// because the edge node happened to publish the right number of messages while the driver was + /// away. + /// + public void Reset() + { + lock (_gate) + { + _lastSeq = 0; + _hasLastSeq = false; + _birthSynchronized = false; + _birthBdSeq = null; + } + } + + /// + /// Reads the bdSeq session token out of a decoded NBIRTH or NDEATH payload. + /// + /// The decoded payload; and invalid are both handled. + /// The token on success; 0 otherwise. + /// when the payload carried a usable bdSeq metric. + /// + /// + /// bdSeq is not a top-level payload field — unlike seq it rides as an + /// ordinary metric named bdSeq, which is why reading it is a helper rather than a + /// property. The name is matched exactly (ordinal): the spec fixes it, so a case variant + /// is far more likely to be a different metric than a typo of this one. + /// + /// + /// Signed values are reinterpreted before they are judged. Sparkplug carries + /// Int64 as two's complement in an unsigned field, so a negative bdSeq + /// reaches the projection as an enormous . Read raw it would become a + /// plausible-looking session token minted out of nonsense; run through + /// it is visibly negative and refused. + /// + /// + /// The value is treated as an opaque token, not as a 0–255 counter. Its only job + /// here is equality against the birth's, and a range check could never make a match more + /// or less correct — it could only discard the one piece of evidence tying a death to its + /// birth. This is the opposite call from seq, whose range is load-bearing precisely + /// because its arithmetic wraps. + /// + /// + public static bool TryReadBdSeq(SparkplugPayload? payload, out ulong bdSeq) + { + bdSeq = 0; + + if (payload is not { IsValid: true }) + { + return false; + } + + foreach (var metric in payload.Metrics) + { + if (!string.Equals(metric.Name, "bdSeq", StringComparison.Ordinal)) + { + continue; + } + + if (metric.ValueKind != SparkplugValueKind.Scalar) + { + return false; + } + + var value = metric.DataType is { } datatype + ? SparkplugCodec.ReinterpretSigned(metric.Value, datatype) + : metric.Value; + + switch (value) + { + case ulong u: + bdSeq = u; + return true; + case uint u: + bdSeq = u; + return true; + case long l when l >= 0: + bdSeq = (ulong)l; + return true; + case int i when i >= 0: + bdSeq = (ulong)i; + return true; + case short s when s >= 0: + bdSeq = (ulong)s; + return true; + case sbyte sb when sb >= 0: + bdSeq = (ulong)sb; + return true; + default: + // Negative, or a wire type no integer token can come from. + return false; + } + } + + return false; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs new file mode 100644 index 00000000..968a56c8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs @@ -0,0 +1,451 @@ +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; + +/// +/// Covers — design doc §3.6 invariant #3. Two halves, each with a +/// distinct failure mode the tests below pin from both directions: +/// +/// +/// seq gap detection across the 0–255 wrap. 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. +/// +/// +/// bdSeq birth↔death tie. A late Last-Will NDEATH from a previous session +/// arriving after the new NBIRTH must not kill the freshly-born node. +/// +/// +/// +public sealed class SequenceTrackerTests +{ + // --------------------------------------------------------------------------------------- + // seq — the wrap boundary + // --------------------------------------------------------------------------------------- + + /// + /// 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. + /// + [Fact] + public void Sequence_WrapAt255IsContiguous_NotAGap() + { + var s = new SequenceTracker(); + + s.Accept(254).ShouldBeTrue(); + s.Accept(255).ShouldBeTrue(); + s.Accept(0).ShouldBeTrue(); // wrap + } + + /// + /// 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. + /// + [Fact] + public void Sequence_SkippedValue_IsGap() + { + var s = new SequenceTracker(); + + s.Accept(10).ShouldBeTrue(); + s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth + } + + /// + /// 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. + /// + [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"); + } + } + + /// + /// A gap is reported once 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. + /// + [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(); + } + + /// + /// 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. + /// + [Fact] + public void Sequence_RepeatedValue_IsGap() + { + var s = new SequenceTracker(); + + s.Accept(10).ShouldBeTrue(); + s.Accept(10).ShouldBeFalse(); + } + + /// A sequence number going backwards is a gap, not a wrap. + [Fact] + public void Sequence_BackwardJump_IsGap() + { + var s = new SequenceTracker(); + + s.Accept(10).ShouldBeTrue(); + s.Accept(9).ShouldBeFalse(); + } + + /// + /// The first message cannot be a gap — there is no baseline to measure it against — but the + /// tracker must still report that it is not 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. + /// + [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"); + } + + /// + /// The aliasing test. The codec hands over seq as a 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. + /// + [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(); + } + + /// + /// 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. + /// + [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"); + } + + /// + /// 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. + /// + [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 + // --------------------------------------------------------------------------------------- + + /// + /// 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. + /// + [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(); + } + + /// + /// A gap detected after 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. + /// + [Fact] + public void Birth_ThenSkippedValue_IsStillAGap() + { + var s = new SequenceTracker(); + s.AcceptBirth(0).ShouldBeTrue(); + + s.Accept(1).ShouldBeTrue(); + s.Accept(3).ShouldBeFalse(); + } + + /// + /// A birth whose seq 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. + /// + [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(); + } + + /// + /// A birth whose seq is out of range establishes no baseline — the same + /// no-masking rule as the data path, applied where it matters most. + /// + [Fact] + public void Birth_WithOutOfRangeSeq_EstablishesNoBaseline() + { + var s = new SequenceTracker(); + + s.AcceptBirth(256).ShouldBeFalse(); + + s.LastSeq.ShouldBeNull(); + s.IsBirthSynchronized.ShouldBeFalse(); + } + + /// + /// 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. + /// + [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 + // --------------------------------------------------------------------------------------- + + /// + /// The case the tie exists for. 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 previous session's bdSeq. Without the compare, + /// that stale will marks a live, freshly-born node dead and every one of its tags goes Bad. + /// + [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"); + } + + /// A death whose bdSeq matches the current birth is the real thing and applies. + [Fact] + public void Death_WithMatchingBdSeq_Applies() + { + var s = new SequenceTracker(); + s.AcceptBirth(0, bdSeq: 8); + + s.IsDeathForCurrentSession(8).ShouldBeTrue(); + } + + /// + /// 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. + /// + [Fact] + public void Death_BeforeAnyBirth_Applies() + { + var s = new SequenceTracker(); + + s.IsDeathForCurrentSession(7).ShouldBeTrue(); + } + + /// + /// A death carrying no bdSeq metric cannot be tied to anything, so it applies — same + /// fail-toward-stale reasoning as above. + /// + [Fact] + public void Death_WithNoBdSeq_Applies() + { + var s = new SequenceTracker(); + s.AcceptBirth(0, bdSeq: 8); + + s.IsDeathForCurrentSession(null).ShouldBeTrue(); + } + + /// A death applies when the birth was the one that carried no bdSeq. + [Fact] + public void Death_WhenTheBirthCarriedNoBdSeq_Applies() + { + var s = new SequenceTracker(); + s.AcceptBirth(0, bdSeq: null); + + s.IsDeathForCurrentSession(7).ShouldBeTrue(); + } + + /// + /// A birth records its bdSeq even when its seq 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. + /// + [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 + // --------------------------------------------------------------------------------------- + + /// + /// bdSeq is not a top-level payload field: it rides as a metric named bdSeq in + /// the NBIRTH and the NDEATH. + /// + [Fact] + public void TryReadBdSeq_ReadsTheBdSeqMetric() + { + var payload = PayloadWith(Metric("bdSeq", DataType.Int64, 8UL)); + + SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue(); + bdSeq.ShouldBe(8UL); + } + + /// + /// Sparkplug carries Int64 as two's complement in an unsigned field, so a negative + /// bdSeq reaches the projection as an enormous . 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. + /// + [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); + } + + /// + /// An unsigned bdSeq is read as-is — no reinterpretation applies. (The generated enum member + /// is Uint64, not UInt64: protobuf's C# codegen lowercases the interior of the + /// proto's UInt64.) + /// + [Fact] + public void TryReadBdSeq_UnsignedValue_IsReadAsIs() + { + var payload = PayloadWith(Metric("bdSeq", DataType.Uint64, 9UL)); + + SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue(); + bdSeq.ShouldBe(9UL); + } + + /// A payload that carries no bdSeq metric reports absence rather than a default. + [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); + } + + /// + /// An undecodable payload yields no bdSeq. It presents as metric-less exactly like a valid + /// but empty one, and only tells them apart. + /// + [Fact] + public void TryReadBdSeq_InvalidPayload_ReportsAbsence() + { + SequenceTracker.TryReadBdSeq(SparkplugPayload.Invalid, out _).ShouldBeFalse(); + } + + /// A null payload is a verdict, not an exception — this runs on the dispatcher thread. + [Fact] + public void TryReadBdSeq_NullPayload_ReportsAbsence_DoesNotThrow() + { + Should.NotThrow(() => SequenceTracker.TryReadBdSeq(null, out _).ShouldBeFalse()); + } + + /// + /// A bdSeq metric with no scalar value (explicitly null, or a kind v1 does not support) is + /// not a session token. + /// + [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); +}