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:
@@ -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