From 2afef00eaa7f6cda4ba4084165bb57350b85c388 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 21:28:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(mqtt):=20AliasTable/BirthCache=20=E2=80=94?= =?UTF-8?q?=20bind-by-name,=20rebuild-per-birth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design §3.6 invariants #1/#2. A Sparkplug alias is scoped to ONE birth: after a rebirth an edge node may point alias 5 at a different metric. So authored tags bind by the stable metric NAME and the alias is a per-birth cache only — RebuildFromBirth builds both indexes fresh and publishes them in one assignment, never merging or upserting, including for an empty birth (which legitimately clears the scope). BirthCache keys scopes by the full (group, edgeNode, device?) triple and applies the spec's node→device rules: an NBIRTH invalidates every DBIRTH under that node (returning their metric sets for the STALE fan-out), a death evicts rather than blanks so data-before-rebirth stays detectable. Reads are lock-free on MQTTnet's dispatcher thread — an immutable snapshot swapped atomically per scope, the same discipline MqttSubscriptionManager.AuthoredTable uses, with rebuilds landing in place so a held table reference always observes the newest birth. Running values are deliberately NOT stored here — LastValueCache owns those, keyed by RawPath. SparkplugMetricBinding.Reinterpret exposes the birth datatype to the codec's two's-complement fix, since a DATA metric carries no datatype. Falsifiability: mutating RebuildFromBirth to merge reddened 5/19 tests (both plan invariants); making the alias carry metric identity across a birth reddened 3/19, including the headline alias-reuse case. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Sparkplug/AliasTable.cs | 263 ++++++++++++ .../Sparkplug/BirthCache.cs | 234 +++++++++++ .../AliasBindingTests.cs | 393 ++++++++++++++++++ 3 files changed, 890 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs new file mode 100644 index 00000000..45d66b0b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs @@ -0,0 +1,263 @@ +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; + +/// +/// One (edgeNode, device) scope's metric catalog, as declared by its most recent +/// NBIRTH/DBIRTH: the alias→metric cache and the stable name→metric index, rebuilt wholesale +/// on every birth. Design doc §3.6 invariants #1 and #2. +/// +/// +/// +/// The alias is a per-birth cache; the NAME is the binding key. Sparkplug's alias +/// mechanism exists to shrink DATA payloads — a BIRTH declares name ↔ alias and every +/// subsequent DATA metric carries only the alias — and the alias is scoped to that one +/// birth. After a rebirth an edge node is entirely free to point alias 5 at a different +/// metric. So an authored tag binds by ; the alias is +/// only ever the lookup that turns an incoming DATA metric back into a name. Binding a tag to +/// an alias instead means a rebirth silently starts routing Pressure into a Temperature tag — +/// good quality, plausible values, completely wrong, and invisible unless something +/// specifically reuses an alias across a rebirth. +/// +/// +/// REPLACES; it never merges or upserts. The whole +/// snapshot — both indexes — is built fresh and published in one assignment. An alias the new +/// birth did not declare is gone, including when the new birth declares no metrics at all. This +/// is the single behaviour the type exists to guarantee: a merge (or an "optimisation" that +/// skips an empty birth) reintroduces exactly the stale-alias mis-route above. +/// +/// +/// Both indexes are replaced together, from the same birth. They are deliberately held +/// in one type rather than split across an "alias table" and a separate "birth cache": they are +/// two views of one birth, and any seam between them is somewhere the two can be replaced +/// independently and skew — an alias resolving through the new birth into a name catalog still +/// holding the old one. +/// +/// +/// Thread-safety: an immutable snapshot swapped atomically, the same discipline +/// MqttSubscriptionManager.AuthoredTable uses and for the same reason — this is read on +/// MQTTnet's shared dispatcher thread as messages arrive, and a reader must never see a +/// half-rebuilt map. No lock is taken on any path. +/// +/// +/// Last values do not live here. The running last-observed value per tag is +/// 's, keyed by RawPath — the identity every publish in this driver +/// is made under. A second copy keyed by metric name would be a duplicate source of truth with +/// a different key, and it would grow with every metric the plant births whether or not any tag +/// was ever authored for it. A birth's own values flow straight through the ingest path to that +/// cache; nothing is retained here. +/// +/// +public sealed class AliasTable +{ + private volatile Snapshot _snapshot = Snapshot.Empty; + + /// How many distinct metrics the most recent birth declared. + public int Count => _snapshot.Metrics.Length; + + /// + /// Every metric the most recent birth declared. The consumer's STALE fan-out on a death, and + /// the discovery path's "which metrics does this scope have" question, both read this. + /// + public IReadOnlyList Metrics => _snapshot.Metrics; + + /// + /// Replaces this scope's whole catalog with — the metrics of one + /// NBIRTH/DBIRTH payload, exactly as projected them. + /// + /// The birth payload's metrics. + /// + /// What was installed, and what was refused — the caller's only window onto a malformed birth, + /// since this type takes no logger. + /// + /// + /// + /// Only an unusable NAME is a rejection. A metric with a blank/absent name cannot be + /// bound to an authored tag by any route, and storing it alias-only would create a binding + /// that can never resolve (or, worse, one that matches a tag whose authored metric name + /// happens to be blank), so it is dropped and counted. + /// + /// + /// A missing datatype is NOT a rejection — it becomes + /// . Dropping such a metric would turn every DATA message + /// for its alias into an unknown-alias event, which the ingest state machine answers with a + /// rebirth request; the edge node would answer with the same malformed birth, and the pair + /// would loop. Kept as Unknown, the typed layer refuses the value once + /// (ToDriverDataType() returns null for it) and nothing storms. + /// + /// + /// An alias claimed by two metrics in one birth resolves to neither. Any choice + /// between them is a coin flip between two different signals, which is the mis-route this + /// type exists to prevent; the alias is dropped (the consumer sees an unknown alias and can + /// request a rebirth) while both metrics stay bindable by name. A duplicate name, by + /// contrast, is last-wins: the name is the binding key, so evicting it would take the + /// authored tag dark entirely. + /// + /// + public BirthApplyResult RebuildFromBirth(IEnumerable birthMetrics) + { + ArgumentNullException.ThrowIfNull(birthMetrics); + + var byName = new Dictionary(StringComparer.Ordinal); + var byAlias = new Dictionary(); + HashSet? collidedAliases = null; + var rejectedUnnamed = 0; + var duplicateNames = 0; + var aliasCollisions = 0; + + foreach (var metric in birthMetrics) + { + if (string.IsNullOrWhiteSpace(metric.Name)) + { + rejectedUnnamed++; + continue; + } + + var binding = new SparkplugMetricBinding(metric.Name, metric.Alias, metric.DataType ?? TahuDataType.Unknown); + + if (!byName.TryAdd(binding.Name, binding)) + { + duplicateNames++; + byName[binding.Name] = binding; + } + + if (binding.Alias is { } alias && !byAlias.TryAdd(alias, binding)) + { + aliasCollisions++; + (collidedAliases ??= []).Add(alias); + } + } + + if (collidedAliases is not null) + { + foreach (var alias in collidedAliases) + { + byAlias.Remove(alias); + } + } + + // A duplicate name is last-wins in `byName`, so the alias index can still hold the losing + // binding — drop those too rather than let an alias resolve to a name that resolves elsewhere. + if (duplicateNames > 0) + { + foreach (var alias in byAlias.Where(kv => !ReferenceEquals(byName[kv.Value.Name], kv.Value)) + .Select(kv => kv.Key).ToList()) + { + byAlias.Remove(alias); + } + } + + // THE replace. One assignment of a fully-built snapshot — no merge, no upsert, and no + // short-circuit for an empty birth (an empty birth legitimately clears the scope). + _snapshot = new Snapshot( + byAlias.ToFrozenDictionary(), + byName.ToFrozenDictionary(StringComparer.Ordinal), + [.. byName.Values]); + + return new BirthApplyResult(byName.Count, rejectedUnnamed, aliasCollisions, duplicateNames); + } + + /// + /// Resolves a DATA metric's alias to the metric the most recent birth bound it to, or + /// when this birth declared no such alias. + /// + /// The alias carried by an inbound DATA metric. + /// The binding, or — which the consumer treats as an unknown alias. + public SparkplugMetricBinding? Resolve(ulong alias) => + _snapshot.ByAlias.TryGetValue(alias, out var binding) ? binding : null; + + /// The try-shape. + /// The alias carried by an inbound DATA metric. + /// The binding when this returns . + /// when the most recent birth declared this alias. + public bool TryResolve(ulong alias, [NotNullWhen(true)] out SparkplugMetricBinding? binding) + { + binding = Resolve(alias); + return binding is not null; + } + + /// + /// Resolves by the stable metric name — the binding key. Used for a DATA metric that carried its + /// name rather than an alias (aliases are optional in Sparkplug), and to answer "does this scope + /// publish the metric this tag was authored against". + /// + /// The stable metric name. Matched ordinally; Sparkplug names are case-sensitive. + /// The binding, or . + public SparkplugMetricBinding? ResolveByName(string name) => + !string.IsNullOrEmpty(name) && _snapshot.ByName.TryGetValue(name, out var binding) ? binding : null; + + /// The try-shape. + /// The stable metric name. + /// The binding when this returns . + /// when the most recent birth declared this metric. + public bool TryResolveByName(string name, [NotNullWhen(true)] out SparkplugMetricBinding? binding) + { + binding = ResolveByName(name); + return binding is not null; + } + + /// + /// One birth's catalog, immutable and published in a single assignment so the dispatcher thread + /// always reads one whole birth's worth of state. + /// + /// Alias → binding, for the birth that declared it. Collided aliases are absent. + /// Stable metric name → binding — the index authored tags bind through. + /// Every declared metric, for the STALE fan-out and discovery. + private sealed record Snapshot( + FrozenDictionary ByAlias, + FrozenDictionary ByName, + SparkplugMetricBinding[] Metrics) + { + public static readonly Snapshot Empty = new( + FrozenDictionary.Empty, + FrozenDictionary.Empty, + []); + } +} + +/// One metric as its birth declared it: the stable name, the per-birth alias, the datatype. +/// +/// The stable metric name — the binding key. An authored tag's metricName matches +/// against this, never against . +/// +/// +/// The alias this birth assigned, or when the birth declared none (legal — +/// such a publisher sends names in DATA too). Valid only until the next birth for this scope. +/// +/// +/// The datatype the birth declared, or when it declared none. +/// This is the only place a DATA metric's datatype can come from — DATA carries none. +/// +public sealed record SparkplugMetricBinding(string Name, ulong? Alias, TahuDataType DataType) +{ + /// + /// Applies to a raw DATA value using + /// this birth's datatype. + /// + /// The raw from a DATA metric. + /// The signed value for a signed-integer datatype; otherwise. + /// + /// Exposed here because this is the one object that holds the datatype at the moment a DATA + /// value is being resolved, and forgetting the call is silent: Sparkplug carries Int8/16/32/64 + /// as two's complement in an unsigned proto field, so a metric whose value is -42 + /// publishes as 4294967254 — plausible, Good quality, and invisible until someone reads a gauge. + /// + public object? Reinterpret(object? rawValue) => SparkplugCodec.ReinterpretSigned(rawValue, DataType); +} + +/// What one installed, and what it refused. +/// Distinct named metrics installed. +/// Metrics dropped for carrying no usable name. +/// Aliases claimed by more than one metric, and therefore left unresolvable. +/// Metrics whose name a later metric in the same birth overwrote. +public readonly record struct BirthApplyResult( + int Accepted, + int RejectedUnnamed, + int AliasCollisions, + int DuplicateNames) +{ + /// Whether the birth was malformed in any way worth logging. + public bool HasAnomaly => RejectedUnnamed > 0 || AliasCollisions > 0 || DuplicateNames > 0; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs new file mode 100644 index 00000000..126238e3 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs @@ -0,0 +1,234 @@ +using System.Collections.Concurrent; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; + +/// +/// The driver's live Sparkplug birth state: one per +/// , with the spec's node→device invalidation rules applied on birth +/// and on death. Design doc §3.6 invariants #2 and #4. +/// +/// +/// +/// Scoping. A scope is the full (group, edgeNode, device?) triple — never the +/// device id alone and never node/device without the group, or one plant's +/// Filler1 would answer for another's. A node-scoped birth (NBIRTH) owns the metrics the +/// edge node publishes itself; each DBIRTH owns its device's. They are separate scopes because +/// Sparkplug gives them separate alias spaces: alias 5 on EdgeA and alias 5 on +/// EdgeA/Filler1 are unrelated metrics. +/// +/// +/// An NBIRTH invalidates every device under that node. Per the Sparkplug spec an NBIRTH +/// means the edge node (re)started and must re-publish a DBIRTH for each of its devices, so +/// every previously published DBIRTH is void. Keeping a device's alias table across its node's +/// rebirth is the same stale-alias mis-route as +/// guards against, one scope up — and it is easier to +/// miss, because nothing about the DBIRTH itself changed. The invalidated devices are returned +/// () rather than merely dropped, so the +/// consumer can fan STALE out over their metrics without a lookup against a table that has +/// already been evicted. +/// +/// +/// A death evicts, it does not blank. After an NDEATH/DDEATH the scope is absent, +/// which is what makes a DATA message arriving before the next birth a data-before-birth event +/// (⇒ request a rebirth, §3.6 invariant #3) rather than a silent unknown-alias drop against a +/// table that still looks alive. +/// +/// +/// Thread-safety. A of scopes, each +/// holding an whose own state is an atomically swapped immutable +/// snapshot. A rebirth rebuilds the existing table in place rather than replacing the +/// table object, so a consumer holding a reference to a scope's table always observes the newest +/// birth instead of quietly reading a detached older one. No lock is taken on any path — this is +/// read (and written) on MQTTnet's shared dispatcher thread. +/// +/// +/// Not a value store. See the remarks on : running values belong +/// to , keyed by RawPath. +/// +/// +public sealed class BirthCache +{ + private readonly ConcurrentDictionary _scopes = new(); + + /// How many scopes currently hold a live birth. + public int Count => _scopes.Count; + + /// A snapshot of the scopes currently holding a live birth. + public IReadOnlyList Scopes => [.. _scopes.Keys]; + + /// + /// Applies an NBIRTH: rebuilds the edge node's own catalog and invalidates every device + /// under it, per the Sparkplug spec. + /// + /// The Sparkplug group id. + /// The Sparkplug edge-node id. + /// The NBIRTH payload's metrics. + /// The node's apply result plus the devices this birth invalidated, with their metrics. + public NodeBirthOutcome ApplyNodeBirth(string groupId, string edgeNodeId, IEnumerable birthMetrics) + { + ArgumentException.ThrowIfNullOrEmpty(groupId); + ArgumentException.ThrowIfNullOrEmpty(edgeNodeId); + ArgumentNullException.ThrowIfNull(birthMetrics); + + // Devices first: a device table must never outlive the node birth that voided it, whatever the + // node's own rebuild does. + var invalidated = Evict(scope => scope.DeviceId is not null && scope.IsUnder(groupId, edgeNodeId)); + + var result = TableFor(new SparkplugScope(groupId, edgeNodeId, null)).RebuildFromBirth(birthMetrics); + return new NodeBirthOutcome(result, invalidated); + } + + /// Applies a DBIRTH: rebuilds exactly that device's catalog, wholesale. + /// The Sparkplug group id. + /// The Sparkplug edge-node id. + /// The Sparkplug device id. + /// The DBIRTH payload's metrics. + /// What the birth installed, and what it refused. + public BirthApplyResult ApplyDeviceBirth( + string groupId, + string edgeNodeId, + string deviceId, + IEnumerable birthMetrics) + { + ArgumentException.ThrowIfNullOrEmpty(groupId); + ArgumentException.ThrowIfNullOrEmpty(edgeNodeId); + ArgumentException.ThrowIfNullOrEmpty(deviceId); + ArgumentNullException.ThrowIfNull(birthMetrics); + + return TableFor(new SparkplugScope(groupId, edgeNodeId, deviceId)).RebuildFromBirth(birthMetrics); + } + + /// + /// The scope's catalog, or when no live birth has been seen for it — + /// which is the consumer's data-before-birth signal. + /// + /// The scope to look up. + /// The catalog, or . + public AliasTable? Find(SparkplugScope scope) => _scopes.TryGetValue(scope, out var table) ? table : null; + + /// + /// Applies an NDEATH: forgets the edge node and every device under it, returning what was + /// forgotten so the consumer can emit STALE for those metrics. + /// + /// The Sparkplug group id. + /// The Sparkplug edge-node id. + /// The evicted scopes and their metrics; empty when nothing was live. + public IReadOnlyList ForgetNode(string groupId, string edgeNodeId) + { + ArgumentException.ThrowIfNullOrEmpty(groupId); + ArgumentException.ThrowIfNullOrEmpty(edgeNodeId); + + return Evict(scope => scope.IsUnder(groupId, edgeNodeId)); + } + + /// + /// Applies a DDEATH: forgets exactly one device, returning its metrics for the STALE fan-out. + /// The node's own catalog is untouched. + /// + /// The Sparkplug group id. + /// The Sparkplug edge-node id. + /// The Sparkplug device id. + /// The evicted scope and its metrics when this returns . + /// when the device had a live birth to forget. + public bool TryForgetDevice( + string groupId, + string edgeNodeId, + string deviceId, + out SparkplugScopeMetrics removed) + { + ArgumentException.ThrowIfNullOrEmpty(groupId); + ArgumentException.ThrowIfNullOrEmpty(edgeNodeId); + ArgumentException.ThrowIfNullOrEmpty(deviceId); + + var scope = new SparkplugScope(groupId, edgeNodeId, deviceId); + if (_scopes.TryRemove(scope, out var table)) + { + removed = new SparkplugScopeMetrics(scope, table.Metrics); + return true; + } + + removed = default; + return false; + } + + /// Forgets every scope — the disconnect/reinitialise reset. + public void Clear() => _scopes.Clear(); + + /// + /// The scope's catalog, created empty on first use. Rebuilds happen in place on the + /// returned instance, so a consumer holding it observes the newest birth rather than a detached + /// older one. + /// + /// The scope. + /// The scope's catalog. + private AliasTable TableFor(SparkplugScope scope) => _scopes.GetOrAdd(scope, static _ => new AliasTable()); + + /// Removes every scope matching , capturing their metrics first. + /// Which scopes to evict. + /// The evicted scopes and the metrics they held. + private List Evict(Func predicate) + { + List? evicted = null; + + // ConcurrentDictionary enumeration is weakly consistent, which is exactly right here: this runs + // on the dispatcher thread and only needs to see the scopes that existed when the birth/death + // arrived. + foreach (var scope in _scopes.Keys) + { + if (!predicate(scope) || !_scopes.TryRemove(scope, out var table)) + { + continue; + } + + (evicted ??= []).Add(new SparkplugScopeMetrics(scope, table.Metrics)); + } + + return evicted ?? []; + } +} + +/// +/// A Sparkplug metric-namespace scope: an edge node, or one device under it. The identity every +/// birth, death and alias resolution is keyed by. +/// +/// The Sparkplug group id. +/// The Sparkplug edge-node id. +/// The Sparkplug device id, or for the node's own scope. +public readonly record struct SparkplugScope(string GroupId, string EdgeNodeId, string? DeviceId) +{ + /// Whether this is a device scope (a DBIRTH's) rather than an edge node's own. + public bool IsDevice => DeviceId is not null; + + /// The owning edge node's scope — this instance itself when it is already node-scoped. + public SparkplugScope NodeScope => DeviceId is null ? this : new SparkplugScope(GroupId, EdgeNodeId, null); + + /// Whether this scope belongs to the given edge node (the node's own scope included). + /// The Sparkplug group id. + /// The Sparkplug edge-node id. + /// when both segments match ordinally. + public bool IsUnder(string groupId, string edgeNodeId) => + string.Equals(GroupId, groupId, StringComparison.Ordinal) + && string.Equals(EdgeNodeId, edgeNodeId, StringComparison.Ordinal); + + /// + public override string ToString() => + DeviceId is null ? $"{GroupId}/{EdgeNodeId}" : $"{GroupId}/{EdgeNodeId}/{DeviceId}"; +} + +/// +/// A scope's metrics, captured at the moment it was evicted — the STALE fan-out's input, taken +/// before the eviction so it cannot race a re-birth of the same scope. +/// +/// The scope that was evicted. +/// The metrics its last birth declared. +public readonly record struct SparkplugScopeMetrics(SparkplugScope Scope, IReadOnlyList Metrics); + +/// The result of an NBIRTH: the node's own apply, plus the devices it invalidated. +/// What the node's catalog rebuild installed and refused. +/// +/// The device scopes this NBIRTH voided, with the metrics they held — the STALE fan-out's input +/// until each device re-DBIRTHs. +/// +public readonly record struct NodeBirthOutcome( + BirthApplyResult Result, + IReadOnlyList InvalidatedDevices); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs new file mode 100644 index 00000000..4fe69960 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs @@ -0,0 +1,393 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; + +// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project +// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated +// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using` +// scope does not cross a ProjectReference. +using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Covers + (Task 18) — design doc §3.6 +/// invariants #1 and #2. +/// +/// +/// +/// What is actually being pinned here. Sparkplug's alias mechanism exists to shrink DATA +/// payloads: a BIRTH declares name ↔ alias and every subsequent DATA metric carries only +/// the alias. The alias is scoped to one birth — after a rebirth an edge node is free to +/// assign alias 5 to an entirely different metric. If this driver merged alias tables across +/// births, or bound authored tags to aliases rather than to the stable metric name, a rebirth +/// would silently start routing Pressure readings into a Temperature tag: good quality, +/// plausible values, completely wrong. It is invisible without a test that specifically +/// reuses an alias across a rebirth, which is what +/// and +/// do. +/// +/// +/// The cross-scope half of the same hazard is that a device's alias table can outlive the birth +/// it came from when its edge node re-births — pinned by +/// . +/// +/// +public sealed class AliasBindingTests +{ + // ---- AliasTable: the two load-bearing invariants ---- + + /// + /// §3.6 invariant #1/#2, the headline case: a rebirth may point the same alias at a different + /// metric, and the table must report the new metric — i.e. the birth replaced the map + /// rather than merging into it. + /// + [Fact] + public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias() + { + var table = new AliasTable(); + + table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]); + table.Resolve(5)!.Name.ShouldBe("Temperature"); + + // Rebirth: alias 5 now means a DIFFERENT metric. + table.RebuildFromBirth([Birth("Pressure", 5, SparkplugDataType.Float)]); + + table.Resolve(5)!.Name.ShouldBe("Pressure"); // wholesale replace, not merge + table.TryResolveByName("Temperature", out _).ShouldBeFalse(); + } + + /// + /// §3.6 invariant #2: an alias the new birth did not declare must be gone, not carried + /// forward. A merge would leave alias 1 resolving to a metric the edge node no longer publishes. + /// + [Fact] + public void RebuildFromBirth_DoesNotMergeStaleAliases() + { + var table = new AliasTable(); + + table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32)]); + table.RebuildFromBirth([Birth("B", 2, SparkplugDataType.Int32)]); + + table.TryResolve(1, out _).ShouldBeFalse(); // alias 1 gone after rebirth + table.TryResolve(2, out _).ShouldBeTrue(); + table.Count.ShouldBe(1); + } + + /// + /// The degenerate replace path: a birth carrying no usable metric still clears the table. + /// An implementation that short-circuits on an empty input (or merges) keeps every stale alias + /// alive behind a birth that declared none of them. + /// + [Fact] + public void RebuildFromBirth_EmptyBirth_ClearsEveryAlias() + { + var table = new AliasTable(); + table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32), Birth("B", 2, SparkplugDataType.Int32)]); + + table.RebuildFromBirth([]); + + table.Count.ShouldBe(0); + table.TryResolve(1, out _).ShouldBeFalse(); + table.TryResolve(2, out _).ShouldBeFalse(); + table.Metrics.ShouldBeEmpty(); + } + + /// + /// The mirror of the headline case: the metric stays put and its alias moves. Binding is + /// by name, so the tag keeps resolving; only the per-birth alias cache changed. + /// + [Fact] + public void Rebirth_MovesAMetricToADifferentAlias_NameBindingFollowsIt() + { + var table = new AliasTable(); + table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]); + + table.RebuildFromBirth([Birth("Temperature", 9, SparkplugDataType.Float)]); + + table.TryResolveByName("Temperature", out var byName).ShouldBeTrue(); + byName!.Alias.ShouldBe(9ul); + table.Resolve(9)!.Name.ShouldBe("Temperature"); + table.TryResolve(5, out _).ShouldBeFalse(); + } + + // ---- AliasTable: malformed births ---- + + /// + /// A BIRTH metric carrying an alias but no name is malformed and is rejected, never + /// stored alias-only: the name is the binding key, so an alias-only entry could only ever + /// resolve to a binding no authored tag can match — or, worse, match a tag whose authored + /// metric name happens to be blank. + /// + [Fact] + public void BirthMetricWithAnAliasButNoName_IsRejected_NotStoredAliasOnly() + { + var table = new AliasTable(); + + var result = table.RebuildFromBirth([Birth(null, 5, SparkplugDataType.Float), Birth(" ", 6, SparkplugDataType.Float)]); + + result.Accepted.ShouldBe(0); + result.RejectedUnnamed.ShouldBe(2); + result.HasAnomaly.ShouldBeTrue(); + table.TryResolve(5, out _).ShouldBeFalse(); + table.TryResolve(6, out _).ShouldBeFalse(); + } + + /// + /// Aliases are optional in Sparkplug — a publisher may declare a metric by name alone and then + /// send DATA carrying the name. Such a metric is a legitimate binding; it simply never appears + /// in the alias index. + /// + [Fact] + public void BirthMetricWithNoAlias_IsBindableByName_AndAbsentFromTheAliasIndex() + { + var table = new AliasTable(); + + table.RebuildFromBirth([Birth("Temperature", null, SparkplugDataType.Float)]); + + table.TryResolveByName("Temperature", out var binding).ShouldBeTrue(); + binding!.Alias.ShouldBeNull(); + table.Metrics.Count.ShouldBe(1); + } + + /// + /// Two metrics claiming the same alias in one birth is malformed, and any resolution is a coin + /// flip between two different metrics — precisely the mis-route this whole type exists to + /// prevent. The colliding alias is dropped from the alias index (the consumer sees an unknown + /// alias and can request a rebirth) while both metrics stay bindable by their names. + /// + [Fact] + public void DuplicateAliasInOneBirth_IsUnresolvable_RatherThanGuessed() + { + var table = new AliasTable(); + + var result = table.RebuildFromBirth( + [Birth("Temperature", 5, SparkplugDataType.Float), Birth("Pressure", 5, SparkplugDataType.Float)]); + + result.AliasCollisions.ShouldBe(1); + table.TryResolve(5, out _).ShouldBeFalse(); + table.TryResolveByName("Temperature", out _).ShouldBeTrue(); + table.TryResolveByName("Pressure", out _).ShouldBeTrue(); + } + + /// + /// Two metrics sharing a name in one birth: last-wins (the name is the binding key, so evicting + /// it would take the tag dark entirely), reported so the consumer can warn. + /// + [Fact] + public void DuplicateNameInOneBirth_IsLastWins_AndReported() + { + var table = new AliasTable(); + + var result = table.RebuildFromBirth( + [Birth("Temperature", 5, SparkplugDataType.Float), Birth("Temperature", 6, SparkplugDataType.Double)]); + + result.DuplicateNames.ShouldBe(1); + result.Accepted.ShouldBe(1); + table.TryResolveByName("Temperature", out var binding).ShouldBeTrue(); + binding!.Alias.ShouldBe(6ul); + } + + /// + /// A BIRTH metric with no datatype is malformed, but it is kept as + /// rather than rejected: rejecting it makes every DATA + /// message for its alias an unknown-alias event, which the ingest state machine answers with a + /// rebirth request — and the edge node would answer with the same malformed birth, forever. Kept + /// as Unknown, the typed layer refuses the value once (ToDriverDataType() returns null) + /// and nothing storms. + /// + [Fact] + public void BirthMetricWithNoDatatype_IsKeptAsUnknown_NotRejected() + { + var table = new AliasTable(); + + var result = table.RebuildFromBirth([Untyped("Temperature", 5)]); + + result.Accepted.ShouldBe(1); + result.RejectedUnnamed.ShouldBe(0); + table.Resolve(5)!.DataType.ShouldBe(SparkplugDataType.Unknown); + } + + /// + /// The birth datatype is what makes Sparkplug's two's-complement-in-an-unsigned-field encoding + /// undoable: a DATA metric carries no datatype of its own, so the binding is the only place the + /// signedness is known. Without this, an Int32 metric holding -42 publishes as 4294967254 — + /// plausible, Good quality, invisible. + /// + [Fact] + public void Binding_Reinterpret_UndoesTwosComplement_UsingTheBirthDatatype() + { + var table = new AliasTable(); + table.RebuildFromBirth([Birth("Setpoint", 5, SparkplugDataType.Int32)]); + + // What a DDATA metric for alias 5 carries: the raw unsigned wire field, no datatype. + var raw = unchecked((uint)-42); + + table.Resolve(5)!.Reinterpret(raw).ShouldBe(-42); + } + + /// A null birth sequence is a caller bug, not a birth — it must not silently clear the table. + [Fact] + public void RebuildFromBirth_Null_Throws() => + Should.Throw(() => new AliasTable().RebuildFromBirth(null!)); + + // ---- BirthCache: scoping ---- + + /// + /// Sparkplug spec: an NBIRTH means the edge node (re)started, so every DBIRTH previously + /// published under it is invalid until re-published. Keeping a device's alias table across its + /// node's rebirth is the same stale-alias mis-route as invariant #2, one scope up. + /// + [Fact] + public void NodeBirth_InvalidatesEveryDeviceUnderThatNode() + { + var cache = new BirthCache(); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 7, SparkplugDataType.Float)]); + + var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Node Control/Rebirth", 1, SparkplugDataType.Boolean)]); + + cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull(); + cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2")).ShouldBeNull(); + cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull(); + + // The invalidated devices come back with their metric sets so the consumer can fan STALE out + // without a lookup against a table that has already been evicted. + outcome.InvalidatedDevices.Count.ShouldBe(2); + outcome.InvalidatedDevices.SelectMany(d => d.Metrics).Select(m => m.Name) + .ShouldBe(["Temperature", "Speed"], ignoreOrder: true); + } + + /// An NBIRTH is scoped to its own edge node — another node's devices are untouched. + [Fact] + public void NodeBirth_DoesNotTouchAnotherNodesDevices() + { + var cache = new BirthCache(); + cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]); + + var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]); + + outcome.InvalidatedDevices.ShouldBeEmpty(); + cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull(); + } + + /// A DBIRTH replaces exactly one device's table; its siblings and its node keep theirs. + [Fact] + public void DeviceBirth_ReplacesOnlyThatDevicesTable() + { + var cache = new BirthCache(); + cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 5, SparkplugDataType.Float)]); + + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Humidity", 5, SparkplugDataType.Float)]); + + cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Humidity"); + cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2"))!.Resolve(5)!.Name.ShouldBe("Speed"); + cache.Find(new SparkplugScope("Plant1", "EdgeA", null))!.Resolve(1)!.Name.ShouldBe("Uptime"); + } + + /// + /// Key completeness. A cache keyed on the device id alone — or on node/device without the + /// group — would let one plant's Filler1 answer for another's, which is the same + /// wrong-but-plausible mis-route as an alias collision. + /// + [Fact] + public void Scopes_AreDistinctAcrossGroupsAndNodesWithTheSameDeviceId() + { + var cache = new BirthCache(); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]); + cache.ApplyDeviceBirth("Plant2", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]); + cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]); + + cache.Count.ShouldBe(3); + cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Temperature"); + cache.Find(new SparkplugScope("Plant2", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Pressure"); + cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Speed"); + } + + /// + /// §3.6 invariant #4's data half: an NDEATH takes the node and everything under it out of the + /// cache and hands back the metric sets, which is what the consumer fans STALE out over. + /// + [Fact] + public void ForgetNode_RemovesNodeAndDevices_AndReturnsTheirMetricsForStaleFanOut() + { + var cache = new BirthCache(); + cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]); + cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]); + + var removed = cache.ForgetNode("Plant1", "EdgeA"); + + removed.SelectMany(r => r.Metrics).Select(m => m.Name).ShouldBe(["Uptime", "Temperature"], ignoreOrder: true); + cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldBeNull(); + cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull(); + cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull(); + } + + /// A DDEATH takes out exactly one device and leaves its node's own metrics live. + [Fact] + public void TryForgetDevice_RemovesOnlyThatDevice_AndReturnsItsMetrics() + { + var cache = new BirthCache(); + cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]); + + cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out var removed).ShouldBeTrue(); + + removed.Metrics.Select(m => m.Name).ShouldBe(["Temperature"]); + removed.Scope.DeviceId.ShouldBe("Filler1"); + cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull(); + cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull(); + cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeFalse(); + } + + /// + /// After a death the scope is absent, not merely empty — that is what makes DATA arriving + /// before the next birth a data-before-birth event (⇒ rebirth request) rather than a silent + /// unknown-alias drop against a table that still looks alive. + /// + [Fact] + public void Find_AfterDeath_IsNull_SoDataBeforeRebirthIsNotResolvable() + { + var cache = new BirthCache(); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]); + + cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeTrue(); + + cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull(); + cache.Count.ShouldBe(0); + } + + /// + /// Invariant #1 end-to-end through the cache: the alias-reuse mis-route must not sneak back in + /// through the scoped path the ingest state machine actually calls. + /// + [Fact] + public void DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName() + { + var cache = new BirthCache(); + var scope = new SparkplugScope("Plant1", "EdgeA", "Filler1"); + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]); + + cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]); + + cache.Find(scope)!.Resolve(5)!.Name.ShouldBe("Pressure"); + cache.Find(scope)!.TryResolveByName("Temperature", out _).ShouldBeFalse(); + } + + /// Builds a BIRTH metric the way projects one. + private static SparkplugMetric Birth(string? name, ulong? alias, SparkplugDataType dataType, object? value = null) => + new( + Name: name, + Alias: alias, + DataType: dataType, + TimestampMs: null, + ValueKind: value is null ? SparkplugValueKind.Absent : SparkplugValueKind.Scalar, + Value: value); + + /// Builds a malformed BIRTH metric that declares no datatype at all. + private static SparkplugMetric Untyped(string name, ulong alias) => + new(Name: name, Alias: alias, DataType: null, TimestampMs: null, ValueKind: SparkplugValueKind.Absent, Value: null); +}