fix(opcua): #855 perf review 2 — where clause in the group bucket key; pin dedupe, modify and keys

- Filter-group buckets include the where clause's (value-based) hash, so
  per-source filters sharing a select list are not all scanned under Lock.
- Locale keys are cached per session array.
- Tests: a notifier reached twice in one walk (Line above two wired
  equipment) gets each event once on both paths (fails without the
  per-report set); queued events re-projected by a modify are identical on
  both paths; locale keys unambiguous and where clauses hash by value.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 14:10:02 -05:00
parent ca0e73fbca
commit b77bb2ded1
2 changed files with 133 additions and 5 deletions
@@ -33,8 +33,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <para>
/// <b>Duplicate check.</b> The SDK compares the event INSTANCE against the last 1000 queued handles. A condition
/// snapshot reaches an item only through this per-report delivery, so "already queued in this report" is the same
/// question, asked of a set instead of the queue (it stays true after the event leaves the queue, where the SDK's
/// would not — a second delivery of one report to one item never happens on purpose).
/// question, asked of a set instead of the queue. It matters: a walk can reach one notifier twice (a Line folder above
/// two equipment folders the alarm is wired into), and the SDK's scan suppressed that second copy too. The set
/// stays true after the event has left the queue, where the SDK's scan would not — a second visit within one walk
/// happens long before anything is published; at a full DiscardOldest=false queue the SDK also counted that second
/// visit as one more discarded event, this counts it once.
/// </para>
/// <para>
/// <b>Fallback.</b> Anything outside that shape — the private members not found (an SDK upgrade; pinned by a guard
@@ -275,7 +278,9 @@ internal static class ProjectedEventDelivery
private static FilterGroup GroupOf(EventFilter filter, IServerInternal server) =>
s_groups.GetValue(filter, f =>
{
var key = SelectKey(f);
// The where clause takes part in the bucket (its hash is value-based), so filters that share a select list but
// differ in their where clauses (per-source filters) do not all land in one bucket scanned under Lock.
var key = SelectKey(f) + "#" + f.WhereClause?.GetHashCode().ToString(System.Globalization.CultureInfo.InvariantCulture);
lock (s_registryLock)
{
if (s_registry.TryGetValue(key, out var bucket))
@@ -325,9 +330,14 @@ internal static class ProjectedEventDelivery
}
/// <summary>An unambiguous key for a locale list (length-prefixed entries; null distinct from empty).</summary>
private static string LocalesKey(string[]? locales)
private static readonly ConditionalWeakTable<string[], string> s_localeKeys = new();
/// <summary>An unambiguous key for a locale list, cached per array (a session's array is fixed for its lifetime).</summary>
internal static string LocalesKey(string[]? locales) =>
locales is null ? "null" : s_localeKeys.GetValue(locales, static l => BuildLocalesKey(l));
private static string BuildLocalesKey(string[] locales)
{
if (locales is null) return "null";
var key = new System.Text.StringBuilder().Append(locales.Length).Append(':');
foreach (var locale in locales) key.Append(locale?.Length ?? -1).Append(':').Append(locale).Append(';');
return key.ToString();