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 86a944b70..5d3fbf678 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
@@ -112,9 +112,10 @@ whose duplicate scan over the last ≤ 1000 queued events grows with the queue (
`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
+- the where clause and the select-clause projection once per (value-equal `EventFilter`, preferred locales), in the
+ SDK's order (fields only for an item that will queue) — filters are grouped once per filter object (`Utils.IsEqual`,
+ ≤ 4096 shared groups, then private groups, counted); a group evaluates its OWN deep clone, validated once, under its own
+ lock, never an item's filter object — 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;
@@ -123,6 +124,13 @@ whose duplicate scan over the last ≤ 1000 queued events grows with the queue (
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
+`ProjectedEventDeliveryWireTests` runs one scenario through each path (`UseProjectedEventDelivery`, which also turns the
+verdict sharing off) 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.
+clause with an absent field, the same select list under three where clauses, a filter modified mid-run, a second session
+with other locales, an ungranted anonymous Server-object item, a Disabled item) plus a small overflowing queue with
+DiscardOldest true and false; it fails if the where clause, a field or the grouping is wrong.
+
+Residual: at a full queue the SDK's own `EventMonitoredItemQueue` still does `List.RemoveAt(0)` per item (memmove of the
+whole queue) — ~1–10 µs/item at QueueSize 1000–10 000; replacing the queue (`IMonitoredItemQueueFactory`) is a separate
+change.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 88594c839..a4284036a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -1711,19 +1711,31 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
///
private void QueueToItem(IEventMonitoredItem item, IFilterTarget e)
{
- if (e is ConditionEventSnapshot snapshot) snapshot.DeliveryCache.Queue(Server, item, e, UseProjectedEventDelivery);
- else item.QueueEvent(e);
+ if (e is ConditionEventSnapshot snapshot)
+ {
+ if (snapshot.DeliveryCache.Queue(Server, item, e, UseProjectedEventDelivery)) Interlocked.Increment(ref _projectedDeliveries);
+ }
+ else
+ {
+ item.QueueEvent(e);
+ }
}
+ private long _projectedDeliveries;
+
+ /// Items this node manager served through the projected path (test observation: it is really taken).
+ internal long ProjectedDeliveries => Interlocked.Read(ref _projectedDeliveries);
+
///
/// 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.
+ /// SDK's own QueueEvent(IFilterTarget) per item, with the role and grant checks asked per item (the
+ /// pre-optimisation path). 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
+ UseProjectedEventDelivery && e is ConditionEventSnapshot snapshot
? snapshot.DeliveryCache.RoleAllows(item, i => !ServiceResult.IsBad(ValidateEventRolePermissions(i, e)))
: !ServiceResult.IsBad(ValidateEventRolePermissions(item, e));
@@ -1734,7 +1746,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
private bool IsConditionVisibleTo(
IEventMonitoredItem item, List scopes, Dictionary perIdentity)
{
- if (item.EffectiveIdentity is not { } identity) return IsConditionVisibleTo(item, scopes);
+ if (!UseProjectedEventDelivery || 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;
@@ -1938,10 +1950,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
/// 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!;
+ public EventDeliveryCache DeliveryCache
+ {
+ get
+ {
+ if (Volatile.Read(ref _deliveryCache) is { } existing) return existing;
+ var created = new EventDeliveryCache();
+ return Interlocked.CompareExchange(ref _deliveryCache, created, null) ?? created;
+ }
+ }
/// 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);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ProjectedEventDelivery.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ProjectedEventDelivery.cs
index c15b59896..877cc57f1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ProjectedEventDelivery.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ProjectedEventDelivery.cs
@@ -57,7 +57,8 @@ internal sealed class EventDeliveryCache
///
/// 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.
+ /// session and identity always get the same answer for one event. Held for the report's walk: on a walk that
+ /// suspends (none of ours does) a RolePermissions change between two hops lands one event later.
///
public bool RoleAllows(IEventMonitoredItem item, Func compute)
{
@@ -80,16 +81,20 @@ internal sealed class EventDeliveryCache
/// 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)
+ /// True when the projected path handled the item (queued, or decided not to queue exactly as the SDK would).
+ public bool 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))
+ if (projected
+ && e is InstanceStateSnapshot { Handle: ConditionState condition }
+ && condition.SupportsFilteredRetain?.Value != true
+ && item.GetType() == typeof(SdkMonitoredItem)
+ && ProjectedEventDelivery.TryQueue(server, (SdkMonitoredItem)item, e, this))
{
- item.QueueEvent(e);
+ return true;
}
+
+ item.QueueEvent(e);
+ return false;
}
/// Marks the item as queued for this report; false when it already was (the SDK's duplicate check).
@@ -98,28 +103,67 @@ internal sealed class EventDeliveryCache
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)
+ /// The projection of the event for a filter group and locale list, created on first use.
+ internal Projection ProjectionFor(FilterGroup group, string locales, Func create)
{
lock (_sync)
{
if (_projections.TryGetValue((group, locales), out var cached)) return cached;
+ var projection = create();
+ _projections[(group, locales)] = projection;
+ return projection;
}
-
- 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);
+ ///
+ /// The event as one filter group sees it, each part computed once and only when needed, in the SDK's order: the
+ /// where clause first, the select-clause fields only for an item that will actually queue the event.
+ ///
+ ///
+ /// 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. Only the report's own walk uses a projection, one
+ /// step at a time; the lazies are thread-safe regardless.
+ ///
+ internal sealed class Projection(Func passes, Func> fields)
+ {
+ private readonly Lazy _passes = new(passes, LazyThreadSafetyMode.ExecutionAndPublication);
+ private readonly Lazy> _fields = new(fields, LazyThreadSafetyMode.ExecutionAndPublication);
+
+ public bool Passes => _passes.Value;
+
+ public ArrayOf Fields => _fields.Value;
+ }
}
-/// A set of value-equal s (one canonical representative), so they can share a projection.
-internal sealed class FilterGroup(EventFilter representative)
+///
+/// A set of value-equal s sharing one projection. The group owns a deep CLONE of the first
+/// member (validated once, here), so evaluating it never touches — or races — a filter object an item owns (#855 perf
+/// review: Validate mutates a select clause).
+///
+internal sealed class FilterGroup
{
- public EventFilter Representative { get; } = representative;
+ public FilterGroup(EventFilter filter, IServerInternal server)
+ {
+ Representative = (EventFilter)CoreUtils.Clone(filter)!;
+ // The SDK's GetRejectedSelectClauses: validation depends on the namespaces and type tree, never on the locales.
+ var context = new FilterContext(server.NamespaceUris, server.TypeTree, server.Telemetry);
+ var clauses = Representative.SelectClauses;
+ Rejected = new bool[clauses.Count];
+ for (var i = 0; i < Rejected.Length; i++)
+ {
+ var clause = clauses[i];
+ Rejected[i] = clause is null || (!clause.Validated && ServiceResult.IsBad(clause.Validate(context, 0)));
+ }
+ }
+
+ /// The group's own copy of the filter; evaluated only under .
+ public EventFilter Representative { get; }
+
+ /// Per select clause: rejected (always projected as null), as the SDK decides once per filter.
+ public bool[] Rejected { get; }
+
+ /// Serialises evaluation of (two reports may project it at once).
+ public object Gate { get; } = new();
}
/// The SDK internals the projected fast path reads, resolved once by reflection — see .
@@ -165,7 +209,9 @@ internal static class ProjectedEventDelivery
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));
+ var group = GroupOf(filter, server);
+ 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;
Interlocked.Increment(ref s_projectedQueued);
@@ -179,42 +225,55 @@ internal static class ProjectedEventDelivery
}
}
- /// 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)
+ /// The SDK's CanSendFilteredAlarm without filtered retain: the where clause, for one group and locale list.
+ private static bool Passes(IServerInternal server, FilterGroup group, 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++)
+ lock (group.Gate)
{
- 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 group.Representative.WhereClause.Evaluate(ContextFor(server, locales), e);
}
-
- return new EventDeliveryCache.Projection(passes, (ArrayOf)fields);
}
+ /// The SDK's GetEventFields values, for one group and locale list.
+ private static ArrayOf Project(IServerInternal server, FilterGroup group, string[]? locales, IFilterTarget e)
+ {
+ lock (group.Gate)
+ {
+ var context = ContextFor(server, locales);
+ var clauses = group.Representative.SelectClauses;
+ var fields = new Variant[clauses.Count];
+ for (var i = 0; i < fields.Length; i++)
+ {
+ if (group.Rejected[i])
+ {
+ fields[i] = Variant.Null;
+ continue;
+ }
+
+ var clause = clauses[i];
+ 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 fields;
+ }
+ }
+
+ /// The SDK's per-item FilterContext : the server's namespaces and type tree, the session's locales (null included).
+ private static FilterContext ContextFor(IServerInternal server, string[]? locales) =>
+ new(server.NamespaceUris, server.TypeTree, locales!, server.Telemetry);
+
/// 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 =>
+ private static FilterGroup GroupOf(EventFilter filter, IServerInternal server) =>
+ s_groups.GetValue(filter, f =>
{
var key = SelectKey(f);
lock (s_registryLock)
@@ -227,14 +286,24 @@ internal static class ProjectedEventDelivery
}
}
- var created = new FilterGroup(f);
- if (s_registered >= MaxSharedFilterGroups) return created;
+ var created = new FilterGroup(f, server);
+ if (s_registered >= MaxSharedFilterGroups)
+ {
+ Interlocked.Increment(ref s_unsharedGroups);
+ return created;
+ }
+
s_registered++;
(bucket ??= s_registry[key] = []).Add(created);
return created;
}
});
+ private static long s_unsharedGroups;
+
+ /// Filters given a private group because the shared registry is full (observability; sharing stops, delivery is unchanged).
+ internal static long UnsharedFilterGroups => Interlocked.Read(ref s_unsharedGroups);
+
/// A cheap bucket key over the select clauses (equality within a bucket is decided by Utils.IsEqual ).
private static string SelectKey(EventFilter filter)
{
@@ -255,8 +324,14 @@ internal static class ProjectedEventDelivery
return string.Join("|", parts);
}
- private static string LocalesKey(string[]? locales) =>
- locales is null ? "\u0002" : string.Join("\u0001", locales);
+ /// An unambiguous key for a locale list (length-prefixed entries; null distinct from empty).
+ private static string LocalesKey(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();
+ }
private static Func? FieldGetter(string name) where T : class
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ProjectedEventDeliveryWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ProjectedEventDeliveryWireTests.cs
index 904aad35b..8cc860272 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ProjectedEventDeliveryWireTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ProjectedEventDeliveryWireTests.cs
@@ -11,16 +11,24 @@ 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.
+/// #855 perf: condition events are queued to many items through one shared projection per (filter, locales), with the
+/// role / grant verdicts shared per identity, instead of the SDK's per-item QueueEvent(IFilterTarget) and
+/// per-item checks. The two paths must deliver the SAME events with the SAME fields: these run one scenario through
+/// each (the node manager's UseProjectedEventDelivery switch, which also turns the verdict sharing off) and
+/// compare every item's stream.
///
public sealed class ProjectedEventDeliveryWireTests
{
private const string EqA = ServerEventRig.EqA;
+ private static readonly ushort[] Severities = [700, 900, 300, 850, 801, 800, 999, 950];
+
+ ///
+ /// Folder and Server-object items; value-equal filters on distinct objects; three filters with the SAME select
+ /// list and different where clauses (they share a registry bucket, never a projection); an OfType where clause
+ /// with a field the event lacks; a Disabled item; a second session with other preferred locales; an anonymous
+ /// session (no grant under the ACL) on the Server object; and a filter modified half-way through each phase.
+ ///
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -38,30 +46,138 @@ public sealed class ProjectedEventDeliveryWireTests
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 severe = AddItem(subscription, eqA, Narrow(severityAbove: 800));
+ var severer = AddItem(subscription, eqA, Narrow(severityAbove: 900));
+ var unfiltered = AddItem(subscription, eqA, Narrow(severityAbove: null));
+ var ofType = AddItem(subscription, eqA, OfTypeAlarm(alternative: false));
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);
+ using var german = await rig.ConnectAsync(anonymous: false, ct, preferredLocales: ["de-DE", "fr"]);
+ var germanEvents = new RecordedEvents();
+ var germanSubscription = await RecordedEvents.SubscribeAsync(german, germanEvents, ct);
+ var g1 = RecordedEvents.AddEventItem(germanSubscription, ServerEventRig.UnsNodeId(german, EqA));
+ await germanSubscription.ApplyChangesAsync(ct);
+ g1.Status.Error.ShouldBeNull();
- foreach (var item in new[] { a1, a2, severe, ofType, disabled, onServer })
+ using var anonymous = await rig.ConnectAsync(anonymous: true, ct);
+ var anonEvents = new RecordedEvents();
+ var anonSubscription = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct);
+ var anonServer = RecordedEvents.AddEventItem(anonSubscription, ObjectIds.Server);
+ await anonSubscription.ApplyChangesAsync(ct);
+ anonServer.Status.Error.ShouldBeNull();
+
+ async Task PhaseAsync(string prefix, bool projectedPath)
{
- 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");
+ rig.Nm.UseProjectedEventDelivery = projectedPath;
+ try
+ {
+ for (var i = 0; i < Severities.Length; i++)
+ {
+ if (i == Severities.Length / 2)
+ {
+ ofType.Filter = OfTypeAlarm(alternative: true); // ModifyMonitoredItems mid-phase
+ await subscription.ApplyChangesAsync(ct);
+ ofType.Status.Error.ShouldBeNull();
+ }
+
+ 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(a1, last) && events.Has(onServer, last) && germanEvents.Has(g1, last));
+ await Task.Delay(TimeSpan.FromMilliseconds(400), ct);
+ }
+ finally
+ {
+ rig.Nm.UseProjectedEventDelivery = true;
+ ofType.Filter = OfTypeAlarm(alternative: false);
+ await subscription.ApplyChangesAsync(ct);
+ }
+ }
+
+ var before = rig.Nm.ProjectedDeliveries;
+ await PhaseAsync("p", projectedPath: true);
+ rig.Nm.ProjectedDeliveries.ShouldBeGreaterThan(before, "the projected path must actually be taken by this rig");
+ var afterProjected = rig.Nm.ProjectedDeliveries;
+ await PhaseAsync("s", projectedPath: false);
+ rig.Nm.ProjectedDeliveries.ShouldBe(afterProjected, "the SDK phase must not use the projected path");
+
+ var streams = new (RecordedEvents Events, MonitoredItem Item)[]
+ {
+ (events, a1), (events, a2), (events, severe), (events, severer), (events, unfiltered), (events, ofType),
+ (events, disabled), (events, onServer), (germanEvents, g1), (anonEvents, anonServer),
+ };
+ foreach (var (recorded, item) in streams)
+ {
+ Normalised(recorded.Of(item), "p").ShouldBe(
+ Normalised(recorded.Of(item), "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);
+ Normalised(events.Of(severe), "p").Count.ShouldBe(Severities.Count(v => v > 800));
+ Normalised(events.Of(severer), "p").Count.ShouldBe(Severities.Count(v => v > 900), "same select list, other where clause");
+ Normalised(events.Of(unfiltered), "p").Count.ShouldBe(Severities.Length);
events.Of(disabled).ShouldBeEmpty("a Disabled item queues nothing on either path");
+ if (enforced) anonEvents.Of(anonServer).Count(f => f[RecordedEvents.AckedField].TryGetValue(out bool _)).ShouldBe(0);
+ else Normalised(anonEvents.Of(anonServer), "p").Count.ShouldBe(Severities.Length);
+ }
+
+ ///
+ /// A small queue overflowing while the client does not publish — DiscardOldest true and false — delivers the same
+ /// survivors and the same EventQueueOverflow event, in the same position, on both paths (each on a fresh server).
+ ///
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task An_overflowing_small_queue_is_identical_on_both_paths(bool discardOldest)
+ {
+ var projected = await OverflowScenarioAsync(discardOldest, projectedPath: true);
+ var sdk = await OverflowScenarioAsync(discardOldest, projectedPath: false);
+ projected.ShouldBe(sdk);
+ projected.ShouldContain(f => f.StartsWith("overflow", StringComparison.Ordinal), "the queue really overflowed");
+ }
+
+ private static async Task> OverflowScenarioAsync(bool discardOldest, bool projectedPath)
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var rig = await ServerEventRig.StartAsync($"Projected855Overflow{projectedPath}", encrypted: false, ct);
+ rig.KeyRoles = [];
+ rig.MaterialiseNative();
+ rig.Enforce(false, [ServerEventRig.Key()], KeyEquipmentGrant(EqA));
+ rig.Nm.UseProjectedEventDelivery = projectedPath;
+
+ using var session = await rig.ConnectAsync(anonymous: false, ct);
+ var events = new RecordedEvents();
+ var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
+ var item = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
+ item.QueueSize = 3;
+ item.DiscardOldest = discardOldest;
+ await subscription.ApplyChangesAsync(ct);
+ item.Status.Error.ShouldBeNull();
+ await subscription.SetPublishingModeAsync(false, ct);
+
+ for (var i = 0; i < Severities.Length; i++)
+ {
+ rig.Project(native: true, new AlarmConditionSnapshot(
+ true, false, false, true, AlarmShelvingKind.Unshelved, Severities[i], $"o{i}"));
+ }
+
+ await subscription.SetPublishingModeAsync(true, ct);
+ await RecordedEvents.WaitUntilAsync(() =>
+ events.Of(item).Any(f => f[RecordedEvents.EventTypeField].GetNodeId() == ObjectTypeIds.EventQueueOverflowEventType));
+ await Task.Delay(TimeSpan.FromMilliseconds(400), ct);
+ return events.Of(item)
+ .Select(f => f[RecordedEvents.EventTypeField].GetNodeId() == ObjectTypeIds.EventQueueOverflowEventType
+ ? "overflow"
+ : $"{RecordedEvents.Message(f)} sev={f[RecordedEvents.SeverityField]}")
+ .ToList();
}
///
@@ -74,30 +190,6 @@ public sealed class ProjectedEventDeliveryWireTests
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
@@ -124,29 +216,34 @@ public sealed class ProjectedEventDeliveryWireTests
return item;
}
- /// EventId, Message, Severity — only events with Severity > 800.
- private static EventFilter SevereOnly()
+ /// EventId, Message, Severity — with the optional where clause Severity > .
+ private static EventFilter Narrow(int? severityAbove)
{
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))));
+ if (severityAbove is { } floor)
+ {
+ where.Push(FilterOperator.GreaterThan,
+ Variant.FromStructure(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.Severity)) { AttributeId = Attributes.Value }),
+ Variant.FromStructure(new LiteralOperand(new Variant((ushort)floor))));
+ }
+
filter.WhereClause = where;
return filter;
}
- /// EventId, Message, Retain, a field the event does not have — OfType AlarmConditionType.
- private static EventFilter OfTypeAlarm()
+ /// OfType AlarmConditionType: EventId, Message, Retain, a field the event lacks (or, alternatively, Severity).
+ private static EventFilter OfTypeAlarm(bool alternative)
{
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));
+ if (alternative) filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Severity));
+ else 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;
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
index 44ae8279e..da0ad7520 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
@@ -298,7 +298,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
.Invoke(server, [on]);
}
- public async Task ConnectAsync(bool anonymous, CancellationToken ct, bool encrypted = false)
+ public async Task ConnectAsync(bool anonymous, CancellationToken ct, bool encrypted = false, string[]? preferredLocales = null)
{
var pki = TestClientSecurity.AllocatePkiRoot();
var security = TestClientSecurity.Build(pki);
@@ -337,7 +337,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
sessionName: "ServerEventRig",
sessionTimeout: 60_000,
identity: identity,
- preferredLocales: default,
+ preferredLocales: preferredLocales is { } locales ? (ArrayOf)locales : default,
ct: ct);
}