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