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); +}