2b4b3fcbbf
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.
1346 lines
62 KiB
C#
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]);
|
|
}
|
|
}
|
|
}
|