From ca0e73fbca22c29fcd58992601c7997b8ba59bb7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 13:59:22 -0500 Subject: [PATCH] =?UTF-8?q?fix(opcua):=20#855=20perf=20review=201=20?= =?UTF-8?q?=E2=80=94=20groups=20evaluate=20their=20own=20filter=20clone;?= =?UTF-8?q?=20lazy=20fields;=20tighter=20equivalence=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A filter group owns a deep clone of its first filter, validated once and evaluated under the group's lock: no item-owned filter is mutated or raced (Validate mutates a select clause). - The projection is computed in the SDK's order: where clause first, fields only for an item that will queue. - Unambiguous locale key; counter for filters given a private group past the registry cap; DeliveryCache getter returns its own allocation. - UseProjectedEventDelivery=false also asks the role and grant checks per item, so the equivalence test compares against the pre-optimisation path. - Equivalence test: same select list under three where clauses, a filter modified mid-run, a session with other locales, an ungranted anonymous Server-object item, per-rig counter; plus a small overflowing queue with DiscardOldest true and false. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk --- ...26-11-11-folder-event-per-item-delivery.md | 18 +- .../OtOpcUaNodeManager.cs | 35 +++- .../ProjectedEventDelivery.cs | 185 ++++++++++++----- .../ProjectedEventDeliveryWireTests.cs | 195 +++++++++++++----- .../ServerEventRig.cs | 4 +- 5 files changed, 317 insertions(+), 120 deletions(-) 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); }