perf(mqtt): wildcard topic trie and a shared strict UTF-8 decoder (R06:M-6, M-7)
Wildcard-authored tags were matched by one MqttTopicFilterComparer call per tag per inbound message on the dispatcher thread. They now live in an allocation-free level trie whose candidates are confirmed by the same comparer (semantics unchanged; pinned differentially against the comparer). TryDecodeUtf8 reuses one static throw-on-invalid UTF8Encoding instead of allocating one per Raw/Scalar delivery. Refs #744 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -48,7 +48,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
/// 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.
|
||||
/// lookup; wildcard filters live in a level trie (<c>TopicFilterTrie</c>) whose candidates the comparer
|
||||
/// confirms, so a thousand wildcard tags cost a walk over the topic's levels, not a thousand compares.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sparkplug seam.</b> P1 ingests plain topics only. Sparkplug B decodes a
|
||||
@@ -74,6 +75,13 @@ public sealed class MqttSubscriptionManager
|
||||
/// </summary>
|
||||
public const int DefaultQos = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Throw-on-invalid UTF-8, shared (R06:M-7): it is stateless and thread-safe, and allocating one per
|
||||
/// Raw/Scalar delivery was per-message garbage on the dispatcher thread. Same choice
|
||||
/// <c>MqttBrowseSession</c> makes.
|
||||
/// </summary>
|
||||
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
|
||||
private const uint StatusGood = 0x00000000u;
|
||||
|
||||
@@ -644,13 +652,14 @@ public sealed class MqttSubscriptionManager
|
||||
}
|
||||
|
||||
// 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)
|
||||
// definition can never also be an exact-index hit: no tag is delivered twice. The trie walks
|
||||
// the topic's levels (R06:M-6 — this used to be one comparer call per wildcard tag, per
|
||||
// message, on the shared dispatcher thread) and every candidate it yields is confirmed by
|
||||
// MQTTnet's own comparer inside the visitor, so the semantics are the broker's.
|
||||
if (authored.WildcardTrie.Count > 0)
|
||||
{
|
||||
if (MqttTopicFilterComparer.Compare(topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
|
||||
{
|
||||
Deliver(def, payload, retained, now, oversized, ref json);
|
||||
}
|
||||
var visitor = new WildcardDelivery(this, topic, payload, retained, now, oversized, ref json);
|
||||
authored.WildcardTrie.Match(topic, ref visitor);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -1447,8 +1456,7 @@ public sealed class MqttSubscriptionManager
|
||||
{
|
||||
try
|
||||
{
|
||||
text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)
|
||||
.GetString(payload);
|
||||
text = Utf8Strict.GetString(payload);
|
||||
return true;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
@@ -1476,6 +1484,47 @@ public sealed class MqttSubscriptionManager
|
||||
return $"mqtt:{_mode}:{id}:[{named}{overflow}]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers each wildcard-trie candidate whose filter the MQTTnet comparer confirms. A <c>ref struct</c>
|
||||
/// so the payload span and the fan-out's shared JSON parse ride along without a closure or a list.
|
||||
/// </summary>
|
||||
private ref struct WildcardDelivery : ITopicFilterVisitor<MqttTagDefinition>
|
||||
{
|
||||
private readonly MqttSubscriptionManager _manager;
|
||||
private readonly string _topic;
|
||||
private readonly ReadOnlySpan<byte> _payload;
|
||||
private readonly bool _retained;
|
||||
private readonly DateTime _now;
|
||||
private readonly bool _oversized;
|
||||
private ref PayloadJson _json;
|
||||
|
||||
public WildcardDelivery(
|
||||
MqttSubscriptionManager manager,
|
||||
string topic,
|
||||
ReadOnlySpan<byte> payload,
|
||||
bool retained,
|
||||
DateTime now,
|
||||
bool oversized,
|
||||
ref PayloadJson json)
|
||||
{
|
||||
_manager = manager;
|
||||
_topic = topic;
|
||||
_payload = payload;
|
||||
_retained = retained;
|
||||
_now = now;
|
||||
_oversized = oversized;
|
||||
_json = ref json;
|
||||
}
|
||||
|
||||
public void Visit(MqttTagDefinition def)
|
||||
{
|
||||
if (MqttTopicFilterComparer.Compare(_topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
|
||||
{
|
||||
_manager.Deliver(def, _payload, _retained, _now, _oversized, ref _json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
@@ -1487,7 +1536,8 @@ public sealed class MqttSubscriptionManager
|
||||
/// <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"/>.
|
||||
/// <see cref="ByExactTopic"/> (concrete only) plus a <see cref="WildcardTrie"/> walk whose candidates the
|
||||
/// comparer confirms.
|
||||
/// 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
|
||||
@@ -1501,12 +1551,12 @@ public sealed class MqttSubscriptionManager
|
||||
/// 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>
|
||||
/// <param name="WildcardTrie">Definitions whose authored topic carries an MQTT wildcard, by filter level.</param>
|
||||
private sealed record AuthoredTable(
|
||||
FrozenDictionary<string, MqttTagDefinition> ByRawPath,
|
||||
FrozenDictionary<string, MqttTagDefinition[]> ByFilter,
|
||||
FrozenDictionary<string, MqttTagDefinition[]> ByExactTopic,
|
||||
MqttTagDefinition[] Wildcards)
|
||||
TopicFilterTrie<MqttTagDefinition> WildcardTrie)
|
||||
{
|
||||
public static readonly AuthoredTable Empty =
|
||||
Build(new Dictionary<string, MqttTagDefinition>(StringComparer.Ordinal));
|
||||
@@ -1556,7 +1606,7 @@ public sealed class MqttSubscriptionManager
|
||||
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]);
|
||||
TopicFilterTrie<MqttTagDefinition>.Build(wildcards.Select(d => (d.Topic, d))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>Receives each candidate a <see cref="TopicFilterTrie{T}"/> match produces.</summary>
|
||||
/// <typeparam name="T">The value type stored against each filter.</typeparam>
|
||||
internal interface ITopicFilterVisitor<in T>
|
||||
{
|
||||
/// <summary>Called once per value whose filter may match the topic.</summary>
|
||||
/// <param name="value">The candidate.</param>
|
||||
void Visit(T value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An immutable trie over MQTT topic FILTERS (<c>+</c> / <c>#</c> wildcards), so matching one published
|
||||
/// topic costs a walk over its levels instead of a comparer call per wildcard-authored tag (R06:M-6:
|
||||
/// the linear scan ran on MQTTnet's shared dispatcher thread for every inbound message).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A candidate generator, not the authority.</b> The trie implements the plain MQTT 3.1.1 §4.7
|
||||
/// level rules — <c>+</c> is exactly one level (an empty one included), <c>#</c> is the parent level
|
||||
/// and everything under it — and deliberately nothing subtler (the <c>$</c>-topic exclusion, shared
|
||||
/// subscriptions). It can therefore only ever return a SUPERSET of the true matches, and every
|
||||
/// caller confirms each candidate with MQTTnet's own <c>MqttTopicFilterComparer</c>, the comparer a
|
||||
/// broker applies. Semantics stay byte-identical to the scan it replaces; only the work changes.
|
||||
/// A differential test pins both halves against that comparer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Allocation-free per match.</b> Levels are walked as spans and looked up through the
|
||||
/// dictionary's span alternate lookup; candidates are pushed to a caller-supplied visitor (a
|
||||
/// <c>ref struct</c> in production) rather than collected into a list.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="T">The value type stored against each filter.</typeparam>
|
||||
internal sealed class TopicFilterTrie<T>
|
||||
{
|
||||
private readonly Node _root;
|
||||
|
||||
private TopicFilterTrie(Node root, int count)
|
||||
{
|
||||
_root = root;
|
||||
Count = count;
|
||||
}
|
||||
|
||||
/// <summary>An empty trie.</summary>
|
||||
public static TopicFilterTrie<T> Empty { get; } = Build([]);
|
||||
|
||||
/// <summary>How many values were inserted.</summary>
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>Builds a trie from <c>(filter, value)</c> pairs.</summary>
|
||||
/// <param name="entries">The filters and their values.</param>
|
||||
/// <returns>The immutable trie.</returns>
|
||||
public static TopicFilterTrie<T> Build(IEnumerable<(string Filter, T Value)> entries)
|
||||
{
|
||||
var root = new Builder();
|
||||
var count = 0;
|
||||
foreach (var (filter, value) in entries)
|
||||
{
|
||||
var node = root;
|
||||
var levels = filter.Split('/');
|
||||
var terminated = false;
|
||||
for (var i = 0; i < levels.Length; i++)
|
||||
{
|
||||
var level = levels[i];
|
||||
if (level == "#")
|
||||
{
|
||||
// '#' must be the last level; anything after it is a malformed filter the comparer
|
||||
// will refuse anyway, so treating '#' as terminal here can only over-generate.
|
||||
node.Hash.Add(value);
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
node = level == "+"
|
||||
? node.Plus ??= new Builder()
|
||||
: node.Children.TryGetValue(level, out var child) ? child : node.Children[level] = new Builder();
|
||||
}
|
||||
|
||||
if (!terminated) node.Exact.Add(value);
|
||||
count++;
|
||||
}
|
||||
|
||||
return new TopicFilterTrie<T>(root.Freeze(), count);
|
||||
}
|
||||
|
||||
/// <summary>Pushes every value whose filter may match <paramref name="topic"/> to <paramref name="visitor"/>.</summary>
|
||||
/// <typeparam name="TVisitor">The visitor; a <c>ref struct</c> is allowed.</typeparam>
|
||||
/// <param name="topic">A concrete published topic (never a filter).</param>
|
||||
/// <param name="visitor">Receives each candidate — a superset of the true matches; confirm each one.</param>
|
||||
public void Match<TVisitor>(string topic, ref TVisitor visitor)
|
||||
where TVisitor : ITopicFilterVisitor<T>, allows ref struct
|
||||
{
|
||||
if (Count == 0 || topic is null) return;
|
||||
Walk(_root, topic.AsSpan(), exhausted: false, ref visitor);
|
||||
}
|
||||
|
||||
private static void Walk<TVisitor>(Node node, ReadOnlySpan<char> rest, bool exhausted, ref TVisitor visitor)
|
||||
where TVisitor : ITopicFilterVisitor<T>, allows ref struct
|
||||
{
|
||||
// '#' here covers this level's parent-match AND every deeper level.
|
||||
foreach (var value in node.Hash) visitor.Visit(value);
|
||||
|
||||
if (exhausted)
|
||||
{
|
||||
foreach (var value in node.Exact) visitor.Visit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
var slash = rest.IndexOf('/');
|
||||
var level = slash < 0 ? rest : rest[..slash];
|
||||
var next = slash < 0 ? ReadOnlySpan<char>.Empty : rest[(slash + 1)..];
|
||||
var nextExhausted = slash < 0;
|
||||
|
||||
if (node.Children.Count > 0
|
||||
&& node.Children.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(level, out var child))
|
||||
{
|
||||
Walk(child, next, nextExhausted, ref visitor);
|
||||
}
|
||||
|
||||
if (node.Plus is { } plus) Walk(plus, next, nextExhausted, ref visitor);
|
||||
}
|
||||
|
||||
private sealed class Node(FrozenDictionary<string, Node> children, Node? plus, T[] exact, T[] hash)
|
||||
{
|
||||
public FrozenDictionary<string, Node> Children { get; } = children;
|
||||
|
||||
public Node? Plus { get; } = plus;
|
||||
|
||||
public T[] Exact { get; } = exact;
|
||||
|
||||
public T[] Hash { get; } = hash;
|
||||
}
|
||||
|
||||
private sealed class Builder
|
||||
{
|
||||
public Dictionary<string, Builder> Children { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Builder? Plus { get; set; }
|
||||
|
||||
public List<T> Exact { get; } = [];
|
||||
|
||||
public List<T> Hash { get; } = [];
|
||||
|
||||
public Node Freeze() => new(
|
||||
Children.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.Freeze(), StringComparer.Ordinal),
|
||||
Plus?.Freeze(),
|
||||
[.. Exact],
|
||||
[.. Hash]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using MQTTnet;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-6.</b> The wildcard trie replaced a linear <c>MqttTopicFilterComparer</c> scan on the
|
||||
/// dispatcher thread. Its contract is "a superset of the true matches, confirmed by the comparer", so it
|
||||
/// is pinned differentially against that comparer — the oracle a broker uses — over a seeded random
|
||||
/// space that deliberately includes empty levels, <c>$</c>-topics and wildcard-only filters.
|
||||
/// </summary>
|
||||
public sealed class TopicFilterTrieTests
|
||||
{
|
||||
private static readonly string[] Levels = ["a", "b", "c", "", "$SYS", "x y"];
|
||||
|
||||
private sealed class Collect : ITopicFilterVisitor<int>
|
||||
{
|
||||
public HashSet<int> Seen { get; } = [];
|
||||
|
||||
public void Visit(int value) => Seen.Add(value).ShouldBeTrue("a filter must be visited at most once per match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_IsASupersetOfTheComparer_AndConfirmingYieldsExactlyTheComparersAnswer()
|
||||
{
|
||||
var random = new Random(744);
|
||||
for (var round = 0; round < 200; round++)
|
||||
{
|
||||
var filters = Enumerable.Range(0, random.Next(1, 25)).Select(_ => RandomFilter(random)).ToList();
|
||||
var trie = TopicFilterTrie<int>.Build(filters.Select((f, i) => (f, i)));
|
||||
|
||||
for (var t = 0; t < 50; t++)
|
||||
{
|
||||
var topic = RandomTopic(random);
|
||||
var expected = filters
|
||||
.Select((f, i) => (f, i))
|
||||
.Where(p => MqttTopicFilterComparer.Compare(topic, p.f) == MqttTopicFilterCompareResult.IsMatch)
|
||||
.Select(p => p.i)
|
||||
.ToHashSet();
|
||||
|
||||
var collect = new Collect();
|
||||
trie.Match(topic, ref collect);
|
||||
|
||||
collect.Seen.IsSupersetOf(expected).ShouldBeTrue($"topic '{topic}' missed a match; filters: {string.Join(" | ", filters)}");
|
||||
collect.Seen
|
||||
.Where(i => MqttTopicFilterComparer.Compare(topic, filters[i]) == MqttTopicFilterCompareResult.IsMatch)
|
||||
.ToHashSet()
|
||||
.SetEquals(expected)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/#", "a", true)] // '#' includes the parent level
|
||||
[InlineData("a/#", "a/b/c", true)]
|
||||
[InlineData("a/+", "a/", true)] // '+' matches an empty level
|
||||
[InlineData("a/+", "a", false)]
|
||||
[InlineData("+/+", "a/b", true)]
|
||||
[InlineData("#", "anything/at/all", true)]
|
||||
[InlineData("a/b", "a/b", true)]
|
||||
[InlineData("a/b", "a/c", false)]
|
||||
public void Match_FindsTheCanonicalCases(string filter, string topic, bool expected)
|
||||
{
|
||||
var trie = TopicFilterTrie<int>.Build([(filter, 1)]);
|
||||
var collect = new Collect();
|
||||
trie.Match(topic, ref collect);
|
||||
collect.Seen.Contains(1).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_MatchesNothing()
|
||||
{
|
||||
var collect = new Collect();
|
||||
TopicFilterTrie<int>.Empty.Match("a/b", ref collect);
|
||||
collect.Seen.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static string RandomFilter(Random random)
|
||||
{
|
||||
var count = random.Next(1, 5);
|
||||
var parts = new List<string>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var roll = random.Next(10);
|
||||
if (roll < 2 && i == count - 1) { parts.Add("#"); break; }
|
||||
parts.Add(roll < 5 ? "+" : Levels[random.Next(Levels.Length)]);
|
||||
}
|
||||
|
||||
return string.Join('/', parts);
|
||||
}
|
||||
|
||||
private static string RandomTopic(Random random) =>
|
||||
string.Join('/', Enumerable.Range(0, random.Next(1, 6)).Select(_ => Levels[random.Next(Levels.Length)]));
|
||||
}
|
||||
Reference in New Issue
Block a user