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