fix(opcua): #855 perf review 1 — groups evaluate their own filter clone; lazy fields; tighter equivalence tests
- 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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1711,19 +1711,31 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Items this node manager served through the projected path (test observation: it is really taken).</summary>
|
||||
internal long ProjectedDeliveries => Interlocked.Read(ref _projectedDeliveries);
|
||||
|
||||
/// <summary>
|
||||
/// Whether condition events are queued through the report's shared projection (#855 perf, default) or through the
|
||||
/// SDK's own <c>QueueEvent(IFilterTarget)</c> per item. A test switch: the two must deliver identical events.
|
||||
/// SDK's own <c>QueueEvent(IFilterTarget)</c> per item, with the role and grant checks asked per item (the
|
||||
/// pre-optimisation path). A test switch: the two must deliver identical events.
|
||||
/// </summary>
|
||||
internal bool UseProjectedEventDelivery { get; set; } = true;
|
||||
|
||||
/// <summary>The SDK's role-permission verdict for one item, shared per (session, identity) within a report (#855 perf).</summary>
|
||||
private bool RoleAllows(IEventMonitoredItem item, IFilterTarget e) =>
|
||||
e is ConditionEventSnapshot snapshot
|
||||
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<NodeId> scopes, Dictionary<object, bool> 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 <c>Lock</c>); dropped when the
|
||||
/// walk ends.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops the delivery cache once the walk is over (the snapshot stays referenced from the item queues).</summary>
|
||||
public void ReleaseDeliveryCache() => Volatile.Write(ref _deliveryCache, null);
|
||||
|
||||
@@ -57,7 +57,8 @@ internal sealed class EventDeliveryCache
|
||||
/// <remarks>
|
||||
/// The SDK check builds an <c>OperationContext</c> from the item and asks <c>ReceiveEvents</c> on the event's type
|
||||
/// and source for that context's identity and session — nothing else of the item takes part, so two items of one
|
||||
/// session and identity always get the same answer for one event.
|
||||
/// 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 <c>RolePermissions</c> change between two hops lands one event later.
|
||||
/// </remarks>
|
||||
public bool RoleAllows(IEventMonitoredItem item, Func<IEventMonitoredItem, bool> compute)
|
||||
{
|
||||
@@ -80,16 +81,20 @@ internal sealed class EventDeliveryCache
|
||||
/// <param name="item">The event item.</param>
|
||||
/// <param name="e">The condition event.</param>
|
||||
/// <param name="projected">False routes it through the SDK's own <c>QueueEvent</c> (the node manager's test switch).</param>
|
||||
public void Queue(IServerInternal server, IEventMonitoredItem item, IFilterTarget e, bool projected)
|
||||
/// <returns>True when the projected path handled the item (queued, or decided not to queue exactly as the SDK would).</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Marks the item as queued for this report; false when it already was (the SDK's duplicate check).</summary>
|
||||
@@ -98,28 +103,67 @@ internal sealed class EventDeliveryCache
|
||||
lock (_sync) return _queued.Add(item);
|
||||
}
|
||||
|
||||
/// <summary>The projection of the event for a filter group and locale list, computed on first use.</summary>
|
||||
internal Projection ProjectionFor(FilterGroup group, string locales, Func<Projection> compute)
|
||||
/// <summary>The projection of the event for a filter group and locale list, created on first use.</summary>
|
||||
internal Projection ProjectionFor(FilterGroup group, string locales, Func<Projection> 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;
|
||||
}
|
||||
|
||||
/// <summary>The event as one filter sees it: whether the where clause passes, and the selected field values.</summary>
|
||||
/// <remarks>The fields are shared by every item queued from this projection: <see cref="ArrayOf{T}"/> is a read-only view, and the array behind it is never written after it is built.</remarks>
|
||||
internal sealed record Projection(bool Passes, ArrayOf<Variant> Fields);
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The fields are shared by every item queued from this projection: <see cref="ArrayOf{T}"/> is a read-only view,
|
||||
/// and the array behind it is never written after it is built. Only the report's own walk uses a projection, one
|
||||
/// step at a time; the lazies are thread-safe regardless.
|
||||
/// </remarks>
|
||||
internal sealed class Projection(Func<bool> passes, Func<ArrayOf<Variant>> fields)
|
||||
{
|
||||
private readonly Lazy<bool> _passes = new(passes, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
private readonly Lazy<ArrayOf<Variant>> _fields = new(fields, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public bool Passes => _passes.Value;
|
||||
|
||||
public ArrayOf<Variant> Fields => _fields.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A set of value-equal <see cref="EventFilter"/>s (one canonical representative), so they can share a projection.</summary>
|
||||
internal sealed class FilterGroup(EventFilter representative)
|
||||
/// <summary>
|
||||
/// A set of value-equal <see cref="EventFilter"/>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: <c>Validate</c> mutates a select clause).
|
||||
/// </summary>
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The group's own copy of the filter; evaluated only under <see cref="Gate"/>.</summary>
|
||||
public EventFilter Representative { get; }
|
||||
|
||||
/// <summary>Per select clause: rejected (always projected as null), as the SDK decides once per filter.</summary>
|
||||
public bool[] Rejected { get; }
|
||||
|
||||
/// <summary>Serialises evaluation of <see cref="Representative"/> (two reports may project it at once).</summary>
|
||||
public object Gate { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>The SDK internals the projected fast path reads, resolved once by reflection — see <see cref="EventDeliveryCache"/>.</summary>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The SDK's where-clause evaluation and <c>GetEventFields</c> projection, for one filter and locale list.</summary>
|
||||
private static EventDeliveryCache.Projection Project(
|
||||
IServerInternal server, EventFilter filter, string[]? locales, IFilterTarget e)
|
||||
/// <summary>The SDK's <c>CanSendFilteredAlarm</c> without filtered retain: the where clause, for one group and locale list.</summary>
|
||||
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<Variant>)fields);
|
||||
}
|
||||
|
||||
/// <summary>The SDK's <c>GetEventFields</c> values, for one group and locale list.</summary>
|
||||
private static ArrayOf<Variant> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The SDK's per-item <c>FilterContext</c>: the server's namespaces and type tree, the session's locales (null included).</summary>
|
||||
private static FilterContext ContextFor(IServerInternal server, string[]? locales) =>
|
||||
new(server.NamespaceUris, server.TypeTree, locales!, server.Telemetry);
|
||||
|
||||
/// <summary>The canonical group of a filter object: value-equal filters share one (decided once per filter object).</summary>
|
||||
private static FilterGroup GroupOf(EventFilter filter) =>
|
||||
s_groups.GetValue(filter, static f =>
|
||||
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;
|
||||
|
||||
/// <summary>Filters given a private group because the shared registry is full (observability; sharing stops, delivery is unchanged).</summary>
|
||||
internal static long UnsharedFilterGroups => Interlocked.Read(ref s_unsharedGroups);
|
||||
|
||||
/// <summary>A cheap bucket key over the select clauses (equality within a bucket is decided by <c>Utils.IsEqual</c>).</summary>
|
||||
private static string SelectKey(EventFilter filter)
|
||||
{
|
||||
@@ -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);
|
||||
/// <summary>An unambiguous key for a locale list (length-prefixed entries; null distinct from empty).</summary>
|
||||
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<SdkMonitoredItem, T?>? FieldGetter<T>(string name) where T : class
|
||||
{
|
||||
|
||||
+146
-49
@@ -11,16 +11,24 @@ using MonitoredItem = Opc.Ua.Client.MonitoredItem;
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// #855 perf: condition events are queued to many items through one shared projection per (filter, locales) instead
|
||||
/// of the SDK's per-item <c>QueueEvent(IFilterTarget)</c>. The two paths must deliver the SAME events with the SAME
|
||||
/// fields: this runs one scenario through each (the node manager's switch) and compares every item's stream —
|
||||
/// folder and Server-object items, value-equal filters on distinct objects, a where clause that rejects some events,
|
||||
/// a where clause on the type, a narrower select list, and a Disabled item.
|
||||
/// #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 <c>QueueEvent(IFilterTarget)</c> 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 <c>UseProjectedEventDelivery</c> switch, which also turns the verdict sharing off) and
|
||||
/// compare every item's stream.
|
||||
/// </summary>
|
||||
public sealed class ProjectedEventDeliveryWireTests
|
||||
{
|
||||
private const string EqA = ServerEventRig.EqA;
|
||||
|
||||
private static readonly ushort[] Severities = [700, 900, 300, 850, 801, 800, 999, 950];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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<List<string>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One phase's events of an item, with the per-event identity (EventId, the phase prefix) removed.</summary>
|
||||
private static List<string> Normalised(IReadOnlyList<ArrayOf<Variant>> stream, string prefix) =>
|
||||
stream
|
||||
@@ -124,29 +216,34 @@ public sealed class ProjectedEventDeliveryWireTests
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>EventId, Message, Severity — only events with Severity > 800.</summary>
|
||||
private static EventFilter SevereOnly()
|
||||
/// <summary>EventId, Message, Severity — with the optional where clause Severity > <paramref name="severityAbove"/>.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>EventId, Message, Retain, a field the event does not have — OfType AlarmConditionType.</summary>
|
||||
private static EventFilter OfTypeAlarm()
|
||||
/// <summary>OfType AlarmConditionType: EventId, Message, Retain, a field the event lacks (or, alternatively, Severity).</summary>
|
||||
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;
|
||||
|
||||
@@ -298,7 +298,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
.Invoke(server, [on]);
|
||||
}
|
||||
|
||||
public async Task<ISession> ConnectAsync(bool anonymous, CancellationToken ct, bool encrypted = false)
|
||||
public async Task<ISession> 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<string>)locales : default,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user