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
This commit is contained in:
@@ -100,3 +100,29 @@ The per-event verdict for folder items moves from the SDK's consumer task (cache
|
||||
ConditionRefresh2, resubscribe), the gate race, newer-than-cut replay, ACL omission not withheld, ACL revocation during
|
||||
deferral, the in-flight suspended event, and two stress tests (production overload with no stall; stall + refresh +
|
||||
reports concurrently).
|
||||
|
||||
## Follow-up: cost under `Lock` (projected delivery)
|
||||
|
||||
Measured with `FolderEventDeliveryPerfTests` (opt-in, `OTOPCUA_EVENT_PERF=1`): the per-item work moved under `Lock` by
|
||||
this change cost ~5–6 µs/item publishing and ~10–30 µs/item at a full queue (N = 50 stalled items: p50 0.4–1.6 ms).
|
||||
Profiling split it into the SDK's role-permission check (~3–9 µs/item) and `MonitoredItem.QueueEvent(IFilterTarget)`,
|
||||
whose duplicate scan over the last ≤ 1000 queued events grows with the queue (~3 µs at QueueSize 10, ~10–16 at 1000,
|
||||
~16–31 at 10 000) on top of the per-item where-clause + select-clause projection.
|
||||
|
||||
`ProjectedEventDelivery` / `EventDeliveryCache` (per report, hung on the condition snapshot, dropped when the walk ends):
|
||||
|
||||
- the SDK role verdict once per (session, effective identity); the #826 grant check once per identity per hop;
|
||||
- the where clause and the select-clause projection once per (value-equal `EventFilter`, preferred locales) — filters
|
||||
are grouped once per filter object (`Utils.IsEqual`, ≤ 4096 shared groups) — and the field values shared read-only by
|
||||
every item queued from it (each item still gets its own `EventFieldList`: the SDK rewrites a queued list's
|
||||
`ClientHandle` on modify);
|
||||
- the SDK's `QueueEvent(instance, false)` body reproduced under the item's own lock (event item; Disabled ⇒ nothing;
|
||||
duplicate ⇒ nothing — asked of the report's "already queued" set instead of the queue; the filter in use;
|
||||
`SetQueueOverflowIfFull`; then the public `QueueEvent(EventFieldList)`), reading three private members (`m_lock`,
|
||||
`m_eventQueueHandler`, `FilterToUse`) — pinned by a guard test; anything else (members missing, a subclassed item, a
|
||||
non-`EventFilter` filter, a `SupportsFilteredRetain` condition, a non-snapshot event, refresh replays) uses the SDK's
|
||||
own `QueueEvent(IFilterTarget)`.
|
||||
|
||||
`ProjectedEventDeliveryWireTests` runs one scenario through each path (`UseProjectedEventDelivery`) and requires
|
||||
identical streams on folder and Server-object items (value-equal filters, a severity where clause, an OfType where
|
||||
clause with an absent field, a Disabled item); it fails if the where clause or a field is projected differently.
|
||||
|
||||
@@ -1520,7 +1520,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
ValueTask delivery;
|
||||
try
|
||||
{
|
||||
delivery = DeliverConditionEventAsync(condition, condition, e);
|
||||
delivery = DeliverReportAsync(condition, e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1611,7 +1611,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
try
|
||||
{
|
||||
current = DeliverConditionEventAsync(condition, condition, next).AsTask();
|
||||
current = DeliverReportAsync(condition, next).AsTask();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1689,6 +1689,58 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
await DeliverConditionEventAsync(condition, parent, e).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One report's whole walk, then its per-report delivery cache is dropped (#855 perf): the cache only serves the
|
||||
/// walk, and the snapshot it hangs on stays referenced from every item queue the event reached.
|
||||
/// </summary>
|
||||
private async ValueTask DeliverReportAsync(AlarmConditionState condition, IFilterTarget e)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DeliverConditionEventAsync(condition, condition, e).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (e is ConditionEventSnapshot snapshot) snapshot.ReleaseDeliveryCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a condition event to one item: through the report's shared projection (#855 perf) for a condition
|
||||
/// snapshot, else through the SDK's own <c>QueueEvent</c>.
|
||||
/// </summary>
|
||||
private void QueueToItem(IEventMonitoredItem item, IFilterTarget e)
|
||||
{
|
||||
if (e is ConditionEventSnapshot snapshot) snapshot.DeliveryCache.Queue(Server, item, e, UseProjectedEventDelivery);
|
||||
else item.QueueEvent(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether condition events are queued through the report's shared projection (#855 perf, default) or through the
|
||||
/// SDK's own <c>QueueEvent(IFilterTarget)</c> per item. A test switch: the two must deliver identical events.
|
||||
/// </summary>
|
||||
internal bool UseProjectedEventDelivery { get; set; } = true;
|
||||
|
||||
/// <summary>The SDK's role-permission verdict for one item, shared per (session, identity) within a report (#855 perf).</summary>
|
||||
private bool RoleAllows(IEventMonitoredItem item, IFilterTarget e) =>
|
||||
e is ConditionEventSnapshot snapshot
|
||||
? snapshot.DeliveryCache.RoleAllows(item, i => !ServiceResult.IsBad(ValidateEventRolePermissions(i, e)))
|
||||
: !ServiceResult.IsBad(ValidateEventRolePermissions(item, e));
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IsConditionVisibleTo"/> per effective identity within one delivery (#855 perf): the decision reads the
|
||||
/// item's identity only, and a delivery's scopes are fixed.
|
||||
/// </summary>
|
||||
private bool IsConditionVisibleTo(
|
||||
IEventMonitoredItem item, List<NodeId> scopes, Dictionary<object, bool> perIdentity)
|
||||
{
|
||||
if (item.EffectiveIdentity is not { } identity) return IsConditionVisibleTo(item, scopes);
|
||||
if (perIdentity.TryGetValue(identity, out var cached)) return cached;
|
||||
var visible = IsConditionVisibleTo(item, scopes);
|
||||
perIdentity[identity] = visible;
|
||||
return visible;
|
||||
}
|
||||
|
||||
/// <summary>Marks an event whose remaining delivery will run off <c>Lock</c> (#855) — before the walk suspends.</summary>
|
||||
private static void MarkDeferred(IFilterTarget e)
|
||||
{
|
||||
@@ -1756,14 +1808,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
scopes = ConditionVisibilityScopes(live);
|
||||
}
|
||||
|
||||
var visibleByIdentity = scopes is null ? null : new Dictionary<object, bool>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
foreach (var item in monitored.EventMonitoredItems.Values)
|
||||
{
|
||||
if (item is null) continue;
|
||||
try
|
||||
{
|
||||
if (IsAuditEvent(e) && !IsAuditDeliverableTo(item)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (!RoleAllows(item, e)) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes, visibleByIdentity!)) continue;
|
||||
QueueConditionEventToItem(item, e, deferred, DeferredFolderDeliveryProbe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1877,6 +1930,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
get => _deferred;
|
||||
set => _deferred = value;
|
||||
}
|
||||
|
||||
private EventDeliveryCache? _deliveryCache;
|
||||
|
||||
/// <summary>
|
||||
/// The report's shared delivery state (#855 perf) — projections, role verdicts, the items it was queued to. Only
|
||||
/// the report's own walk uses it (sequentially, even when part of the walk runs off <c>Lock</c>); dropped when the
|
||||
/// walk ends.
|
||||
/// </summary>
|
||||
public EventDeliveryCache DeliveryCache =>
|
||||
Volatile.Read(ref _deliveryCache)
|
||||
?? Interlocked.CompareExchange(ref _deliveryCache, new EventDeliveryCache(), null)
|
||||
?? _deliveryCache!;
|
||||
|
||||
/// <summary>Drops the delivery cache once the walk is over (the snapshot stays referenced from the item queues).</summary>
|
||||
public void ReleaseDeliveryCache() => Volatile.Write(ref _deliveryCache, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -5841,14 +5909,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
var reportingSession = (context as ISessionSystemContext)?.SessionId;
|
||||
var deferred = e is ConditionEventSnapshot { Deferred: true } d ? d : null;
|
||||
var visibleByIdentity = scopes is null ? null : new Dictionary<object, bool>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes, visibleByIdentity!)) continue;
|
||||
if (!RoleAllows(item, e)) continue;
|
||||
if (reportingSession is { IsNull: false } reportingId
|
||||
&& item.Session?.Id is { } own
|
||||
&& !own.Equals(reportingId))
|
||||
@@ -5884,7 +5953,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
if (deferred is not null) state = EventItemStateOf(item);
|
||||
else if (!_eventItemStates.TryGetValue(item, out state) || state.PendingReplay == 0 || e is not ConditionEventSnapshot)
|
||||
{
|
||||
item.QueueEvent(e);
|
||||
QueueToItem(item, e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5907,7 +5976,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
state.NewerSinceCut.Add(ce.ConditionId);
|
||||
|
||||
if (deferred is not null) probe?.Invoke();
|
||||
item.QueueEvent(e);
|
||||
QueueToItem(item, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
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?>>();
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using MonitoredItem = Opc.Ua.Client.MonitoredItem;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// #855 perf: condition events are queued to many items through one shared projection per (filter, locales) instead
|
||||
/// of the SDK's per-item <c>QueueEvent(IFilterTarget)</c>. The two paths must deliver the SAME events with the SAME
|
||||
/// fields: this runs one scenario through each (the node manager's switch) and compares every item's stream —
|
||||
/// folder and Server-object items, value-equal filters on distinct objects, a where clause that rejects some events,
|
||||
/// a where clause on the type, a narrower select list, and a Disabled item.
|
||||
/// </summary>
|
||||
public sealed class ProjectedEventDeliveryWireTests
|
||||
{
|
||||
private const string EqA = ServerEventRig.EqA;
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Projected_delivery_queues_exactly_what_the_sdk_queues(bool enforced)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Projected855", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
rig.MaterialiseNative();
|
||||
rig.Enforce(enforced, [ServerEventRig.Key()], KeyEquipmentGrant(EqA));
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var eqA = ServerEventRig.UnsNodeId(session, EqA);
|
||||
var a1 = RecordedEvents.AddEventItem(subscription, eqA);
|
||||
var a2 = RecordedEvents.AddEventItem(subscription, eqA); // a value-equal filter on a distinct object
|
||||
var severe = AddItem(subscription, eqA, SevereOnly());
|
||||
var ofType = AddItem(subscription, eqA, OfTypeAlarm());
|
||||
var disabled = RecordedEvents.AddEventItem(subscription, eqA);
|
||||
var onServer = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
foreach (var item in subscription.MonitoredItems) item.Status.Error.ShouldBeNull();
|
||||
await subscription.SetMonitoringModeAsync(MonitoringMode.Disabled, [disabled], ct);
|
||||
|
||||
var before = ProjectedEventDelivery.ProjectedQueued;
|
||||
await PhaseAsync(rig, events, a1, onServer, "p", projectedPath: true);
|
||||
ProjectedEventDelivery.ProjectedQueued.ShouldBeGreaterThan(before, "the projected path must actually be taken");
|
||||
await PhaseAsync(rig, events, a1, onServer, "s", projectedPath: false);
|
||||
|
||||
foreach (var item in new[] { a1, a2, severe, ofType, disabled, onServer })
|
||||
{
|
||||
var p = Normalised(events.Of(item), "p");
|
||||
var s = Normalised(events.Of(item), "s");
|
||||
p.ShouldBe(s, $"{item.DisplayName}: the projected path must deliver exactly the SDK's events and fields");
|
||||
}
|
||||
|
||||
Normalised(events.Of(a1), "p").Count.ShouldBe(Severities.Length);
|
||||
Normalised(events.Of(severe), "p").Count.ShouldBe(Severities.Count(v => v > 800), "the where clause rejects some");
|
||||
Normalised(events.Of(ofType), "p").Count.ShouldBe(Severities.Length);
|
||||
events.Of(disabled).ShouldBeEmpty("a Disabled item queues nothing on either path");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The projected path rests on PRIVATE members of the pinned SDK's <c>MonitoredItem</c> (its lock, its event queue
|
||||
/// handler, the filter in use). If an SDK upgrade drops one, delivery silently falls back to the SDK's per-item
|
||||
/// <c>QueueEvent</c> (correct, slower) — this fails first, so the upgrade is looked at.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_sdk_members_the_projected_path_reads_are_still_resolvable() =>
|
||||
ProjectedEventDelivery.Available.ShouldBeTrue(
|
||||
"MonitoredItem.m_lock / m_eventQueueHandler / FilterToUse changed — re-check ProjectedEventDelivery.TryQueue against the new SDK's QueueEvent(IFilterTarget)");
|
||||
|
||||
private static readonly ushort[] Severities = [700, 900, 300, 850, 801, 800, 999];
|
||||
|
||||
private static async Task PhaseAsync(
|
||||
ServerEventRig rig, RecordedEvents events, MonitoredItem probe, MonitoredItem onServer, string prefix, bool projectedPath)
|
||||
{
|
||||
rig.Nm.UseProjectedEventDelivery = projectedPath;
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < Severities.Length; i++)
|
||||
{
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(
|
||||
i % 2 == 0, i % 3 == 0, false, true, AlarmShelvingKind.Unshelved, Severities[i], $"{prefix}{i}"));
|
||||
}
|
||||
|
||||
var last = $"{prefix}{Severities.Length - 1}";
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(probe, last) && events.Has(onServer, last));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
finally
|
||||
{
|
||||
rig.Nm.UseProjectedEventDelivery = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One phase's events of an item, with the per-event identity (EventId, the phase prefix) removed.</summary>
|
||||
private static List<string> Normalised(IReadOnlyList<ArrayOf<Variant>> stream, string prefix) =>
|
||||
stream
|
||||
.Where(f => RecordedEvents.Message(f)?.StartsWith(prefix, StringComparison.Ordinal) == true)
|
||||
.Select(f => string.Join(" | ", f.ToArray()!.Select((v, i) => i switch
|
||||
{
|
||||
RecordedEvents.EventIdField => v.IsNull ? "null" : "id",
|
||||
RecordedEvents.MessageField => RecordedEvents.Message(f)![prefix.Length..],
|
||||
_ => v.ToString(),
|
||||
})))
|
||||
.ToList();
|
||||
|
||||
private static MonitoredItem AddItem(Subscription subscription, NodeId source, EventFilter filter)
|
||||
{
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = source,
|
||||
AttributeId = Attributes.EventNotifier,
|
||||
Filter = filter,
|
||||
QueueSize = 1000,
|
||||
SamplingInterval = 0,
|
||||
};
|
||||
subscription.AddItem(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>EventId, Message, Severity — only events with Severity > 800.</summary>
|
||||
private static EventFilter SevereOnly()
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventId));
|
||||
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
|
||||
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Severity));
|
||||
var where = new ContentFilter();
|
||||
where.Push(FilterOperator.GreaterThan,
|
||||
Variant.FromStructure(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.Severity)) { AttributeId = Attributes.Value }),
|
||||
Variant.FromStructure(new LiteralOperand(new Variant((ushort)800))));
|
||||
filter.WhereClause = where;
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>EventId, Message, Retain, a field the event does not have — OfType AlarmConditionType.</summary>
|
||||
private static EventFilter OfTypeAlarm()
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventId));
|
||||
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
|
||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, QualifiedName.From(BrowseNames.Retain));
|
||||
filter.AddSelectClause(ObjectTypeIds.LimitAlarmType, QualifiedName.From(BrowseNames.HighLimit));
|
||||
var where = new ContentFilter();
|
||||
where.Push(FilterOperator.OfType, Variant.FromStructure(new LiteralOperand(new Variant(ObjectTypeIds.AlarmConditionType))));
|
||||
filter.WhereClause = where;
|
||||
return filter;
|
||||
}
|
||||
|
||||
private static NodeAcl KeyEquipmentGrant(string equipment) => new()
|
||||
{
|
||||
NodeAclId = $"acl-key-{equipment}",
|
||||
ClusterId = ServerEventRig.Cluster,
|
||||
Principal = ServerEventRig.KeyPrincipal,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = $"eq-{equipment}",
|
||||
PermissionFlags = NodePermissions.ReadOnly,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user