Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs
T
Joseph Doherty 2b4b3fcbbf fix(mqtt): session-rebind subscriptions, re-init teardown, timeout clamp
Findings from the per-module review of Driver.Mqtt at b990adae
(code-reviews/Driver.Mqtt/findings.md records all ten; these are the fixed
ones).

Driver.Mqtt-001 (High) — MqttSubscriptionManager._subscribedTopics is a claim
about the broker session it established filters on, and AttachTo never cleared
it, but the manager OUTLIVES a session rebuild (AdoptOptions keeps it whenever
the ingest identity is unchanged). After a driver-host reconnect
(ShutdownAsync + InitializeAsync, the #527 watchdog's retry) or a
ReinitializeAsync that only changed the endpoint/credentials, the host's
re-subscribe resolved an empty pending set, sent no SUBSCRIBE at all, and left
the driver Connected/Healthy on a session carrying ZERO subscriptions — no
error, no Bad status, values frozen until the new session happened to drop.
AttachTo now forgets them, mirroring SparkplugIngestor.AttachTo's reset.

Driver.Mqtt-002 (High) — InitializeCoreAsync published _connection and
_primaryHost over whatever the driver already held, so a second initialize
stranded a connected, self-supervising broker session (plus a second Sparkplug
primary-host claim) that no later dispose could reach. Reachable without any
teardown of ours via DriverInstanceActor.AdoptConfigDuringInit. Now tears down
first, under the gate both callers already hold.

Driver.Mqtt-003 (Medium) — connectTimeoutSeconds is [Range]-annotated but
unvalidated on the runtime path; 0 makes every deadline fire immediately and a
negative value throws ArgumentOutOfRangeException out of ShutdownAsync and
DisposeAsync. Normalized once, at the seam every options instance passes.

Also: ShutdownAsync keeps the live last-message instant (-004), three usings
left behind by #512 removed (-005), stale remarks corrected (-009, -010), and
docs/drivers/Mqtt.md now distinguishes the two kinds of reconnect.

Tests 708 -> 713. All three regression tests were confirmed to fail against
the un-fixed code, with two controls that pass in either state.
2026-08-10 06:59:18 -04:00

1346 lines
62 KiB
C#

using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using MQTTnet;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// The plain-MQTT ingest path: holds the driver's authored <c>RawPath → </c>
/// <see cref="MqttTagDefinition"/> table, establishes one broker subscription per distinct authored
/// topic, and turns each inbound message into <see cref="OnDataChange"/> notifications plus a
/// <see cref="LastValueCache"/> update.
/// </summary>
/// <remarks>
/// <para>
/// <b>The published reference is the RawPath.</b> Every <see cref="DataChangeEventArgs"/> this
/// type raises carries <see cref="MqttTagDefinition.Name"/> — the tag's RawPath — as its
/// <see cref="DataChangeEventArgs.FullReference"/>, never the topic and never the authored
/// <c>TagConfig</c> blob. <c>DriverHostActor</c>'s dual-namespace fan-out is keyed by RawPath;
/// publishing under any other key produces a driver that passes every unit test and delivers
/// nothing at all in production.
/// </para>
/// <para>
/// <b>A message fans out to EVERY matching tag.</b> Several tags routinely share one topic
/// (one JSON document, one JSONPath per signal), so the topic index maps a topic to a
/// <i>list</i> of definitions and delivery walks all of them. Stopping at the first match is
/// the silent-starvation bug this shape exists to prevent, and it is pinned by a test.
/// </para>
/// <para>
/// <b>Nothing here may throw on MQTTnet's dispatcher thread.</b>
/// <see cref="HandleMessage"/> runs on it: extraction failures degrade the offending
/// <i>tag</i> to a Bad status code, a throwing downstream <see cref="OnDataChange"/> subscriber
/// is caught (and the tags behind it in the fan-out are still delivered), and no path does I/O
/// or takes a lock.
/// </para>
/// <para>
/// <b>Unauthored topics are ignored.</b> A wildcard-authored tag, or simply a chatty broker,
/// delivers messages this driver never asked for. They are dropped: MQTT ingest never
/// auto-provisions a tag the operator did not author.
/// </para>
/// <para>
/// <b>Wildcards are matched for real.</b> An authored topic carrying <c>+</c> / <c>#</c> is
/// accepted by the parser (warned at deploy, see <c>MqttTagDefinitionFactory.Inspect</c>), so
/// matching uses MQTTnet's own <see cref="MqttTopicFilterComparer"/> rather than string
/// equality — the same comparer a broker applies. Concrete topics stay a single dictionary
/// lookup; the linear wildcard scan runs only when a wildcard tag actually exists.
/// </para>
/// <para>
/// <b>Sparkplug seam.</b> P1 ingests plain topics only. Sparkplug B (Task 21) decodes a
/// protobuf payload and resolves metrics by the stable
/// <c>(group, node, device, metricName)</c> tuple rather than by topic; it belongs in its own
/// handler alongside this one, feeding the same <see cref="OnDataChange"/> +
/// <see cref="Values"/> sinks. Nothing Sparkplug-shaped is built here.
/// </para>
/// </remarks>
public sealed class MqttSubscriptionManager
{
/// <summary>
/// Default <see cref="MaxPayloadBytes"/> — 1 MiB. Generous for MQTT telemetry (which is
/// kilobytes) while still bounding the dispatcher-thread work any one publisher can impose.
/// </summary>
public const int DefaultMaxPayloadBytes = 1024 * 1024;
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
private const uint StatusGood = 0x00000000u;
/// <summary>The payload could not be decoded, or the JSONPath selected nothing.</summary>
private const uint StatusBadDecodingError = 0x80070000u;
/// <summary>The payload decoded but does not fit the tag's declared <c>dataType</c>.</summary>
private const uint StatusBadTypeMismatch = 0x80740000u;
/// <summary>The subscribed reference is not an authored MQTT tag.</summary>
private const uint StatusBadNodeIdUnknown = 0x80340000u;
/// <summary>The broker refused, or never answered, the SUBSCRIBE for this reference's topic.</summary>
private const uint StatusBadCommunicationError = 0x80050000u;
private readonly int _defaultQos;
private readonly string _driverId;
private readonly ILogger? _logger;
private readonly MqttMode _mode;
/// <summary>RawPath → the handle of the subscription currently covering it.</summary>
private readonly ConcurrentDictionary<string, ISubscriptionHandle> _handleByRawPath = new(StringComparer.Ordinal);
/// <summary>Handle → the references it was created for, so unsubscribe can undo exactly its own.</summary>
private readonly ConcurrentDictionary<ISubscriptionHandle, string[]> _refsByHandle = new();
/// <summary>Topics already SUBSCRIBEd on the live session — the dedupe set and the re-subscribe set.</summary>
private readonly ConcurrentDictionary<string, byte> _subscribedTopics = new(StringComparer.Ordinal);
/// <summary>RawPaths whose extraction failure has already been logged loudly once.</summary>
private readonly ConcurrentDictionary<string, byte> _warnedRefs = new(StringComparer.Ordinal);
private readonly EquipmentTagRefResolver<MqttTagDefinition> _resolver;
private long _handleSeq;
/// <summary>The authored table + its topic index, swapped atomically by <see cref="Register"/>.</summary>
private volatile AuthoredTable _authored = AuthoredTable.Empty;
private IMqttSubscribeTransport? _transport;
/// <summary>Initializes a new instance of the <see cref="MqttSubscriptionManager"/> class.</summary>
/// <param name="options">Driver options — supplies the ingest mode and the default QoS.</param>
/// <param name="driverId">Driver instance id, used only for log correlation.</param>
/// <param name="transport">
/// The SUBSCRIBE seam. <see langword="null"/> records subscriptions without dialling a broker
/// (the shape tests use, and the shape a not-yet-connected driver is in);
/// <see cref="AttachTo"/> supplies the live connection.
/// </param>
/// <param name="logger">Optional logger; never receives credentials or payload bodies.</param>
/// <param name="cache">
/// The last-value store backing <c>IReadable</c>. Defaults to a fresh one owned by this
/// manager and exposed as <see cref="Values"/>.
/// </param>
/// <param name="maxPayloadBytes">
/// Ceiling on an inbound message body; see <see cref="MaxPayloadBytes"/>. Non-positive values
/// fall back to <see cref="DefaultMaxPayloadBytes"/> — "unbounded" is not offered, because the
/// bound exists to protect a shared dispatcher thread.
/// </param>
public MqttSubscriptionManager(
MqttDriverOptions options,
string driverId,
IMqttSubscribeTransport? transport = null,
ILogger? logger = null,
LastValueCache? cache = null,
int maxPayloadBytes = DefaultMaxPayloadBytes)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverId);
MaxPayloadBytes = maxPayloadBytes > 0 ? maxPayloadBytes : DefaultMaxPayloadBytes;
_mode = options.Mode;
_defaultQos = options.Plain?.DefaultQos ?? 1;
_driverId = driverId;
_transport = transport;
_logger = logger;
Values = cache ?? new LastValueCache();
_resolver = new EquipmentTagRefResolver<MqttTagDefinition>(
rawPath => _authored.ByRawPath.TryGetValue(rawPath, out var def) ? def : null);
}
/// <inheritdoc cref="ISubscribable.OnDataChange"/>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// The last observed value per RawPath — the bridge from MQTT's push model to the OPC UA
/// server's polled <c>IReadable.ReadAsync</c> batches.
/// </summary>
public LastValueCache Values { get; }
/// <summary>
/// Ceiling on an inbound message body. A larger message is refused before any decode or parse
/// and degrades its own tags to <c>BadDecodingError</c>.
/// </summary>
/// <remarks>
/// Decode and parse both run synchronously on MQTTnet's shared dispatcher thread, so their cost
/// is paid by <i>every</i> subscription, not just the offending topic. Without a bound, one
/// publisher shipping a multi-megabyte body — or a hostile one shipping a huge nested document
/// — stalls the whole driver's delivery for as long as the parse takes. Sized for telemetry
/// payloads, which are kilobytes; a deployment that legitimately needs more should raise this
/// deliberately rather than run unbounded.
/// <para>
/// <b>Follow-up:</b> this is a constructor knob, not yet an operator-facing one. Promoting
/// it to an <c>MqttDriverOptions</c> key (and pairing it with MQTTnet's
/// <c>WithMaximumPacketSize</c>, so an oversized body is refused at the protocol layer
/// rather than after the client has already buffered it) needs an edit to the
/// <c>.Contracts</c> project that this task was scoped out of.
/// </para>
/// </remarks>
public int MaxPayloadBytes { get; }
/// <summary>
/// Wires this manager onto a live connection: inbound messages route to
/// <see cref="HandleMessage"/> and every reconnect re-establishes the subscriptions through
/// <see cref="OnReconnectedAsync"/>. The connection also becomes this manager's SUBSCRIBE
/// transport.
/// </summary>
/// <remarks>
/// <para>
/// The manager does not own the connection's lifetime and never detaches — the driver disposes
/// both together. Call once per connection, and <b>before</b> the first
/// <see cref="SubscribeAsync"/>: a subscribe issued with no transport attached records its
/// intent (and warns) but sends nothing to a broker, and would only be established if the
/// connection later dropped and recovered.
/// </para>
/// <para>
/// <b>Binding to a session forgets the previous session's established filters</b> — the same
/// reset <see cref="Sparkplug.SparkplugIngestor.AttachTo"/> performs, and for the same reason:
/// this manager <i>outlives</i> a session rebuild. <c>MqttDriver.AdoptOptions</c> only
/// replaces it when the ingest identity changed, so a driver-host reconnect (ShutdownAsync +
/// InitializeAsync, #527's watchdog path) and a <c>ReinitializeAsync</c> that changes only the
/// endpoint/credentials both re-attach <i>this</i> instance to a brand-new broker session.
/// <c>_subscribedTopics</c> is a claim about the session that has just gone: carried across,
/// the host's post-reconnect re-subscribe resolves an empty pending set, issues no SUBSCRIBE
/// at all, and leaves a driver that reports Connected/Healthy and receives nothing until the
/// <i>new</i> session happens to drop — the silent-death outcome every other path in this
/// type is written to prevent.
/// </para>
/// </remarks>
/// <param name="connection">The connection to observe.</param>
public void AttachTo(MqttConnection connection)
{
ArgumentNullException.ThrowIfNull(connection);
// FIRST, before delivery is wired: a new session holds none of the old one's subscriptions.
_subscribedTopics.Clear();
_transport = connection;
connection.MessageReceived += HandleMessage;
connection.Reconnected += OnReconnectedAsync;
}
/// <summary>
/// Replaces the authored tag table with the deploy's raw tags, rebuilding the topic index.
/// Wholesale, not additive: a redeploy that drops a tag must stop feeding it.
/// </summary>
/// <remarks>
/// A <see cref="RawTagEntry"/> whose <c>TagConfig</c> does not map is skipped with a warning,
/// never thrown — one bad tag must not fail the whole driver's initialise. Such a reference
/// then resolves to nothing and surfaces <c>BadNodeIdUnknown</c>, which is the stated contract
/// of <c>MqttTagDefinitionFactory.FromTagConfig</c>.
/// </remarks>
/// <param name="rawTags">The authored raw tags delivered by the deploy artifact.</param>
/// <returns>How many entries mapped to a usable definition.</returns>
public int Register(IEnumerable<RawTagEntry> rawTags)
{
ArgumentNullException.ThrowIfNull(rawTags);
var byRawPath = new Dictionary<string, MqttTagDefinition>(StringComparer.Ordinal);
foreach (var entry in rawTags)
{
if (MqttTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
byRawPath[entry.RawPath] = def;
}
else
{
_logger?.LogWarning(
"MQTT driver '{DriverId}': tag config did not map to a definition; skipping. RawPath={RawPath}",
_driverId,
entry.RawPath);
}
}
var table = AuthoredTable.Build(byRawPath);
_authored = table;
_warnedRefs.Clear();
// Drop state belonging to tags this deploy removed. Without this, a topic whose last tag was
// deleted stays in _subscribedTopics and is dutifully re-SUBSCRIBEd on every future reconnect,
// forever — and its RawPaths keep a handle mapping that can never fire again.
foreach (var topic in _subscribedTopics.Keys)
{
if (!table.ByFilter.ContainsKey(topic))
{
_subscribedTopics.TryRemove(topic, out _);
}
}
foreach (var rawPath in _handleByRawPath.Keys)
{
if (!table.ByRawPath.ContainsKey(rawPath))
{
_handleByRawPath.TryRemove(rawPath, out _);
}
}
return byRawPath.Count;
}
/// <summary>Resolves a RawPath to its authored definition through the shared v3 resolver.</summary>
/// <param name="rawPath">The driver wire reference.</param>
/// <param name="def">The definition, when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the reference is an authored MQTT tag.</returns>
public bool TryResolve(string rawPath, out MqttTagDefinition def) => _resolver.TryResolve(rawPath, out def!);
/// <summary>
/// Subscribes a batch of RawPath references: records them against a new handle and issues one
/// SUBSCRIBE for every distinct authored topic not already established.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the first subscribe.</b> <see cref="MqttConnection.Reconnected"/> deliberately
/// does not fire after the initial <c>ConnectAsync</c>, so the manager must issue the
/// opening SUBSCRIBE itself; <see cref="OnReconnectedAsync"/> only re-establishes it later.
/// </para>
/// <para>
/// <b>Never throws, never hangs.</b> A reference that is not an authored tag is recorded as
/// <c>BadNodeIdUnknown</c>; a SUBACK rejection, transport failure or subscribe deadline
/// degrades exactly the affected references to <c>BadCommunicationError</c>. Either way a
/// handle is returned — the OPC UA server's subscribe call must not fail because one topic
/// is ACL-denied. Recovery for a transient failure comes from the reconnect path;
/// a persistent broker-side refusal is visible as a Bad status on the affected nodes.
/// </para>
/// </remarks>
/// <param name="fullReferences">The RawPath references to subscribe.</param>
/// <param name="publishingInterval">
/// Ignored. MQTT is push-based — the broker's publish rate is the notification rate, and this
/// driver neither polls nor throttles.
/// </param>
/// <param name="cancellationToken">Cancellation for the SUBSCRIBE round-trip.</param>
/// <returns>The handle every resulting <see cref="OnDataChange"/> is attributed to.</returns>
public async Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var defs = new List<MqttTagDefinition>(fullReferences.Count);
var now = DateTime.UtcNow;
foreach (var reference in fullReferences)
{
if (_resolver.TryResolve(reference, out var def))
{
defs.Add(def);
continue;
}
// Not an authored MQTT tag. Say so, rather than leaving it in "waiting for initial data"
// forever — a reference that will never be fed is a different fault from one that has
// simply not been fed yet.
_logger?.LogWarning(
"MQTT driver '{DriverId}': subscribe reference '{RawPath}' is not an authored MQTT tag.",
_driverId,
reference);
Values.Update(reference, new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now));
}
var handle = new MqttSubscriptionHandle(BuildDiagnosticId(defs));
_refsByHandle[handle] = [.. defs.Select(d => d.Name)];
foreach (var def in defs)
{
_handleByRawPath[def.Name] = handle;
}
// One SUBSCRIBE per distinct topic, and only for topics not already live: re-SUBSCRIBEing an
// established filter is legal but pointless, and it re-triggers the retained seed.
var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic)).ToList();
await EstablishAsync(BuildFilters(pending, _defaultQos), cancellationToken).ConfigureAwait(false);
return handle;
}
/// <summary>
/// Stops attributing notifications to <paramref name="handle"/>. References another live
/// subscription also covers keep publishing.
/// </summary>
/// <remarks>
/// The broker-side subscription is deliberately left in place: MQTT UNSUBSCRIBE is per-filter,
/// several handles can share one filter, and an idle filter costs only the traffic a topic
/// already produces. The session teardown drops them all.
/// </remarks>
/// <param name="handle">The handle returned by <see cref="SubscribeAsync"/>.</param>
/// <param name="cancellationToken">Unused; present for the <c>ISubscribable</c> shape.</param>
/// <returns>A completed task.</returns>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not null && _refsByHandle.TryRemove(handle, out var refs))
{
foreach (var rawPath in refs)
{
// Only clear the mapping if it is still OURS — a later overlapping subscription may
// have taken the reference over, and it must keep publishing.
_handleByRawPath.TryRemove(new KeyValuePair<string, ISubscriptionHandle>(rawPath, handle));
}
}
return Task.CompletedTask;
}
/// <summary>
/// Re-establishes every live subscription after a reconnect. Wired to
/// <see cref="MqttConnection.Reconnected"/> by <see cref="AttachTo"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Idempotent</b> — re-SUBSCRIBEing an already-subscribed filter is expected on a
/// persistent session and is harmless; missing one is a healthy-looking client that
/// receives nothing forever.
/// </para>
/// <para>
/// <b>Total failure THROWS, partial rejection does not.</b> This is a deliberate split.
/// <see cref="MqttConnection"/>'s contract is that a throwing <c>Reconnected</c> subscriber
/// tears the fresh session down and retries under backoff — the correct response to "no
/// subscription was re-established", because the alternative is serving a connected,
/// permanently deaf driver. But if the broker granted some filters and refused others
/// (an ACL denying one topic), tearing the session down would retry forever and take
/// <i>every</i> tag dark to punish one; those references degrade to Bad instead and the
/// rest keep flowing. That is the plan's "SUBACK failure → per-ref Bad, not hang", scoped
/// to the case where it does not cost the whole driver.
/// </para>
/// </remarks>
/// <param name="cancellationToken">The connection's lifetime token; cancelled by its dispose.</param>
/// <returns>A task that completes when the re-subscribe has been answered.</returns>
/// <exception cref="Exception">
/// The subscribe failed outright, or every filter was refused — the caller is expected to tear
/// the session down and retry.
/// </exception>
public async Task OnReconnectedAsync(CancellationToken cancellationToken)
{
var authored = _authored;
var subscribed = _subscribedTopics.Keys.ToList();
// ByFilter, NOT ByExactTopic: these keys are subscribed FILTER strings, so a wildcard-authored
// tag's key is its pattern ("f/+/temp"). Reading the concrete-only index here misses every
// wildcard tag, drops it out of the re-subscribe set, and leaves it dark forever behind a
// connection that reports healthy — see the AuthoredTable remarks.
var live = subscribed
.Select(topic => authored.ByFilter.TryGetValue(topic, out var defs) ? defs : [])
.SelectMany(defs => defs)
.ToList();
var filters = BuildFilters(live, _defaultQos);
if (filters.Count == 0)
{
// Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. If any
// topic was live, resolving zero filters for it is an anomaly and must be loud.
if (subscribed.Count > 0)
{
_logger?.LogWarning(
"MQTT driver '{DriverId}': reconnect resolved NO filters for {TopicCount} live topic(s) "
+ "({Topics}); those subscriptions are not being re-established.",
_driverId,
subscribed.Count,
string.Join(", ", subscribed));
}
else
{
_logger?.LogDebug(
"MQTT driver '{DriverId}': reconnect had no live subscriptions to re-establish.",
_driverId);
}
return;
}
var granted = await EstablishAsync(filters, cancellationToken).ConfigureAwait(false);
if (granted == 0)
{
throw new InvalidOperationException(
$"MQTT driver '{_driverId}': no subscription could be re-established after reconnect "
+ $"({filters.Count} filter(s) requested); tearing the session down rather than serving a "
+ "connection that receives nothing.");
}
}
/// <summary>
/// Routes one inbound message to every authored tag whose topic matches, extracts the value,
/// updates <see cref="Values"/>, and raises <see cref="OnDataChange"/> for the tags that are
/// currently subscribed.
/// </summary>
/// <remarks>
/// <para>
/// Runs on MQTTnet's dispatcher thread: no I/O, no locks, and <b>no path throws</b>. A tag
/// whose payload is malformed degrades to a Bad status code and the fan-out continues to
/// the next tag.
/// </para>
/// <para>
/// <b>Bounded work.</b> A body larger than <see cref="MaxPayloadBytes"/> is refused
/// <i>before</i> any decode or parse: the whole point of the dispatcher discipline is that
/// one topic cannot stall delivery for every other subscription, and an unbounded
/// <c>GetString</c> / <c>JsonDocument.Parse</c> over a multi-megabyte body does exactly
/// that. The oversized message degrades its own tags to <c>BadDecodingError</c>.
/// </para>
/// <para>
/// <b>Parsed once.</b> The documented shape is several tags sharing one JSON document with
/// a JSONPath each, so the document is parsed at most once per message and shared across
/// the whole fan-out rather than re-parsed per tag.
/// </para>
/// </remarks>
/// <param name="topic">The concrete topic the message arrived on.</param>
/// <param name="payload">The message body; valid only for the duration of this call.</param>
/// <param name="retained">The message's retain flag — <c>true</c> is the broker's subscribe-time seed.</param>
public void HandleMessage(string topic, ReadOnlySpan<byte> payload, bool retained)
{
if (string.IsNullOrEmpty(topic))
{
return;
}
var authored = _authored;
var now = DateTime.UtcNow;
var oversized = payload.Length > MaxPayloadBytes;
// Zero-alloc until a Json-format tag actually matches; disposed in the finally below.
var json = default(PayloadJson);
try
{
// Concrete topics — the overwhelmingly common case — cost one lookup, so a chatty broker
// publishing thousands of topics this driver does not care about stays cheap.
if (authored.ByExactTopic.TryGetValue(topic, out var exact))
{
foreach (var def in exact)
{
Deliver(def, payload, retained, now, oversized, ref json);
}
}
// A published topic name may not itself contain a wildcard, so a wildcard-authored
// definition can never also be an exact-index hit: no tag is delivered twice.
foreach (var def in authored.Wildcards)
{
if (MqttTopicFilterComparer.Compare(topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
{
Deliver(def, payload, retained, now, oversized, ref json);
}
}
}
finally
{
json.Dispose();
}
}
/// <summary>
/// Collapses a set of definitions into the filters to SUBSCRIBE: one per distinct topic,
/// carrying the <b>strongest</b> QoS any tag on that topic asked for (so no tag silently gets a
/// weaker delivery guarantee than it was authored with), and seeding from the retained message
/// if <b>any</b> tag on the topic wants it (tags that do not are filtered on the retain flag at
/// delivery).
/// </summary>
/// <param name="defs">The definitions to collapse.</param>
/// <param name="defaultQos">The driver-level QoS applied to tags that authored none.</param>
/// <returns>One filter per distinct topic.</returns>
internal static IReadOnlyList<MqttTopicSubscription> BuildFilters(
IEnumerable<MqttTagDefinition> defs,
int defaultQos)
{
var byTopic = new Dictionary<string, MqttTopicSubscription>(StringComparer.Ordinal);
foreach (var def in defs)
{
var qos = def.Qos ?? defaultQos;
byTopic[def.Topic] = byTopic.TryGetValue(def.Topic, out var existing)
? existing with
{
Qos = Math.Max(existing.Qos, qos),
SeedRetained = existing.SeedRetained || def.RetainSeed,
}
: new MqttTopicSubscription(def.Topic, qos, def.RetainSeed);
}
return [.. byTopic.Values];
}
/// <summary>
/// Issues the SUBSCRIBE and applies its verdict: granted topics join the live set, refused ones
/// degrade their references to <c>BadCommunicationError</c>.
/// </summary>
/// <param name="filters">The filters to establish; an empty list is a no-op.</param>
/// <param name="cancellationToken">Cancellation for the round-trip.</param>
/// <returns>How many filters the broker granted.</returns>
private async Task<int> EstablishAsync(
IReadOnlyList<MqttTopicSubscription> filters,
CancellationToken cancellationToken)
{
if (filters.Count == 0)
{
return 0;
}
var transport = _transport;
if (transport is null)
{
// No transport: the intent is recorded so the reconnect path establishes it, but NOTHING
// reaches a broker now. Loud, because outside tests this means the composition order is
// wrong — AttachTo must run before the OPC UA server's first subscribe, or these topics
// stay unestablished until the connection happens to drop and recover.
foreach (var filter in filters)
{
_subscribedTopics[filter.Topic] = 0;
}
_logger?.LogWarning(
"MQTT driver '{DriverId}': {FilterCount} subscription(s) recorded with no connection attached; "
+ "nothing was sent to a broker. Call AttachTo before subscribing.",
_driverId,
filters.Count);
return filters.Count;
}
IReadOnlyList<MqttSubscribeOutcome> outcomes;
try
{
outcomes = await transport.SubscribeAsync(filters, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger?.LogError(
ex,
"MQTT driver '{DriverId}': SUBSCRIBE for {FilterCount} filter(s) failed; the affected tags degrade to Bad.",
_driverId,
filters.Count);
foreach (var filter in filters)
{
DegradeTopic(filter.Topic, ex.GetType().Name);
}
return 0;
}
var granted = 0;
foreach (var outcome in outcomes)
{
if (outcome.Granted)
{
_subscribedTopics[outcome.Topic] = 0;
granted++;
}
else
{
_logger?.LogError(
"MQTT driver '{DriverId}': broker refused SUBSCRIBE '{Topic}' ({Reason}); its tags degrade to Bad.",
_driverId,
outcome.Topic,
outcome.Reason);
DegradeTopic(outcome.Topic, outcome.Reason);
}
}
return granted;
}
/// <summary>Marks every authored reference on <paramref name="topic"/> as unreachable.</summary>
/// <remarks>
/// <paramref name="topic"/> is a subscribed <i>filter</i> string (it came from a requested
/// filter or a SUBACK outcome), so the lookup must go through <c>ByFilter</c> — the
/// concrete-only index misses every wildcard-authored tag and this method would then return
/// having silently degraded nothing, leaving the tag at <c>BadWaitingForInitialData</c> (or
/// worse, its last Good value) after the broker refused its subscription.
/// </remarks>
/// <param name="topic">The topic filter whose subscription could not be established.</param>
/// <param name="reason">The failure reason, for logs.</param>
private void DegradeTopic(string topic, string reason)
{
_subscribedTopics.TryRemove(topic, out _);
if (!_authored.ByFilter.TryGetValue(topic, out var defs))
{
_logger?.LogWarning(
"MQTT driver '{DriverId}': cannot degrade tags for unestablished filter '{Topic}' ({Reason}) — "
+ "no authored tag resolves to it.",
_driverId,
topic,
reason);
return;
}
var now = DateTime.UtcNow;
var snapshot = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
foreach (var def in defs)
{
Values.Update(def.Name, snapshot);
Raise(def.Name, snapshot);
}
_logger?.LogDebug(
"MQTT driver '{DriverId}': degraded {Count} tag(s) on '{Topic}' ({Reason}).",
_driverId,
defs.Length,
topic,
reason);
}
/// <summary>
/// Delivers one message to one tag: honours the retained-seed opt-out, extracts the value,
/// caches it and raises the notification. Never throws.
/// </summary>
/// <param name="def">The authored tag this message matched.</param>
/// <param name="payload">The message body.</param>
/// <param name="retained">The message's retain flag.</param>
/// <param name="nowUtc">One clock read shared by the whole fan-out, so siblings agree.</param>
/// <param name="oversized">Whether the body exceeded <see cref="MaxPayloadBytes"/>.</param>
/// <param name="json">The fan-out's shared JSON parse, created on first use.</param>
private void Deliver(
MqttTagDefinition def,
ReadOnlySpan<byte> payload,
bool retained,
DateTime nowUtc,
bool oversized,
ref PayloadJson json)
{
// MQTT 3.1.1 has no retain-handling option, so the broker always replays its retained message
// on subscribe; a tag that opted out of seeding is filtered here rather than at the broker.
if (retained && !def.RetainSeed)
{
return;
}
DataValueSnapshot snapshot;
try
{
// The cap is enforced before any decode/parse, so an oversized body costs one comparison
// per matched tag rather than a full pass over the payload.
snapshot = oversized
? new DataValueSnapshot(null, StatusBadDecodingError, null, nowUtc)
: Extract(def, payload, nowUtc, ref json);
}
catch (Exception ex)
{
// Defence in depth: Extract is written not to throw, but this runs on the library's
// dispatcher and an escape here would stall delivery for every other subscription.
snapshot = new DataValueSnapshot(null, StatusBadDecodingError, null, nowUtc);
_logger?.LogDebug(ex, "MQTT driver '{DriverId}': extraction threw for '{RawPath}'.", _driverId, def.Name);
}
if (snapshot.StatusCode != StatusGood && _warnedRefs.TryAdd(def.Name, 0))
{
// Loud once per reference per deploy, quiet thereafter: a mis-authored jsonPath on a 10 Hz
// topic must be diagnosable without drowning the log.
_logger?.LogWarning(
"MQTT driver '{DriverId}': tag '{RawPath}' (topic '{Topic}', {Format}, jsonPath '{JsonPath}', "
+ "{DataType}) could not be extracted from its payload; status 0x{Status:X8}.",
_driverId,
def.Name,
def.Topic,
def.PayloadFormat,
def.JsonPath,
def.DataType,
snapshot.StatusCode);
}
Values.Update(def.Name, snapshot);
Raise(def.Name, snapshot);
}
/// <summary>
/// Raises <see cref="OnDataChange"/> for a reference, if a live subscription covers it.
/// A throwing subscriber is contained so it cannot starve the rest of the fan-out — or, worse,
/// escape onto MQTTnet's dispatcher thread.
/// </summary>
/// <param name="rawPath">The RawPath — the reference the notification is published under.</param>
/// <param name="snapshot">The value/quality/timestamps to publish.</param>
private void Raise(string rawPath, DataValueSnapshot snapshot)
{
if (!_handleByRawPath.TryGetValue(rawPath, out var handle))
{
// Authored but not subscribed: the value is cached (so a read answers) and nothing is
// published — there is no subscription to attribute it to.
return;
}
try
{
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, rawPath, snapshot));
}
catch (Exception ex)
{
_logger?.LogError(
ex,
"MQTT driver '{DriverId}': a data-change subscriber threw for '{RawPath}'.",
_driverId,
rawPath);
}
}
/// <summary>
/// Extracts the tag's value from a payload according to its <see cref="MqttPayloadFormat"/>.
/// Never throws: every failure becomes a Bad status code on the returned snapshot.
/// </summary>
/// <param name="def">The authored tag definition.</param>
/// <param name="payload">The message body.</param>
/// <param name="nowUtc">Timestamp for the snapshot — MQTT carries no source timestamp of its own.</param>
/// <param name="json">The fan-out's shared JSON parse, created on first use by a Json-format tag.</param>
/// <returns>The extracted snapshot.</returns>
internal static DataValueSnapshot Extract(
MqttTagDefinition def,
ReadOnlySpan<byte> payload,
DateTime nowUtc,
ref PayloadJson json)
{
switch (def.PayloadFormat)
{
case MqttPayloadFormat.Json:
return ExtractJson(def, payload, nowUtc, ref json);
case MqttPayloadFormat.Raw:
// "Bytes as-is": the payload's UTF-8 text, published verbatim. dataType is deliberately
// NOT applied — Scalar is the format that coerces. Authoring Raw with a numeric
// dataType is a mis-authoring, and one the deploy-time inspection should grow a
// warning for.
return TryDecodeUtf8(payload, out var raw)
? new DataValueSnapshot(raw, StatusGood, nowUtc, nowUtc)
: Bad(StatusBadDecodingError, nowUtc);
case MqttPayloadFormat.Scalar:
if (!TryDecodeUtf8(payload, out var text))
{
return Bad(StatusBadDecodingError, nowUtc);
}
return TryCoerceText(text, def.DataType, out var scalar)
? new DataValueSnapshot(scalar, StatusGood, nowUtc, nowUtc)
: Bad(StatusBadTypeMismatch, nowUtc);
default:
return Bad(StatusBadDecodingError, nowUtc);
}
}
private static DataValueSnapshot Bad(uint status, DateTime nowUtc) => new(null, status, null, nowUtc);
/// <summary>
/// Selects the tag's JSONPath out of the fan-out's <b>shared</b> parse and coerces it to the
/// tag's data type. The document is parsed at most once per message however many tags share the
/// topic — the documented "one JSON document, one JSONPath per signal" shape would otherwise
/// re-parse it once per tag, on the dispatcher thread.
/// </summary>
/// <param name="def">The authored tag definition.</param>
/// <param name="payload">The message body.</param>
/// <param name="nowUtc">Timestamp for the snapshot.</param>
/// <param name="json">The shared parse; parses on first call and caches the outcome.</param>
/// <returns>The extracted snapshot.</returns>
private static DataValueSnapshot ExtractJson(
MqttTagDefinition def,
ReadOnlySpan<byte> payload,
DateTime nowUtc,
ref PayloadJson json)
{
if (!json.TryGetRoot(payload, out var root))
{
return Bad(StatusBadDecodingError, nowUtc);
}
if (!TryEvaluateJsonPath(root, def.JsonPath, out var selected))
{
return Bad(StatusBadDecodingError, nowUtc);
}
return TryCoerce(selected, def.DataType, out var value)
? new DataValueSnapshot(value, StatusGood, nowUtc, nowUtc)
: Bad(StatusBadTypeMismatch, nowUtc);
}
/// <summary>
/// One message's JSON parse, shared across the whole fan-out. A mutable struct passed by
/// <c>ref</c> so the common case — a message no Json-format tag matched — allocates nothing at
/// all, while a message several Json tags share is parsed exactly once.
/// </summary>
internal struct PayloadJson : IDisposable
{
private JsonDocument? _doc;
private bool _attempted;
private bool _parsed;
/// <summary>
/// The parsed document root, parsing on first call. A malformed payload is remembered as a
/// failure so the fan-out's remaining tags do not each retry the same doomed parse.
/// </summary>
/// <param name="payload">The message body; only read on the first call.</param>
/// <param name="root">The document root when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the payload is valid JSON.</returns>
public bool TryGetRoot(ReadOnlySpan<byte> payload, out JsonElement root)
{
if (!_attempted)
{
_attempted = true;
try
{
var reader = new Utf8JsonReader(payload);
_parsed = JsonDocument.TryParseValue(ref reader, out _doc);
}
catch (JsonException)
{
_parsed = false;
}
}
root = _parsed ? _doc!.RootElement : default;
return _parsed;
}
/// <inheritdoc/>
public void Dispose()
{
_doc?.Dispose();
_doc = null;
}
}
/// <summary>
/// Evaluates the <b>supported subset</b> of JSONPath against a document: the root (<c>$</c>),
/// dotted member access (<c>$.a.b</c>), bracketed member access (<c>$['a']</c> / <c>$["a"]</c>)
/// and non-negative array indexing (<c>$.a[0]</c>).
/// </summary>
/// <remarks>
/// Filters, slices, wildcards and recursive descent (<c>$..x</c>) are deliberately <b>not</b>
/// supported: this repo pins no JSONPath package, adding one for an expression form that
/// selects a <i>set</i> — while a tag needs exactly one scalar — is the wrong trade. An
/// unsupported expression is reported as a miss, so the tag surfaces
/// <c>BadDecodingError</c> plus the one-shot warning rather than a wrong value.
/// </remarks>
/// <param name="root">The parsed document root.</param>
/// <param name="path">The authored JSONPath.</param>
/// <param name="value">The selected element when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the path is supported and selects an element.</returns>
internal static bool TryEvaluateJsonPath(JsonElement root, string path, out JsonElement value)
{
value = root;
if (string.IsNullOrEmpty(path) || path[0] != '$')
{
return false;
}
var i = 1;
while (i < path.Length)
{
if (path[i] == '.')
{
i++;
if (i >= path.Length || path[i] == '.')
{
return false; // trailing '.', or recursive descent '..'
}
var start = i;
while (i < path.Length && path[i] != '.' && path[i] != '[')
{
i++;
}
var name = path[start..i];
if (name.Length == 0 || name == "*")
{
return false;
}
if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(name, out var member))
{
return false;
}
value = member;
}
else if (path[i] == '[')
{
var close = path.IndexOf(']', i);
if (close < 0)
{
return false;
}
var inner = path[(i + 1)..close].Trim();
i = close + 1;
if (inner.Length >= 2
&& ((inner[0] == '\'' && inner[^1] == '\'') || (inner[0] == '"' && inner[^1] == '"')))
{
var name = inner[1..^1];
if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(name, out var member))
{
return false;
}
value = member;
}
else if (int.TryParse(inner, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)
&& index >= 0)
{
if (value.ValueKind != JsonValueKind.Array || index >= value.GetArrayLength())
{
return false;
}
value = value[index];
}
else
{
return false; // slice, filter or wildcard — outside the supported subset
}
}
else
{
return false;
}
}
return true;
}
/// <summary>Coerces a selected JSON element to the tag's declared data type.</summary>
/// <param name="element">The selected element.</param>
/// <param name="type">The tag's declared type.</param>
/// <param name="value">The coerced value when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the element fits the declared type.</returns>
internal static bool TryCoerce(JsonElement element, DriverDataType type, out object? value)
{
value = null;
if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return false;
}
if (type is DriverDataType.String or DriverDataType.Reference)
{
// A String tag pointed at an object/array gets that subtree's JSON text — the authored
// intent ("give me this fragment") rather than a refusal.
value = element.ValueKind == JsonValueKind.String ? element.GetString() : element.GetRawText();
return true;
}
// A JSON string holding a number ({"v":"21.5"}) is common in the wild; parse it as text rather
// than refusing a payload the operator plainly meant.
if (element.ValueKind == JsonValueKind.String)
{
return TryCoerceText(element.GetString() ?? string.Empty, type, out value);
}
switch (type)
{
case DriverDataType.Boolean:
if (element.ValueKind == JsonValueKind.True) { value = true; return true; }
if (element.ValueKind == JsonValueKind.False) { value = false; return true; }
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var b)) { value = b != 0d; return true; }
return false;
case DriverDataType.Int16:
case DriverDataType.Int32:
case DriverDataType.Int64:
case DriverDataType.UInt16:
case DriverDataType.UInt32:
case DriverDataType.UInt64:
if (element.ValueKind != JsonValueKind.Number)
{
return false;
}
// Fast path: the JSON number is already written as an integer.
if (TryExactInteger(element, type, out value))
{
return true;
}
// Fallback: an integral-valued REAL. JS/Python edge gateways routinely serialize an
// integer as 5.0 (or 5E2), and System.Text.Json's TryGetInt32/64 are syntactic — they
// refuse it. Refusing here would mean an integer tag fed by such a gateway silently
// never receives data, so an exactly-integral real is accepted; a genuinely fractional
// value (5.5) is still refused rather than rounded.
return TryIntegralReal(element.GetRawText(), type, out value);
case DriverDataType.Float32:
if (element.ValueKind == JsonValueKind.Number && element.TryGetSingle(out var f32)) { value = f32; return true; }
return false;
case DriverDataType.Float64:
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var f64)) { value = f64; return true; }
return false;
case DriverDataType.DateTime:
// Only ISO-8601 text is accepted; a bare number is ambiguous (epoch seconds? ms?) and
// guessing would silently place samples decades apart.
return false;
default:
return false;
}
}
/// <summary>Parses a textual payload (or JSON string) into the tag's declared data type.</summary>
/// <param name="text">The decoded text.</param>
/// <param name="type">The tag's declared type.</param>
/// <param name="value">The parsed value when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the text parses as the declared type.</returns>
internal static bool TryCoerceText(string text, DriverDataType type, out object? value)
{
value = null;
var inv = CultureInfo.InvariantCulture;
var trimmed = text.Trim();
switch (type)
{
case DriverDataType.String:
case DriverDataType.Reference:
// Untrimmed: for a String tag the payload IS the value, whitespace included.
value = text;
return true;
case DriverDataType.Boolean:
if (bool.TryParse(trimmed, out var b)) { value = b; return true; }
if (long.TryParse(trimmed, NumberStyles.Integer, inv, out var bi)) { value = bi != 0L; return true; }
return false;
case DriverDataType.Int16:
case DriverDataType.Int32:
case DriverDataType.Int64:
case DriverDataType.UInt16:
case DriverDataType.UInt32:
case DriverDataType.UInt64:
// Same two-step as the JSON path: exact integer text first, then an integral-valued
// real ("5.0"), which a Scalar-format edge gateway emits just as readily.
if (TryExactIntegerText(trimmed, type, out value))
{
return true;
}
return TryIntegralReal(trimmed, type, out value);
case DriverDataType.Float32:
if (float.TryParse(trimmed, NumberStyles.Float, inv, out var f32)) { value = f32; return true; }
return false;
case DriverDataType.Float64:
if (double.TryParse(trimmed, NumberStyles.Float, inv, out var f64)) { value = f64; return true; }
return false;
case DriverDataType.DateTime:
if (DateTime.TryParse(
trimmed,
inv,
DateTimeStyles.RoundtripKind | DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out var dt))
{
value = dt;
return true;
}
return false;
default:
return false;
}
}
/// <summary>Reads a JSON number that is written as an exact integer of the requested type.</summary>
/// <param name="element">The JSON number.</param>
/// <param name="type">The target integer type.</param>
/// <param name="value">The boxed value when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the number is an exact integer in range.</returns>
private static bool TryExactInteger(JsonElement element, DriverDataType type, out object? value)
{
value = type switch
{
DriverDataType.Int16 => element.TryGetInt16(out var v) ? v : null,
DriverDataType.Int32 => element.TryGetInt32(out var v) ? v : null,
DriverDataType.Int64 => element.TryGetInt64(out var v) ? v : null,
DriverDataType.UInt16 => element.TryGetUInt16(out var v) ? v : null,
DriverDataType.UInt32 => element.TryGetUInt32(out var v) ? v : null,
DriverDataType.UInt64 => element.TryGetUInt64(out var v) ? v : (object?)null,
_ => null,
};
return value is not null;
}
/// <summary>Parses text written as an exact integer of the requested type.</summary>
/// <param name="text">The trimmed text.</param>
/// <param name="type">The target integer type.</param>
/// <param name="value">The boxed value when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the text is an exact integer in range.</returns>
private static bool TryExactIntegerText(string text, DriverDataType type, out object? value)
{
var inv = CultureInfo.InvariantCulture;
value = type switch
{
DriverDataType.Int16 => short.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null,
DriverDataType.Int32 => int.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null,
DriverDataType.Int64 => long.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null,
DriverDataType.UInt16 => ushort.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null,
DriverDataType.UInt32 => uint.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : null,
DriverDataType.UInt64 => ulong.TryParse(text, NumberStyles.Integer, inv, out var v) ? v : (object?)null,
_ => null,
};
return value is not null;
}
/// <summary>
/// Accepts a real that is <b>exactly</b> integral (<c>5.0</c>, <c>5E2</c>) as an integer of the
/// requested type. A fractional value is refused, never rounded — silently turning 5.5 into 5
/// or 6 would be a wrong value, which is worse than no value.
/// </summary>
/// <remarks>
/// Parsed as <see cref="decimal"/> rather than <see cref="double"/> so the integrality test and
/// the conversion are exact across the whole Int64/UInt64 range, where double loses precision
/// above 2^53. Out-of-range values overflow the conversion and are refused.
/// </remarks>
/// <param name="text">The number's text (JSON raw text, or a Scalar payload).</param>
/// <param name="type">The target integer type.</param>
/// <param name="value">The boxed value when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the text is an integral real in range.</returns>
private static bool TryIntegralReal(string text, DriverDataType type, out object? value)
{
value = null;
if (!decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)
|| d != decimal.Truncate(d))
{
return false;
}
try
{
value = type switch
{
DriverDataType.Int16 => (short)d,
DriverDataType.Int32 => (int)d,
DriverDataType.Int64 => (long)d,
DriverDataType.UInt16 => (ushort)d,
DriverDataType.UInt32 => (uint)d,
DriverDataType.UInt64 => (ulong)d,
_ => null,
};
}
catch (OverflowException)
{
// decimal → integer conversions throw rather than wrap; out of range is a refusal.
value = null;
}
return value is not null;
}
/// <summary>
/// Decodes a payload as UTF-8, refusing invalid byte sequences rather than rendering a genuinely
/// binary body as a field of replacement characters (the same choice the browse session makes).
/// </summary>
/// <param name="payload">The message body.</param>
/// <param name="text">The decoded text when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the payload is valid UTF-8.</returns>
private static bool TryDecodeUtf8(ReadOnlySpan<byte> payload, out string text)
{
try
{
text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)
.GetString(payload);
return true;
}
catch (DecoderFallbackException)
{
text = string.Empty;
return false;
}
}
/// <summary>
/// Builds the handle's diagnostic id: the ingest mode plus the topics it covers, bounded so a
/// thousand-tag subscription does not produce a thousand-topic string in every log line.
/// </summary>
/// <param name="defs">The definitions the subscription covers.</param>
/// <returns>The diagnostic id.</returns>
private string BuildDiagnosticId(IReadOnlyCollection<MqttTagDefinition> defs)
{
const int MaxNamedTopics = 5;
var id = Interlocked.Increment(ref _handleSeq);
var topics = defs.Select(d => d.Topic).Distinct(StringComparer.Ordinal).ToList();
var named = string.Join(',', topics.Take(MaxNamedTopics));
var overflow = topics.Count > MaxNamedTopics ? $",+{topics.Count - MaxNamedTopics}" : string.Empty;
return $"mqtt:{_mode}:{id}:[{named}{overflow}]";
}
/// <summary>Opaque subscription identity; the diagnostic id names the mode and its topics.</summary>
/// <param name="DiagnosticId">The human-readable identity surfaced for diagnostics.</param>
private sealed record MqttSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
/// <summary>
/// The authored tag table and its derived indexes, published as one immutable snapshot so the
/// dispatcher thread reads a consistent view while a redeploy rebuilds it.
/// </summary>
/// <remarks>
/// <b>Two topic indexes, and the difference is load-bearing.</b> Delivery matches an
/// <i>incoming published topic</i>, which never carries a wildcard, so it uses
/// <see cref="ByExactTopic"/> (concrete only) plus a comparer scan of <see cref="Wildcards"/>.
/// Every path that instead reconstructs state from a <i>subscribed filter string</i> —
/// reconnect re-subscribe and per-topic degradation — must use <see cref="ByFilter"/>, which is
/// keyed by the tag's authored topic <b>including wildcard patterns</b>, because that is what
/// lands in <c>_subscribedTopics</c> and in a SUBACK outcome. Looking a wildcard filter up in
/// the concrete index always misses, and a miss on those paths is silent: the tag simply stops
/// updating behind a healthy-looking connection, keeping its last Good value forever.
/// </remarks>
/// <param name="ByRawPath">RawPath → definition; the v3 resolver's backing table.</param>
/// <param name="ByFilter">
/// Authored topic <i>filter</i> string → every definition bound to it, wildcard patterns
/// included. Keyed the same way <c>_subscribedTopics</c> and SUBACK outcomes are.
/// </param>
/// <param name="ByExactTopic">Concrete topic → its definitions; the delivery fast path.</param>
/// <param name="Wildcards">Definitions whose authored topic carries an MQTT wildcard.</param>
private sealed record AuthoredTable(
FrozenDictionary<string, MqttTagDefinition> ByRawPath,
FrozenDictionary<string, MqttTagDefinition[]> ByFilter,
FrozenDictionary<string, MqttTagDefinition[]> ByExactTopic,
MqttTagDefinition[] Wildcards)
{
public static readonly AuthoredTable Empty =
Build(new Dictionary<string, MqttTagDefinition>(StringComparer.Ordinal));
/// <summary>True when a topic string carries an MQTT wildcard and is therefore a filter, not a name.</summary>
/// <param name="topic">The authored topic.</param>
/// <returns><see langword="true"/> when it contains <c>+</c> or <c>#</c>.</returns>
public static bool IsWildcard(string topic) =>
topic.Contains(MqttTopicFilterComparer.SingleLevelWildcard, StringComparison.Ordinal)
|| topic.Contains(MqttTopicFilterComparer.MultiLevelWildcard, StringComparison.Ordinal);
/// <summary>Builds the snapshot: every def into <c>ByFilter</c>, concrete/wildcard split for delivery.</summary>
/// <param name="byRawPath">The mapped definitions, keyed by RawPath.</param>
/// <returns>The immutable snapshot.</returns>
public static AuthoredTable Build(IReadOnlyDictionary<string, MqttTagDefinition> byRawPath)
{
var byFilter = new Dictionary<string, List<MqttTagDefinition>>(StringComparer.Ordinal);
var concrete = new Dictionary<string, List<MqttTagDefinition>>(StringComparer.Ordinal);
var wildcards = new List<MqttTagDefinition>();
foreach (var def in byRawPath.Values)
{
// EVERY def lands here, wildcard or not — this is the index the reconnect and degrade
// paths read, and omitting wildcards from it is exactly the C1 silent-death defect.
if (!byFilter.TryGetValue(def.Topic, out var all))
{
byFilter[def.Topic] = all = [];
}
all.Add(def);
if (IsWildcard(def.Topic))
{
wildcards.Add(def);
continue;
}
if (!concrete.TryGetValue(def.Topic, out var list))
{
concrete[def.Topic] = list = [];
}
list.Add(def);
}
return new AuthoredTable(
byRawPath.ToFrozenDictionary(StringComparer.Ordinal),
byFilter.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal),
concrete.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal),
[.. wildcards]);
}
}
}