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