diff --git a/docs/plans/2026-11-11-folder-event-per-item-delivery.md b/docs/plans/2026-11-11-folder-event-per-item-delivery.md
index 72ea22512..86a944b70 100644
--- a/docs/plans/2026-11-11-folder-event-per-item-delivery.md
+++ b/docs/plans/2026-11-11-folder-event-per-item-delivery.md
@@ -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.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 0bed75dad..88594c839 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -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);
}
+ ///
+ /// 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.
+ ///
+ private async ValueTask DeliverReportAsync(AlarmConditionState condition, IFilterTarget e)
+ {
+ try
+ {
+ await DeliverConditionEventAsync(condition, condition, e).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (e is ConditionEventSnapshot snapshot) snapshot.ReleaseDeliveryCache();
+ }
+ }
+
+ ///
+ /// 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 QueueEvent .
+ ///
+ private void QueueToItem(IEventMonitoredItem item, IFilterTarget e)
+ {
+ if (e is ConditionEventSnapshot snapshot) snapshot.DeliveryCache.Queue(Server, item, e, UseProjectedEventDelivery);
+ else item.QueueEvent(e);
+ }
+
+ ///
+ /// Whether condition events are queued through the report's shared projection (#855 perf, default) or through the
+ /// SDK's own QueueEvent(IFilterTarget) per item. A test switch: the two must deliver identical events.
+ ///
+ internal bool UseProjectedEventDelivery { get; set; } = true;
+
+ /// The SDK's role-permission verdict for one item, shared per (session, identity) within a report (#855 perf).
+ 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));
+
+ ///
+ /// per effective identity within one delivery (#855 perf): the decision reads the
+ /// item's identity only, and a delivery's scopes are fixed.
+ ///
+ private bool IsConditionVisibleTo(
+ IEventMonitoredItem item, List scopes, Dictionary 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;
+ }
+
/// Marks an event whose remaining delivery will run off Lock (#855) — before the walk suspends.
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(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;
+
+ ///
+ /// 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 Lock ); dropped when the
+ /// walk ends.
+ ///
+ public EventDeliveryCache DeliveryCache =>
+ Volatile.Read(ref _deliveryCache)
+ ?? Interlocked.CompareExchange(ref _deliveryCache, new EventDeliveryCache(), null)
+ ?? _deliveryCache!;
+
+ /// Drops the delivery cache once the walk is over (the snapshot stays referenced from the item queues).
+ public void ReleaseDeliveryCache() => Volatile.Write(ref _deliveryCache, null);
}
///
@@ -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(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);
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ProjectedEventDelivery.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ProjectedEventDelivery.cs
new file mode 100644
index 000000000..c15b59896
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ProjectedEventDelivery.cs
@@ -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;
+
+///
+/// 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.
+///
+///
+///
+/// Why. Since #855 condition events are queued to folder items by the reporter under the node manager's
+/// Lock . The SDK's MonitoredItem.QueueEvent(IFilterTarget) 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.
+///
+///
+/// What is reproduced, exactly. performs the SDK's QueueEvent(instance, false)
+/// 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 (FilterToUse , the revised one); CanSendFilteredAlarm , which
+/// without SupportsFilteredRetain on the condition is the where clause alone; SetQueueOverflowIfFull ;
+/// then GetEventFields (rejected select clauses ⇒ null, LocalizedText translated to the session's
+/// preferred locales, Handle = the event, the item's ClientHandle ) and the public
+/// QueueEvent(EventFieldList) . Each item gets its OWN (the SDK mutates a queued
+/// list's ClientHandle on a modify); the field values are shared (immutable).
+///
+///
+/// Duplicate check. 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).
+///
+///
+/// Fallback. 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 MonitoredItem , a filter in use that is not an
+/// EventFilter , a condition with SupportsFilteredRetain , or an event that is not a condition snapshot —
+/// goes through the SDK's own QueueEvent(IFilterTarget) , unchanged.
+///
+///
+internal sealed class EventDeliveryCache
+{
+ private readonly object _sync = new();
+ private readonly HashSet _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();
+
+ /// The SDK role-permission verdict for an item, computed once per (session, effective identity).
+ /// The event item.
+ /// The SDK check (ValidateEventRolePermissions ) for that item.
+ /// when the item's identity may receive the event.
+ ///
+ /// The SDK check builds an OperationContext from the item and asks ReceiveEvents 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.
+ ///
+ public bool RoleAllows(IEventMonitoredItem item, Func 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;
+ }
+
+ ///
+ /// Queues the event to the item through the projected fast path when it has exactly the shape the SDK's
+ /// QueueEvent(IFilterTarget) would handle, else through that method itself.
+ ///
+ /// The server (namespaces, type tree, resource manager).
+ /// The event item.
+ /// The condition event.
+ /// False routes it through the SDK's own QueueEvent (the node manager's test switch).
+ 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);
+ }
+ }
+
+ /// Marks the item as queued for this report; false when it already was (the SDK's duplicate check).
+ internal bool TryMarkQueued(IEventMonitoredItem item)
+ {
+ lock (_sync) return _queued.Add(item);
+ }
+
+ /// The projection of the event for a filter group and locale list, computed on first use.
+ internal Projection ProjectionFor(FilterGroup group, string locales, Func compute)
+ {
+ lock (_sync)
+ {
+ if (_projections.TryGetValue((group, locales), out var cached)) return cached;
+ }
+
+ var projection = compute();
+ lock (_sync) _projections[(group, locales)] = projection;
+ return projection;
+ }
+
+ /// The event as one filter sees it: whether the where clause passes, and the selected field values.
+ /// The fields are shared by every item queued from this projection: is a read-only view, and the array behind it is never written after it is built.
+ internal sealed record Projection(bool Passes, ArrayOf Fields);
+}
+
+/// A set of value-equal s (one canonical representative), so they can share a projection.
+internal sealed class FilterGroup(EventFilter representative)
+{
+ public EventFilter Representative { get; } = representative;
+}
+
+/// The SDK internals the projected fast path reads, resolved once by reflection — see .
+internal static class ProjectedEventDelivery
+{
+ private static readonly Func? s_itemLock = FieldGetter("m_lock");
+ private static readonly Func? s_eventQueue = FieldGetter("m_eventQueueHandler");
+ private static readonly Func? s_filterToUse = PropertyGetter("FilterToUse");
+
+ private static long s_projectedQueued;
+
+ /// Events queued through the projected path since start (test observation: the fast path is really taken).
+ internal static long ProjectedQueued => Interlocked.Read(ref s_projectedQueued);
+
+ /// Whether every SDK member the fast path needs was found (pinned by a guard test).
+ internal static bool Available => s_itemLock is not null && s_eventQueue is not null && s_filterToUse is not null;
+
+ private static readonly ConditionalWeakTable s_groups = new();
+ private static readonly object s_registryLock = new();
+ private static readonly Dictionary> s_registry = new(StringComparer.Ordinal);
+ private static int s_registered;
+
+ /// Distinct filter shapes kept for sharing; past this a new filter gets a group of its own (no sharing).
+ internal const int MaxSharedFilterGroups = 4096;
+
+ ///
+ /// The SDK's QueueEvent(instance, bypassFilter: false) for one condition snapshot, with the projection taken
+ /// from . Returns false (nothing done) when the item is not in a shape this reproduces.
+ ///
+ 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;
+ }
+ }
+
+ /// The SDK's where-clause evaluation and GetEventFields projection, for one filter and locale list.
+ 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)fields);
+ }
+
+ /// The canonical group of a filter object: value-equal filters share one (decided once per filter object).
+ 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;
+ }
+ });
+
+ /// A cheap bucket key over the select clauses (equality within a bucket is decided by Utils.IsEqual ).
+ private static string SelectKey(EventFilter filter)
+ {
+ var parts = new List();
+ foreach (var clause in filter.SelectClauses)
+ {
+ if (clause is null)
+ {
+ parts.Add("-");
+ continue;
+ }
+
+ var path = new List();
+ 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? FieldGetter(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>(Expression.Convert(Expression.Field(item, field), typeof(T)), item).Compile();
+ }
+
+ private static Func? PropertyGetter(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>();
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ProjectedEventDeliveryWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ProjectedEventDeliveryWireTests.cs
new file mode 100644
index 000000000..904aad35b
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ProjectedEventDeliveryWireTests.cs
@@ -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;
+
+///
+/// #855 perf: condition events are queued to many items through one shared projection per (filter, locales) instead
+/// of the SDK's per-item QueueEvent(IFilterTarget) . 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.
+///
+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");
+ }
+
+ ///
+ /// The projected path rests on PRIVATE members of the pinned SDK's MonitoredItem (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
+ /// QueueEvent (correct, slower) — this fails first, so the upgrade is looked at.
+ ///
+ [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;
+ }
+ }
+
+ /// One phase's events of an item, with the per-event identity (EventId, the phase prefix) removed.
+ private static List Normalised(IReadOnlyList> 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;
+ }
+
+ /// EventId, Message, Severity — only events with Severity > 800.
+ 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;
+ }
+
+ /// EventId, Message, Retain, a field the event does not have — OfType AlarmConditionType.
+ 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,
+ };
+}