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:
Joseph Doherty
2026-10-22 15:38:29 -04:00
parent c14a3388f1
commit 6a51025012
3 changed files with 311 additions and 13 deletions
@@ -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)]));
}