fix(mqtt): reset Sparkplug ingest state at every session/authoring seam

Task 21 review — two Criticals sharing one root cause, plus four follow-ups.

C1/C2 root cause: `OnReconnectedAsync` opened with `Births.Clear(); _trackers.Clear();`
— "not housekeeping, the correctness step" — and that reset was missing from the
other two seams where the view underneath a surviving cache changes.

- C1: the ingestor OUTLIVES a session rebuild. `SessionIdentity` is a strict
  superset of `IngestIdentity`, so changing only Host/Port/ClientId/credentials/
  TLS/a timeout gives `!SameSession && SameIngest` — `ReinitializeAsync` tears the
  session down and re-establishes on the same instance, and `TeardownAsync` touches
  no ingest state (the resilience layer re-running `InitializeAsync` is the second
  path). A node that rebound alias 5 during the changeover would publish Pressure's
  value under the Temperature RawPath at Good. Hoisted into `ResetSessionState`,
  called from `AttachTo` (earliest seam — a persistent session can push between
  CONNACK and our SUBSCRIBE), `EstablishAsync` and `OnReconnectedAsync`.
- C2: `Register` pruned trackers but not births. While a node is unauthored its
  traffic is dropped at `Dispatch`, so its cached birth FREEZES rather than
  refreshing; drop-then-re-add across a week mis-routes at Good, and `Births` grew
  monotonically. Now evicted on edge-node departure.
  Deliberately NARROWER than "absent from ByScope": a device with no authored tags
  under a still-authored node keeps receiving traffic, so its birth is live, not
  frozen — evicting it would discard correct state. Pinned both ways.

- I1: a birth whose seq is unreadable produced a permanent debounce-paced rebirth
  loop. "A birth arrived" is now tracked separately from "the birth established a
  baseline". Writing the test showed the cap belonged wider than the review scoped
  it: data-before-birth and unknown-alias loop identically (20 messages → 23 NCMDs
  against a cap of 3), so all three missing-metadata reasons share one per-node
  consecutive cap, re-armed by any applied birth. A seq gap stays uncapped — it
  self-limits.
- I2: the oversize `WarnOnce` key was the raw topic, off a group-wide `#`
  subscription. `HandleMessage` now parses and filters to authored nodes BEFORE the
  size check (also skipping the protobuf parse for discarded traffic), and `_warned`
  carries a hard ceiling so a future bad derivation degrades to silence.
- I3: array metrics slipped the unsupported-type gate — `ToDriverDataType` returns
  the ELEMENT type, so a String-typed tag published a packed `byte[]` as base64 at
  Good. Gated on `IsSparkplugArray`.
- I4: driver-level Sparkplug coverage for the composition-root lines T22 left
  untested — the `OnDataChange` re-raise, `ReadAsync` off the shared cache,
  subscribe/unsubscribe dispatch, and both directions of the mode gate.

Minors: M1 the oversize test fed unparseable bytes and passed with the size check
deleted (now a real NBIRTH + an at-the-ceiling control); M2 answered in-place (an
absent seq is refused WITHOUT becoming the baseline, so the NCMD assertion is the
complete check); M3 `HostStateObserved` raise wrapped; M5 the legacy `STATE/{host}`
form is now subscribed, so the parser's tolerance is reachable in production; M8
`ResolveBinding` prefers the NAME over a disagreeing alias; M9 a narrowing float
conversion refuses instead of publishing ±Infinity at Good (a publisher's own
infinity still passes through). M4 was already resolved by T22.

Also fixed a race in the new tests: `publish.Topics.Clear()` after a call that
dispatches rebirths off-thread must drain first — it made the C1 sequence-baseline
pin pass vacuously in the first falsifiability run.

