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