fix(opcua): #855 perf review 4 — registry sweep threshold capped; re-sweep at the cap

- The sweep threshold never rises above the 4096 cap (a threshold the count
  could not reach stopped sweeping, and sharing, for good); at the cap a
  sweep for dead shapes runs after every 256 refusals.
- The per-report "queued" mark is taken after the filter group resolves,
  so a failure there leaves the item unmarked.
- The bucket key also carries TypeDefinitionId and IndexRange.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 14:31:46 -05:00
parent 733dfe94da
commit 74b2ce170b
@@ -199,6 +199,10 @@ internal static class ProjectedEventDelivery
private static readonly Dictionary<string, List<WeakReference<FilterGroup>>> s_registry = new(StringComparer.Ordinal);
private static int s_registered;
private static int s_sweepAt = 1024;
private static int s_unsharedSinceSweep;
/// <summary>At the cap, refusals between two sweeps for dead shapes.</summary>
private const int UnsharedResweepInterval = 256;
/// <summary>Live distinct filter shapes kept for sharing; past this a new filter gets a group of its own (no sharing).</summary>
internal const int MaxSharedFilterGroups = 4096;
@@ -225,10 +229,9 @@ internal static class ProjectedEventDelivery
var handler = s_eventQueue!(item);
if (s_filterToUse!(item) is not EventFilter filter) return false;
if (handler is null) return true; // Disabled: the SDK queues nothing (no handler)
if (!cache.TryMarkQueued(item)) return true; // the SDK's duplicate check (see EventDeliveryCache)
var locales = item.Session?.PreferredLocales; // string[]?, exactly what the SDK hands its FilterContext and TranslateValue
var group = GroupOf(filter, server);
if (!cache.TryMarkQueued(item)) return true; // the SDK's duplicate check (see EventDeliveryCache)
var projection = cache.ProjectionFor(group, LocalesKey(locales), () => new EventDeliveryCache.Projection(
() => Passes(server, group, locales, e), () => Project(server, group, locales, e)));
if (!projection.Passes || handler.SetQueueOverflowIfFull()) return true;
@@ -313,9 +316,21 @@ internal static class ProjectedEventDelivery
var created = new FilterGroup(f, server);
if (s_registered >= MaxSharedFilterGroups)
{
OtOpcUaTelemetry.ConditionEventFilterGroupsUnshared.Add(1);
Interlocked.Increment(ref s_unsharedGroups);
return created;
// Full: re-check for dead shapes after every UnsharedResweepInterval refusals, so a registry that filled
// once does not refuse sharing for good, and one that is genuinely full of live shapes is not swept on
// every new filter.
if (++s_unsharedSinceSweep >= UnsharedResweepInterval)
{
s_unsharedSinceSweep = 0;
SweepRegistry();
}
if (s_registered >= MaxSharedFilterGroups)
{
OtOpcUaTelemetry.ConditionEventFilterGroupsUnshared.Add(1);
Interlocked.Increment(ref s_unsharedGroups);
return created;
}
}
s_registered++;
@@ -337,8 +352,9 @@ internal static class ProjectedEventDelivery
}
s_registered = live;
// Amortised: the next sweep only once as many shapes again have been registered (never more often than every 1024).
s_sweepAt = Math.Max(1024, live * 2);
// Amortised: the next sweep only once as many shapes again have been registered (never more often than every 1024),
// and never above the cap — a threshold the count can no longer reach would stop sweeping, and sharing, for good.
s_sweepAt = Math.Min(MaxSharedFilterGroups, Math.Max(1024, live * 2));
}
private static long s_unsharedGroups;
@@ -361,7 +377,7 @@ internal static class ProjectedEventDelivery
var path = new List<string>();
foreach (var name in clause.BrowsePath) path.Add(name.ToString());
parts.Add(string.Join("/", path) + "@" + clause.AttributeId);
parts.Add(clause.TypeDefinitionId + ":" + string.Join("/", path) + "@" + clause.AttributeId + "[" + clause.IndexRange + "]");
}
return string.Join("|", parts);