Falsifiability: dropping the reset from AttachTo+EstablishAsync reddens 4 (C1);
dropping the Register eviction reddens 2 (C2); widening the eviction predicate to
the reviewer's literal phrasing reddens the keep-live-births pin (C2b). 545/545 MQTT
unit tests green (was 524); whole-solution `--no-incremental` build clean under TWAE.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 22:44:43 -04:00
parent cd0157a3b8
commit 6d7a458c4d
3 changed files with 911 additions and 34 deletions
@@ -584,6 +584,124 @@ public sealed class MqttDriverDiscoveryTests
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String);
}
/// <summary>
/// <b>The composition-root line.</b> Task 21 wires the ingestor's <c>OnDataChange</c> to the
/// driver's own in <c>CreateSparkplugIngestor</c>; every other Sparkplug test in the repo drives
/// <see cref="SparkplugIngestor"/> directly and would pass with that lambda deleted. This repo
/// has twice shipped a component correct in isolation and inert in production for exactly that
/// reason (<c>DeferredAddressSpaceSink</c>, <c>GatewayTagProvisioner</c>) — so the re-raise is
/// asserted through <see cref="MqttDriver"/>'s own event, under the RawPath.
/// </summary>
[Fact]
public async Task SparkplugMode_DriverRepublishesIngestDataChanges_UnderTheRawPath()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
object? sender = null;
string? firedRef = null;
object? firedValue = null;
((ISubscribable)driver).OnDataChange += (s, e) =>
{
sender = s;
firedRef = e.FullReference;
firedValue = e.Snapshot.Value;
};
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
FeedDeviceData(driver, seq: 2, (5UL, 21.5f));
firedRef.ShouldBe(SpTempPath);
firedValue.ShouldBe(21.5f);
// The driver re-raises with ITSELF as sender, not the ingestor — DriverHostActor attributes
// publishes to the driver instance.
sender.ShouldBeSameAs(driver);
}
/// <summary>
/// <c>ReadAsync</c> serves from the driver-owned <c>LastValueCache</c>, which the Sparkplug
/// ingestor is handed at construction. If the two ever stopped sharing one instance, every
/// Sparkplug read would answer <c>BadWaitingForInitialData</c> forever while subscriptions
/// looked perfectly healthy.
/// </summary>
[Fact]
public async Task SparkplugMode_ReadAsyncServesTheIngestedValue()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
FeedDeviceData(driver, seq: 2, (5UL, 33.5f));
var results = await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken);
results[0].Value.ShouldBe(33.5f);
results[0].StatusCode.ShouldBe(0x00000000u);
}
/// <summary>
/// Subscribe/unsubscribe dispatch by mode. After an unsubscribe the reference is no longer
/// attributed to a handle, so ingest stops raising for it — while the cache keeps answering, so
/// a read still works. Both halves matter: a driver that kept publishing after unsubscribe
/// would leak notifications into a torn-down monitored item.
/// </summary>
[Fact]
public async Task SparkplugMode_UnsubscribeStopsNotifications_ButNotReads()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
var handle = await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var fired = 0;
((ISubscribable)driver).OnDataChange += (_, _) => Interlocked.Increment(ref fired);
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
FeedDeviceData(driver, seq: 2, (5UL, 7.5f));
fired.ShouldBe(0);
(await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken))[0].Value.ShouldBe(7.5f);
}
/// <summary>
/// The mode gate from the other side — the reviewer's reading of the branching, turned into a
/// test. A Plain-mode driver builds NO Sparkplug ingestor at all, so no Sparkplug traffic can
/// reach it and a Sparkplug-shaped blob (which carries no <c>topic</c>) resolves to nothing.
/// </summary>
[Fact]
public void PlainMode_BuildsNoSparkplugIngestor()
{
var driver = new MqttDriver(
new MqttDriverOptions
{
Mode = MqttMode.Plain,
RawTags = [SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))],
},
"d",
null);
driver.Sparkplug.ShouldBeNull();
driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse();
}
/// <summary>
/// …and the converse: a Sparkplug-mode driver registers into the Sparkplug path, not the plain
/// manager. Registering into both would double-warn on every deploy and, worse, let a plain
/// topic route a Sparkplug tag.
/// </summary>
[Fact]
public void SparkplugMode_RegistersIntoTheSparkplugPathOnly()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
driver.Sparkplug!.TryResolve(SpTempPath, out var def).ShouldBeTrue();
def.MetricName.ShouldBe("Temperature");
driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse();
}
// ---- Sparkplug fixtures ----
private const string SpGroup = "Plant1";
@@ -657,6 +775,22 @@ public sealed class MqttDriverDiscoveryTests
SparkplugCodec.Decode(payload.ToByteArray()));
}
/// <summary>
/// Feeds a real DDATA — alias-only metrics, exactly as a Sparkplug node publishes after a birth.
/// </summary>
private static void FeedDeviceData(MqttDriver driver, ulong seq, params (ulong Alias, float Value)[] metrics)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL };
foreach (var (alias, value) in metrics)
{
payload.Metrics.Add(new Payload.Types.Metric { Alias = alias, FloatValue = value });
}
driver.Sparkplug!.Dispatch(
new SparkplugTopic(SparkplugMessageType.DDATA, SpGroup, SpNode, SpDevice, null),
SparkplugCodec.Decode(payload.ToByteArray()));
}
/// <summary>
/// A birth metric declaring name/alias/datatype and <b>no value</b> — a birth's job here is to
/// declare the catalog, and these tests assert on the catalog, never on a published value.
@@ -482,6 +482,15 @@ public sealed class SparkplugIngestorTests
ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
await ing.DrainPendingRebirthsAsync();
// This single assertion IS the complete check, and the review question "does it also verify the
// baseline?" has an answer rather than a gap: SequenceTracker.Accept refuses an absent seq
// WITHOUT adopting it as the baseline, so routing the death through it has exactly one
// observable consequence — a reported gap, and therefore this NCMD. Falsifiability run (b)
// confirmed it: inserting CheckSequence into OnNodeDeath reddens this test and only this test.
//
// A follow-up DDATA would NOT strengthen it. An accepted NDEATH evicts the node's births, so
// the next data message is legitimately data-before-birth and asks for a rebirth on its own
// merits — asserting silence there would pin the wrong behaviour.
publish.Topics.ShouldBeEmpty();
}
@@ -723,21 +732,42 @@ public sealed class SparkplugIngestorTests
Should.NotThrow(() => FeedBirths(ing));
}
/// <summary>An oversize body is refused BEFORE any protobuf parse, and mutates nothing.</summary>
/// <summary>
/// An oversize body is refused BEFORE any protobuf parse, and mutates nothing.
/// </summary>
/// <remarks>
/// The payload is a <b>genuine, decodable</b> NBIRTH — the control below proves the very same
/// bytes birth the scope once the ceiling is raised. An earlier version of this test fed
/// <c>new byte[64]</c>, which fails to parse anyway, so it held with the size check deleted.
/// </remarks>
[Fact]
public async Task OversizePayload_IsDroppedBeforeDecoding()
{
var ing = NewIngestor(maxPayloadBytes: 16);
var wire = NbirthBytes(seq: 0, bdSeq: 1);
var ing = NewIngestor(maxPayloadBytes: wire.Length - 1);
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
var bytes = Nbirth(seq: 0, bdSeq: 1);
bytes.Metrics.Count.ShouldBeGreaterThan(0);
ing.HandleMessage("spBv1.0/Plant1/NBIRTH/EdgeA", new byte[64], retained: false);
ing.HandleMessage("spBv1.0/Plant1/NBIRTH/EdgeA", wire, retained: false);
ing.Births.Count.ShouldBe(0);
}
/// <summary>
/// Falsifiability control for the test above: the identical bytes, one byte of headroom, birth
/// the scope. Without this the oversize assertion is satisfied by any reason the payload failed.
/// </summary>
[Fact]
public async Task PayloadExactlyAtTheCeiling_IsAccepted()
{
var wire = NbirthBytes(seq: 0, bdSeq: 1);
var ing = NewIngestor(maxPayloadBytes: wire.Length);
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
ing.HandleMessage("spBv1.0/Plant1/NBIRTH/EdgeA", wire, retained: false);
ing.Births.Count.ShouldBe(1);
}
/// <summary>
/// The driver subscribes <c>spBv1.0/{group}/#</c>, so a large plant delivers traffic for every
/// node in the group. Only authored edge nodes are processed — that is what bounds the tracker
@@ -877,7 +907,8 @@ public sealed class SparkplugIngestorTests
await ing.EstablishAsync(TestContext.Current.CancellationToken);
await ing.DrainPendingRebirthsAsync();
subscribe.Filters.Select(f => f.Topic).ShouldBe(["spBv1.0/Plant1/#", "spBv1.0/STATE/otopcua-host-1"]);
subscribe.Filters.Select(f => f.Topic).ShouldBe(
["spBv1.0/Plant1/#", "spBv1.0/STATE/otopcua-host-1", "STATE/otopcua-host-1"]);
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
@@ -968,6 +999,7 @@ public sealed class SparkplugIngestorTests
FeedBirths(ing);
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
await ing.DrainPendingRebirthsAsync();
publish.Topics.Clear();
var fired = false;
@@ -1038,6 +1070,378 @@ public sealed class SparkplugIngestorTests
observed[1].MetricNames.ShouldContain("Temperature");
}
// ---------------------------------------------------------------------------------
// Session-state reset — the seams where the view underneath a surviving cache changes
// ---------------------------------------------------------------------------------
/// <summary>
/// <b>C1.</b> The ingestor OUTLIVES a session rebuild: <c>SessionIdentity</c> is a strict
/// superset of <c>IngestIdentity</c>, so changing only the broker Host / Port / ClientId /
/// credentials / TLS / a timeout re-establishes on this very instance. If the birth cache
/// survived that, an edge node which restarted during the changeover and rebound alias 5 from
/// Temperature to Pressure would have its first DDATA on the NEW session resolved against the
/// OLD table — Pressure's value under the Temperature RawPath, at Good quality.
/// </summary>
[Fact]
public async Task EstablishAsync_DropsBirthsFromThePreviousSession()
{
var subscribe = new FakeSubscribeTransport();
var ing = await NewSubscribedIngestorAsync(new RecordingPublishTransport(), subscribe);
FeedBirths(ing);
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
await ing.EstablishAsync(TestContext.Current.CancellationToken);
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldBeNull();
}
/// <summary>
/// The consequence, asserted on the wire rather than on the cache: after a session rebuild the
/// old alias must not resolve, so the first DDATA publishes nothing and asks for a rebirth.
/// </summary>
[Fact]
public async Task AfterEstablish_OldAliasNoLongerResolves()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport());
FeedBirths(ing);
await ing.EstablishAsync(TestContext.Current.CancellationToken);
// Drain BEFORE clearing: Establish's own late-join rebirth is dispatched off-thread, so a bare
// Clear() races it and the assertions below could be satisfied by that NCMD rather than by the
// one the reset is supposed to cause.
await ing.DrainPendingRebirthsAsync();
publish.Topics.Clear();
var fired = false;
ing.OnDataChange += (_, _) => fired = true;
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
fired.ShouldBeFalse();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
/// <summary>
/// The sequence half of the same reset. A surviving baseline lets a fresh stream look contiguous
/// by coincidence — and, worse, leaves <c>IsBirthSynchronized</c> true, so the late-join branch
/// never fires and nothing asks for the metadata the driver no longer has.
/// </summary>
[Fact]
public async Task EstablishAsync_DropsTheSequenceBaseline()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport());
FeedBirths(ing);
await ing.EstablishAsync(TestContext.Current.CancellationToken);
// Drain BEFORE clearing: Establish's own late-join rebirth is dispatched off-thread, so a bare
// Clear() races it and the assertions below could be satisfied by that NCMD rather than by the
// one the reset is supposed to cause.
await ing.DrainPendingRebirthsAsync();
publish.Topics.Clear();
// Contiguous against the OLD baseline (the DBIRTH was seq 1) — must still be treated as a late
// join, because nothing about the new session's stream has been observed.
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
/// <summary>
/// <c>AttachTo</c> is the earliest seam at which the session changes, and a persistent-session
/// broker can push queued messages between CONNACK and our own SUBSCRIBE — so the reset happens
/// there too, not only at <c>EstablishAsync</c>.
/// </summary>
[Fact]
public async Task AttachTo_DropsBirthsFromThePreviousSession()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
await using var connection = new MqttConnection(
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false },
"mqtt-spb-1");
ing.AttachTo(connection);
ing.Births.Count.ShouldBe(0);
}
/// <summary>
/// <b>C2.</b> While an edge node is unauthored its traffic is dropped at <c>Dispatch</c>, so its
/// cached birth FREEZES rather than refreshing. Deploy A authors it, deploy B drops it, deploy C
/// re-adds it a week later having missed every rebirth in between — a surviving cache answers
/// with the week-old alias table and the next DDATA mis-routes at Good quality.
/// </summary>
[Fact]
public async Task Register_DroppingAnEdgeNode_EvictsItsBirths()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
ing.Births.Count.ShouldBeGreaterThan(0);
ing.Register([]); // deploy B: the node is no longer authored
ing.Register(DefaultTags()); // deploy C: it comes back
ing.Births.Count.ShouldBe(0);
}
/// <summary>The C2 consequence on the wire: the re-added node's old alias must not resolve.</summary>
[Fact]
public async Task AfterEdgeNodeLeftAndReturned_OldAliasNoLongerResolves()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
ing.Register([]);
ing.Register(DefaultTags());
await ing.SubscribeAsync(
[.. ing.AuthoredRawPaths],
TimeSpan.Zero,
TestContext.Current.CancellationToken);
publish.Topics.Clear();
var fired = false;
ing.OnDataChange += (_, _) => fired = true;
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
fired.ShouldBeFalse();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
/// <summary>
/// The narrower half of the C2 rule, asserted so a future "tidy-up" cannot widen it. A device
/// with no authored tags whose EDGE NODE is still authored keeps receiving traffic (the Dispatch
/// filter is node-level), so its birth is live, not frozen — evicting it would throw away
/// correct state and cost a rebirth round-trip the moment a later deploy authored a tag on it.
/// </summary>
[Fact]
public async Task Register_KeepsBirthsForStillAuthoredEdgeNodes()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
// Drops the Filler1 tags but keeps the node-level Uptime tag, so EdgeA stays authored.
ing.Register([Tag(NodeUptimePath, Blob(Group, Node, null, "Uptime", "Int64"))]);
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
ing.Births.Find(new SparkplugScope(Group, Node, null)).ShouldNotBeNull();
}
// ---------------------------------------------------------------------------------
// Rebirth-loop bounds
// ---------------------------------------------------------------------------------
/// <summary>
/// An NBIRTH whose <c>seq</c> cannot be read leaves the tracker un-synchronised forever — asking
/// for another birth cannot fix it, because the node answers with the same unusable payload. The
/// driver must record that a birth ARRIVED and stop asking.
/// </summary>
[Fact]
public async Task NbirthWithUnreadableSeq_DoesNotLoopRequestingRebirths()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
// seq is present but out of the 0-255 Sparkplug range, so AcceptNodeBirth refuses it.
var payload = new Payload { Seq = 9999UL, Timestamp = 1UL };
payload.Metrics.Add(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Decode(payload));
publish.Topics.Clear();
for (var i = 0; i < 5; i++)
{
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: (ulong)i));
}
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldBeEmpty();
}
/// <summary>
/// The residual loop the birth-seen flag cannot see: an NBIRTH that never reaches the state
/// machine — because it exceeded the payload ceiling, or because the node simply never sends
/// one. Nothing about asking again changes that.
/// </summary>
/// <remarks>
/// Each of these messages raises <b>two</b> uncapped conditions in the pre-fix code — late join
/// AND data-before-birth — which is what made the first version of this test observe 23 NCMDs
/// against a cap of 3. The debounce caps the RATE, not the LIFETIME; only the per-node
/// consecutive cap ends the loop, and it has to cover every missing-metadata reason to do it.
/// </remarks>
[Fact]
public async Task NbirthNeverArriving_CapsConsecutiveMetadataRebirths()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
for (var i = 0; i < 20; i++)
{
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: (ulong)i, (TempAlias, TahuDataType.Float, 1f)));
}
await ing.DrainPendingRebirthsAsync();
// Bounded, and bounded LOW — the cap, not "eventually stops".
publish.Topics.Count.ShouldBeLessThanOrEqualTo(3);
publish.Topics.ShouldNotBeEmpty();
}
/// <summary>A birth that DOES arrive re-arms the cap — the count is consecutive, not cumulative.</summary>
[Fact]
public async Task ArrivingNbirth_ResetsTheMetadataRebirthCap()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
for (var i = 0; i < 20; i++)
{
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: (ulong)i, (TempAlias, TahuDataType.Float, 1f)));
}
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
await ing.DrainPendingRebirthsAsync();
publish.Topics.Clear();
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldNotBeEmpty();
}
// ---------------------------------------------------------------------------------
// Value-level gates
// ---------------------------------------------------------------------------------
/// <summary>
/// Array metrics slip past the unsupported-type gate, because <c>ToDriverDataType</c> maps every
/// <c>*Array</c> variant to its ELEMENT type and is therefore never null for one. The value
/// rides as a packed <c>byte[]</c>, so a String-typed tag would publish base64 at GOOD quality.
/// </summary>
[Fact]
public async Task ArrayMetric_IsSkipped_NotPublishedAsBase64()
{
const string ArrTag = "Plant/Mqtt/spb/TempArray";
var ing = NewIngestor(tags: [Tag(ArrTag, Blob(Group, Node, Device, "Temperature", "String"))]);
await ing.SubscribeAsync([ArrTag], TimeSpan.Zero, TestContext.Current.CancellationToken);
var seen = new List<DataValueSnapshot>();
ing.OnDataChange += (_, e) => seen.Add(e.Snapshot);
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
var birth = new Payload { Seq = 1UL, Timestamp = 1UL };
birth.Metrics.Add(new Payload.Types.Metric
{
Name = "Temperature",
Alias = TempAlias,
Datatype = (uint)TahuDataType.FloatArray,
BytesValue = ByteString.CopyFrom([0x00, 0x00, 0x80, 0x3F]),
});
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
seen.ShouldBeEmpty();
ing.Values.Read(ArrTag).StatusCode.ShouldNotBe(Good);
}
/// <summary>
/// <c>Convert.ToSingle</c> saturates rather than throwing, so a Double of 1e300 onto a Float32
/// tag would publish +Infinity at GOOD quality — a number no gauge renders and no comparison
/// handles. A narrowing overflow is a refusal.
/// </summary>
[Fact]
public async Task NarrowingOverflowToFloat32_IsRefused_NotPublishedAsInfinity()
{
const string F32 = "Plant/Mqtt/spb/Narrowed";
var ing = NewIngestor(tags: [Tag(F32, Blob(Group, Node, Device, "Big", "Float32"))]);
await ing.SubscribeAsync([F32], TimeSpan.Zero, TestContext.Current.CancellationToken);
DataValueSnapshot? snapshot = null;
ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(
Topic(SparkplugMessageType.DBIRTH, Device),
DbirthCustom(seq: 1, ("Big", 21UL, TahuDataType.Double, 1e300d)));
snapshot!.StatusCode.ShouldBe(BadTypeMismatch);
snapshot.Value.ShouldBeNull();
}
/// <summary>
/// A publisher's OWN infinity is its value, not our overflow — it passes through, so the guard
/// above cannot be satisfied by blanket-refusing non-finite numbers.
/// </summary>
[Fact]
public async Task PublishersOwnInfinity_IsPassedThrough()
{
const string F32 = "Plant/Mqtt/spb/Inf";
var ing = NewIngestor(tags: [Tag(F32, Blob(Group, Node, Device, "Big", "Float32"))]);
await ing.SubscribeAsync([F32], TimeSpan.Zero, TestContext.Current.CancellationToken);
DataValueSnapshot? snapshot = null;
ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(
Topic(SparkplugMessageType.DBIRTH, Device),
DbirthCustom(seq: 1, ("Big", 21UL, TahuDataType.Float, float.PositiveInfinity)));
snapshot!.StatusCode.ShouldBe(Good);
snapshot.Value.ShouldBe(float.PositiveInfinity);
}
/// <summary>
/// The metric NAME is the binding key, so when a DATA metric carries both a name and an alias
/// that disagree, the name wins. Preferring the alias resolves to whichever metric currently
/// occupies that slot — the mis-route the binding rule exists to prevent.
/// </summary>
[Fact]
public async Task DataMetricCarryingNameAndDisagreeingAlias_BindsByName()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var seen = new Dictionary<string, object?>(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
// Alias 6 is Pressure; the name says Temperature. The name is the identity that survives a
// rebirth, so this must land on the Temperature RawPath.
var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Temperature",
Alias = PressAlias,
FloatValue = 3.5f,
});
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
seen.ShouldContainKey(TempPath);
seen[TempPath].ShouldBe(3.5f);
seen.ShouldNotContainKey(PressPath);
}
/// <summary>
/// The legacy pre-3.0 STATE form is parsed on receive — so it must also be SUBSCRIBED, or that
/// tolerance is reachable only from a test calling <c>HandleMessage</c> directly.
/// </summary>
[Fact]
public async Task EstablishAsync_SubscribesTheLegacyStateFilterToo()
{
var subscribe = new FakeSubscribeTransport();
var ing = NewIngestor(subscribe: subscribe, hostId: "otopcua-host-1");
await ing.EstablishAsync(TestContext.Current.CancellationToken);
subscribe.Filters.Select(f => f.Topic).ShouldContain("STATE/otopcua-host-1");
}
// =================================================================================
// Fixtures
// =================================================================================
@@ -1132,6 +1536,20 @@ public sealed class SparkplugIngestorTests
private static SparkplugPayload Decode(Payload payload) => SparkplugCodec.Decode(payload.ToByteArray());
/// <summary>The standard NBIRTH as WIRE BYTES — for the payload-ceiling tests, which need a real one.</summary>
private static byte[] NbirthBytes(ulong seq, ulong bdSeq)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "bdSeq",
Datatype = (uint)TahuDataType.Uint64,
LongValue = bdSeq,
});
payload.Metrics.Add(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
return payload.ToByteArray();
}
/// <summary>An NBIRTH: the <c>bdSeq</c> session token plus the node's own metric catalog.</summary>
private static SparkplugPayload Nbirth(ulong seq, ulong bdSeq)
{