Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ProjectedEventDelivery.cs
T
Joseph Doherty 98d962b24b perf(opcua): #855 — shared per-report projection for condition events under Lock
Condition events delivered to many event items (folder and Server-object)
no longer pay the SDK's per-item QueueEvent(IFilterTarget) work under the
node-manager Lock: the role-permission verdict is shared per (session,
identity), the #826 grant check per identity per hop, and the where clause +
select-clause projection per (value-equal EventFilter, locales); each item
gets its own EventFieldList over the shared read-only fields. The SDK's
QueueEvent body is reproduced under the item's own lock (three private SDK
members, guard-tested); anything outside that shape falls back to the SDK.
The per-report duplicate check replaces the SDK's O(queue) scan.

ProjectedEventDeliveryWireTests compares both paths over the wire.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 13:42:29 -05:00

276 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using Opc.Ua;
using Opc.Ua.Server;
using SdkMonitoredItem = Opc.Ua.Server.MonitoredItem;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Per-report state for delivering ONE condition event to many event items (Gitea #855 perf): the event's projected
/// fields computed once per distinct (EventFilter, preferred locales) instead of once per item, the SDK's
/// role-permission verdict once per (session, identity), and the set of items it was already queued to.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why.</b> Since #855 condition events are queued to folder items by the reporter under the node manager's
/// <c>Lock</c>. The SDK's <c>MonitoredItem.QueueEvent(IFilterTarget)</c> re-does, for every item, the where-clause
/// evaluation and the select-clause projection, and first scans the last ≤ 1000 queued events for a duplicate —
/// at a full queue that scan alone dominated (measured ~2.8 µs/item at QueueSize 10, ~10–16 µs at 1000, ~16–31 µs
/// at 10 000). The projection and verdicts are the same for every item that shares a filter and an identity.
/// </para>
/// <para>
/// <b>What is reproduced, exactly.</b> <see cref="TryQueue"/> performs the SDK's <c>QueueEvent(instance, false)</c>
/// body (decompiled, SDK 2.0.1) under the item's own lock: event-item check; no queue handler (Disabled) ⇒ nothing;
/// duplicate ⇒ nothing; the filter in use (<c>FilterToUse</c>, the revised one); <c>CanSendFilteredAlarm</c>, which
/// without <c>SupportsFilteredRetain</c> on the condition is the where clause alone; <c>SetQueueOverflowIfFull</c>;
/// then <c>GetEventFields</c> (rejected select clauses ⇒ null, <c>LocalizedText</c> translated to the session's
/// preferred locales, <c>Handle</c> = the event, the item's <c>ClientHandle</c>) and the public
/// <c>QueueEvent(EventFieldList)</c>. Each item gets its OWN <see cref="EventFieldList"/> (the SDK mutates a queued
/// list's <c>ClientHandle</c> on a modify); the field values are shared (immutable).
/// </para>
/// <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).
/// </para>
/// <para>
/// <b>Fallback.</b> Anything outside that shape — the private members not found (an SDK upgrade; pinned by a guard
/// test), an item type that is not exactly the SDK's <c>MonitoredItem</c>, a filter in use that is not an
/// <c>EventFilter</c>, a condition with <c>SupportsFilteredRetain</c>, or an event that is not a condition snapshot —
/// goes through the SDK's own <c>QueueEvent(IFilterTarget)</c>, unchanged.
/// </para>
/// </remarks>
internal sealed class EventDeliveryCache
{
private readonly object _sync = new();
private readonly HashSet<IEventMonitoredItem> _queued = new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
private readonly Dictionary<(object? Session, object? Identity), bool> _roleVerdicts = new();
private readonly Dictionary<(FilterGroup Group, string Locales), Projection> _projections = new();
/// <summary>The SDK role-permission verdict for an item, computed once per (session, effective identity).</summary>
/// <param name="item">The event item.</param>
/// <param name="compute">The SDK check (<c>ValidateEventRolePermissions</c>) for that item.</param>
/// <returns><see langword="true"/> when the item's identity may receive the event.</returns>
/// <remarks>
/// The SDK check builds an <c>OperationContext</c> from the item and asks <c>ReceiveEvents</c> on the event's type
/// and source for that context's identity and session — nothing else of the item takes part, so two items of one
/// session and identity always get the same answer for one event.
/// </remarks>
public bool RoleAllows(IEventMonitoredItem item, Func<IEventMonitoredItem, bool> compute)
{
var key = ((object?)item.Session, (object?)item.EffectiveIdentity);
lock (_sync)
{
if (_roleVerdicts.TryGetValue(key, out var cached)) return cached;
}
var verdict = compute(item);
lock (_sync) _roleVerdicts[key] = verdict;
return verdict;
}
/// <summary>
/// Queues the event to the item through the projected fast path when it has exactly the shape the SDK's
/// <c>QueueEvent(IFilterTarget)</c> would handle, else through that method itself.
/// </summary>
/// <param name="server">The server (namespaces, type tree, resource manager).</param>
/// <param name="item">The event item.</param>
/// <param name="e">The condition event.</param>
/// <param name="projected">False routes it through the SDK's own <c>QueueEvent</c> (the node manager's test switch).</param>
public void Queue(IServerInternal server, IEventMonitoredItem item, IFilterTarget e, bool projected)
{
if (!projected
|| e is not InstanceStateSnapshot { Handle: ConditionState condition }
|| condition.SupportsFilteredRetain?.Value == true
|| item.GetType() != typeof(SdkMonitoredItem)
|| !ProjectedEventDelivery.TryQueue(server, (SdkMonitoredItem)item, e, this))
{
item.QueueEvent(e);
}
}
/// <summary>Marks the item as queued for this report; false when it already was (the SDK's duplicate check).</summary>
internal bool TryMarkQueued(IEventMonitoredItem item)
{
lock (_sync) return _queued.Add(item);
}
/// <summary>The projection of the event for a filter group and locale list, computed on first use.</summary>
internal Projection ProjectionFor(FilterGroup group, string locales, Func<Projection> compute)
{
lock (_sync)
{
if (_projections.TryGetValue((group, locales), out var cached)) return cached;
}
var projection = compute();
lock (_sync) _projections[(group, locales)] = projection;
return projection;
}
/// <summary>The event as one filter sees it: whether the where clause passes, and the selected field values.</summary>
/// <remarks>The fields are shared by every item queued from this projection: <see cref="ArrayOf{T}"/> is a read-only view, and the array behind it is never written after it is built.</remarks>
internal sealed record Projection(bool Passes, ArrayOf<Variant> Fields);
}
/// <summary>A set of value-equal <see cref="EventFilter"/>s (one canonical representative), so they can share a projection.</summary>
internal sealed class FilterGroup(EventFilter representative)
{
public EventFilter Representative { get; } = representative;
}
/// <summary>The SDK internals the projected fast path reads, resolved once by reflection — see <see cref="EventDeliveryCache"/>.</summary>
internal static class ProjectedEventDelivery
{
private static readonly Func<SdkMonitoredItem, Lock?>? s_itemLock = FieldGetter<Lock>("m_lock");
private static readonly Func<SdkMonitoredItem, EventQueueHandler?>? s_eventQueue = FieldGetter<EventQueueHandler>("m_eventQueueHandler");
private static readonly Func<SdkMonitoredItem, MonitoringFilter?>? s_filterToUse = PropertyGetter<MonitoringFilter>("FilterToUse");
private static long s_projectedQueued;
/// <summary>Events queued through the projected path since start (test observation: the fast path is really taken).</summary>
internal static long ProjectedQueued => Interlocked.Read(ref s_projectedQueued);
/// <summary>Whether every SDK member the fast path needs was found (pinned by a guard test).</summary>
internal static bool Available => s_itemLock is not null && s_eventQueue is not null && s_filterToUse is not null;
private static readonly ConditionalWeakTable<EventFilter, FilterGroup> s_groups = new();
private static readonly object s_registryLock = new();
private static readonly Dictionary<string, List<FilterGroup>> s_registry = new(StringComparer.Ordinal);
private static int s_registered;
/// <summary>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;
/// <summary>
/// The SDK's <c>QueueEvent(instance, bypassFilter: false)</c> for one condition snapshot, with the projection taken
/// from <paramref name="cache"/>. Returns false (nothing done) when the item is not in a shape this reproduces.
/// </summary>
internal static bool TryQueue(IServerInternal server, SdkMonitoredItem item, IFilterTarget e, EventDeliveryCache cache)
{
if (!Available || (item.MonitoredItemType & 2) == 0) return false;
// Everything below happens under the item's own lock, as in the SDK: a concurrent ModifyMonitoredItems (filter,
// client handle, queue) or SetMonitoringMode (queue handler) cannot interleave between the decision and the queueing.
var itemLock = s_itemLock!(item);
if (itemLock is null) return false;
using (itemLock.EnterScope())
{
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 projection = cache.ProjectionFor(GroupOf(filter), LocalesKey(locales), () => Project(server, filter, locales, e));
if (!projection.Passes || handler.SetQueueOverflowIfFull()) return true;
Interlocked.Increment(ref s_projectedQueued);
item.QueueEvent(new EventFieldList
{
ClientHandle = item.ClientHandle,
Handle = e,
EventFields = projection.Fields, // shared, read-only (see Projection)
});
return true;
}
}
/// <summary>The SDK's where-clause evaluation and <c>GetEventFields</c> projection, for one filter and locale list.</summary>
private static EventDeliveryCache.Projection Project(
IServerInternal server, EventFilter filter, string[]? locales, IFilterTarget e)
{
var context = new FilterContext(
server.NamespaceUris, server.TypeTree, locales!, server.Telemetry); // as the SDK: Session?.PreferredLocales, null included
var passes = filter.WhereClause.Evaluate(context, e);
var clauses = filter.SelectClauses;
var fields = new Variant[clauses.Count];
for (var i = 0; i < fields.Length; i++)
{
var clause = clauses[i];
if (clause is null || (!clause.Validated && ServiceResult.IsBad(clause.Validate(context, 0))))
{
fields[i] = Variant.Null; // a rejected select clause
continue;
}
var value = e.GetAttributeValue(context, clause.TypeDefinitionId, clause.BrowsePath, clause.AttributeId, clause.ParsedIndexRange);
if (value.IsNull)
{
fields[i] = Variant.Null;
continue;
}
if (value.TypeInfo.BuiltInType == BuiltInType.LocalizedText)
value = server.ResourceManager.TranslateValue(locales!, value);
fields[i] = value;
}
return new EventDeliveryCache.Projection(passes, (ArrayOf<Variant>)fields);
}
/// <summary>The canonical group of a filter object: value-equal filters share one (decided once per filter object).</summary>
private static FilterGroup GroupOf(EventFilter filter) =>
s_groups.GetValue(filter, static f =>
{
var key = SelectKey(f);
lock (s_registryLock)
{
if (s_registry.TryGetValue(key, out var bucket))
{
foreach (var group in bucket)
{
if (Utils.IsEqual(group.Representative, f)) return group;
}
}
var created = new FilterGroup(f);
if (s_registered >= MaxSharedFilterGroups) return created;
s_registered++;
(bucket ??= s_registry[key] = []).Add(created);
return created;
}
});
/// <summary>A cheap bucket key over the select clauses (equality within a bucket is decided by <c>Utils.IsEqual</c>).</summary>
private static string SelectKey(EventFilter filter)
{
var parts = new List<string>();
foreach (var clause in filter.SelectClauses)
{
if (clause is null)
{
parts.Add("-");
continue;
}
var path = new List<string>();
foreach (var name in clause.BrowsePath) path.Add(name.ToString());
parts.Add(string.Join("/", path) + "@" + clause.AttributeId);
}
return string.Join("|", parts);
}
private static string LocalesKey(string[]? locales) =>
locales is null ? "\u0002" : string.Join("\u0001", locales);
private static Func<SdkMonitoredItem, T?>? FieldGetter<T>(string name) where T : class
{
var field = typeof(SdkMonitoredItem).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
if (field is null || !typeof(T).IsAssignableFrom(field.FieldType)) return null;
var item = Expression.Parameter(typeof(SdkMonitoredItem));
return Expression.Lambda<Func<SdkMonitoredItem, T?>>(Expression.Convert(Expression.Field(item, field), typeof(T)), item).Compile();
}
private static Func<SdkMonitoredItem, T?>? PropertyGetter<T>(string name) where T : class
{
var property = typeof(SdkMonitoredItem).GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic);
if (property?.GetMethod is null || !typeof(T).IsAssignableFrom(property.PropertyType)) return null;
return property.GetMethod.CreateDelegate<Func<SdkMonitoredItem, T?>>();
}
}