2afef00eaa
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
235 lines
12 KiB
C#
235 lines
12 KiB
C#
using System.Collections.Concurrent;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
|
|
|
/// <summary>
|
|
/// The driver's live Sparkplug birth state: one <see cref="AliasTable"/> per
|
|
/// <see cref="SparkplugScope"/>, with the spec's node→device invalidation rules applied on birth
|
|
/// and on death. Design doc §3.6 invariants #2 and #4.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Scoping.</b> A scope is the full <c>(group, edgeNode, device?)</c> triple — never the
|
|
/// device id alone and never <c>node/device</c> without the group, or one plant's
|
|
/// <c>Filler1</c> 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 <c>EdgeA</c> and alias 5 on
|
|
/// <c>EdgeA/Filler1</c> are unrelated metrics.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>An NBIRTH invalidates every device under that node.</b> 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
|
|
/// <see cref="AliasTable.RebuildFromBirth"/> guards against, one scope up — and it is easier to
|
|
/// miss, because nothing about the DBIRTH itself changed. The invalidated devices are returned
|
|
/// (<see cref="NodeBirthOutcome.InvalidatedDevices"/>) 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A death evicts, it does not blank.</b> After an NDEATH/DDEATH the scope is <i>absent</i>,
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Thread-safety.</b> A <see cref="ConcurrentDictionary{TKey,TValue}"/> of scopes, each
|
|
/// holding an <see cref="AliasTable"/> whose own state is an atomically swapped immutable
|
|
/// snapshot. A rebirth <i>rebuilds the existing table in place</i> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Not a value store.</b> See the remarks on <see cref="AliasTable"/>: running values belong
|
|
/// to <see cref="LastValueCache"/>, keyed by RawPath.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class BirthCache
|
|
{
|
|
private readonly ConcurrentDictionary<SparkplugScope, AliasTable> _scopes = new();
|
|
|
|
/// <summary>How many scopes currently hold a live birth.</summary>
|
|
public int Count => _scopes.Count;
|
|
|
|
/// <summary>A snapshot of the scopes currently holding a live birth.</summary>
|
|
public IReadOnlyList<SparkplugScope> Scopes => [.. _scopes.Keys];
|
|
|
|
/// <summary>
|
|
/// Applies an NBIRTH: rebuilds the edge node's own catalog and <b>invalidates every device</b>
|
|
/// under it, per the Sparkplug spec.
|
|
/// </summary>
|
|
/// <param name="groupId">The Sparkplug group id.</param>
|
|
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
|
/// <param name="birthMetrics">The NBIRTH payload's metrics.</param>
|
|
/// <returns>The node's apply result plus the devices this birth invalidated, with their metrics.</returns>
|
|
public NodeBirthOutcome ApplyNodeBirth(string groupId, string edgeNodeId, IEnumerable<SparkplugMetric> 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);
|
|
}
|
|
|
|
/// <summary>Applies a DBIRTH: rebuilds exactly that device's catalog, wholesale.</summary>
|
|
/// <param name="groupId">The Sparkplug group id.</param>
|
|
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
|
/// <param name="deviceId">The Sparkplug device id.</param>
|
|
/// <param name="birthMetrics">The DBIRTH payload's metrics.</param>
|
|
/// <returns>What the birth installed, and what it refused.</returns>
|
|
public BirthApplyResult ApplyDeviceBirth(
|
|
string groupId,
|
|
string edgeNodeId,
|
|
string deviceId,
|
|
IEnumerable<SparkplugMetric> birthMetrics)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(groupId);
|
|
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
|
|
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
|
ArgumentNullException.ThrowIfNull(birthMetrics);
|
|
|
|
return TableFor(new SparkplugScope(groupId, edgeNodeId, deviceId)).RebuildFromBirth(birthMetrics);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The scope's catalog, or <see langword="null"/> when no live birth has been seen for it —
|
|
/// which is the consumer's data-before-birth signal.
|
|
/// </summary>
|
|
/// <param name="scope">The scope to look up.</param>
|
|
/// <returns>The catalog, or <see langword="null"/>.</returns>
|
|
public AliasTable? Find(SparkplugScope scope) => _scopes.TryGetValue(scope, out var table) ? table : null;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="groupId">The Sparkplug group id.</param>
|
|
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
|
/// <returns>The evicted scopes and their metrics; empty when nothing was live.</returns>
|
|
public IReadOnlyList<SparkplugScopeMetrics> ForgetNode(string groupId, string edgeNodeId)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(groupId);
|
|
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
|
|
|
|
return Evict(scope => scope.IsUnder(groupId, edgeNodeId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a DDEATH: forgets exactly one device, returning its metrics for the STALE fan-out.
|
|
/// The node's own catalog is untouched.
|
|
/// </summary>
|
|
/// <param name="groupId">The Sparkplug group id.</param>
|
|
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
|
/// <param name="deviceId">The Sparkplug device id.</param>
|
|
/// <param name="removed">The evicted scope and its metrics when this returns <see langword="true"/>.</param>
|
|
/// <returns><see langword="true"/> when the device had a live birth to forget.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Forgets every scope — the disconnect/reinitialise reset.</summary>
|
|
public void Clear() => _scopes.Clear();
|
|
|
|
/// <summary>
|
|
/// The scope's catalog, created empty on first use. Rebuilds happen <b>in place</b> on the
|
|
/// returned instance, so a consumer holding it observes the newest birth rather than a detached
|
|
/// older one.
|
|
/// </summary>
|
|
/// <param name="scope">The scope.</param>
|
|
/// <returns>The scope's catalog.</returns>
|
|
private AliasTable TableFor(SparkplugScope scope) => _scopes.GetOrAdd(scope, static _ => new AliasTable());
|
|
|
|
/// <summary>Removes every scope matching <paramref name="predicate"/>, capturing their metrics first.</summary>
|
|
/// <param name="predicate">Which scopes to evict.</param>
|
|
/// <returns>The evicted scopes and the metrics they held.</returns>
|
|
private List<SparkplugScopeMetrics> Evict(Func<SparkplugScope, bool> predicate)
|
|
{
|
|
List<SparkplugScopeMetrics>? 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 ?? [];
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Sparkplug metric-namespace scope: an edge node, or one device under it. The identity every
|
|
/// birth, death and alias resolution is keyed by.
|
|
/// </summary>
|
|
/// <param name="GroupId">The Sparkplug group id.</param>
|
|
/// <param name="EdgeNodeId">The Sparkplug edge-node id.</param>
|
|
/// <param name="DeviceId">The Sparkplug device id, or <see langword="null"/> for the node's own scope.</param>
|
|
public readonly record struct SparkplugScope(string GroupId, string EdgeNodeId, string? DeviceId)
|
|
{
|
|
/// <summary>Whether this is a device scope (a DBIRTH's) rather than an edge node's own.</summary>
|
|
public bool IsDevice => DeviceId is not null;
|
|
|
|
/// <summary>The owning edge node's scope — this instance itself when it is already node-scoped.</summary>
|
|
public SparkplugScope NodeScope => DeviceId is null ? this : new SparkplugScope(GroupId, EdgeNodeId, null);
|
|
|
|
/// <summary>Whether this scope belongs to the given edge node (the node's own scope included).</summary>
|
|
/// <param name="groupId">The Sparkplug group id.</param>
|
|
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
|
/// <returns><see langword="true"/> when both segments match ordinally.</returns>
|
|
public bool IsUnder(string groupId, string edgeNodeId) =>
|
|
string.Equals(GroupId, groupId, StringComparison.Ordinal)
|
|
&& string.Equals(EdgeNodeId, edgeNodeId, StringComparison.Ordinal);
|
|
|
|
/// <inheritdoc/>
|
|
public override string ToString() =>
|
|
DeviceId is null ? $"{GroupId}/{EdgeNodeId}" : $"{GroupId}/{EdgeNodeId}/{DeviceId}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="Scope">The scope that was evicted.</param>
|
|
/// <param name="Metrics">The metrics its last birth declared.</param>
|
|
public readonly record struct SparkplugScopeMetrics(SparkplugScope Scope, IReadOnlyList<SparkplugMetricBinding> Metrics);
|
|
|
|
/// <summary>The result of an NBIRTH: the node's own apply, plus the devices it invalidated.</summary>
|
|
/// <param name="Result">What the node's catalog rebuild installed and refused.</param>
|
|
/// <param name="InvalidatedDevices">
|
|
/// The device scopes this NBIRTH voided, with the metrics they held — the STALE fan-out's input
|
|
/// until each device re-DBIRTHs.
|
|
/// </param>
|
|
public readonly record struct NodeBirthOutcome(
|
|
BirthApplyResult Result,
|
|
IReadOnlyList<SparkplugScopeMetrics> InvalidatedDevices);
|