fix(opcua): Server-object ConditionRefresh off Lock unless deferring; cut path O(conditions) with replay queued off Lock (#854 review)
Review of1622d07f: 1. Perf: every i=2253 refresh ran the whole notifier walk + permission checks + QueueEvent under Lock. Now, with nothing deferred (the common case), the refresh is the SDK-shaped walk off Lock (Lock only to copy the root notifiers), kept only if no deferral episode existed at any moment of it (count 0 before and after, episode unchanged); otherwise the cut path also runs and its later replay supersedes. The cut path takes ACL/role verdicts off Lock, then one short Lock hold: watermark + frozen snapshots of Retain conditions from _alarmConditions (no tree walk) + per-item cut and "replay pending" marker; the replay is queued OFF Lock, one event per hold of the item gate. While pending, a condition event newer than the cut marks its condition so the older frozen entry is dropped rather than following it. A concurrent fast-path refresh cancels a pending cut replay of the same item. Measured, 3000 retained conditions, longest Lock wait seen by another thread during one refresh:1622d07f95-106 ms idle / 102-127 ms deferring; now 0-3 ms idle / 23-32 ms deferring. 2. ACL: a retained condition omitted from the replay for want of a grant is never withheld (RefreshCut.RetainedAtCut); only Retain=true events of a condition omitted because it was NOT retained are. 3. One gate/cut state per item for its lifetime (ConditionalWeakTable keyed by the item): an unsubscribe + resubscribe between refresh and drain keeps the cut. 4. Cuts carry their deferral episode; ClearStaleRefreshCuts runs when the count returns to zero, and a stale-episode cut is inert. 5. Dispose guard for the custom refresh (ObjectDisposedException once disposing; Dispose waits for refreshes in flight). 6. Docs: a refresh waits at most for one in-progress QueueEvent on the item. Tests: resubscribe variants, ACL-grant-lands test, pending-replay probe test, dispose test, 3000-condition Lock-hold measurement with a probe pinning that neither path queues its replay under Lock; stress invariant tightened (never backwards outside a refresh; each refresh ends on the newest delivered state) and tolerant of the SDK's own BadRefreshInProgress overlap refusal. Each new test fails against its targeted mutation.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
@@ -620,6 +621,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// #854 review: refuse new Server-object refreshes and let the ones in flight finish (the base's private
|
||||
// NodeManagerOperation guard does this for its own paths).
|
||||
_disposing = true;
|
||||
SpinWait.SpinUntil(() => Volatile.Read(ref _refreshesInFlight) == 0, TimeSpan.FromSeconds(10));
|
||||
Volatile.Read(ref _dataPlaneAuthority).Changed -= OnAuthorityChanged;
|
||||
_liveItems.Clear();
|
||||
_eventItems.Clear();
|
||||
@@ -1554,19 +1559,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {NodeId}", condition.NodeId);
|
||||
}
|
||||
|
||||
IFilterTarget next;
|
||||
IFilterTarget? next = null;
|
||||
var episodeEnded = false;
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
CountDeferred(-1, condition);
|
||||
if (queue.Pending.Count == 0)
|
||||
{
|
||||
_deferredReports.Remove(condition);
|
||||
return;
|
||||
episodeEnded = _deferredEventReports == 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
next = queue.Pending.Dequeue();
|
||||
}
|
||||
|
||||
next = queue.Pending.Dequeue();
|
||||
}
|
||||
|
||||
if (episodeEnded)
|
||||
{
|
||||
ClearStaleRefreshCuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (next is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
current = DeliverConditionEventAsync(condition, condition, next).AsTask();
|
||||
@@ -1641,7 +1657,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
condition.NodeId, MaxDeferredEventsPerCondition);
|
||||
}
|
||||
}
|
||||
else if (_deferredEventReports == 0 && before > 0 && _episodeWarned)
|
||||
if (_deferredEventReports == 0 && before > 0)
|
||||
{
|
||||
// #854: every deferred event has been delivered, so no refresh cut taken during this episode can match
|
||||
// anything any more; a new number makes them all stale at once (see RefreshCut.Episode).
|
||||
_deferralEpisode++;
|
||||
}
|
||||
|
||||
if (_deferredEventReports == 0 && before > 0 && _episodeWarned)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"OtOpcUaNodeManager: deferred condition events drained ({Deferred} deferred, {Discarded} discarded this episode)",
|
||||
@@ -1704,21 +1727,94 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
/// <summary>Under <see cref="Gate"/>.</summary>
|
||||
public RefreshCut? Cut;
|
||||
|
||||
/// <summary>
|
||||
/// The refresh whose frozen replay is being queued to the item (0 = none). Set under <c>Lock</c> AND the gate
|
||||
/// at the cut; cleared under the gate once queued. While set, every condition event delivered to the item
|
||||
/// takes the gate, and one newer than the cut marks its condition in <see cref="NewerSinceCut"/> so the replay
|
||||
/// does not follow it with an older state.
|
||||
/// </summary>
|
||||
public volatile int PendingReplay;
|
||||
|
||||
/// <summary>Under <see cref="Gate"/>: the watermark of <see cref="PendingReplay"/>.</summary>
|
||||
public long PendingWatermark;
|
||||
|
||||
/// <summary>Under <see cref="Gate"/>: conditions an event newer than the pending replay already reached.</summary>
|
||||
public readonly HashSet<NodeId> NewerSinceCut = [];
|
||||
}
|
||||
|
||||
private int _refreshGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// One <see cref="AllEventsItemState"/> per Server-object item for the item's whole lifetime (#854 review): keyed by
|
||||
/// the item object itself, so an unsubscribe + resubscribe of the same item between a refresh and the drain keeps its
|
||||
/// cut and its gate, and a drain holding the state always holds the one a refresh installs into.
|
||||
/// </summary>
|
||||
private readonly ConditionalWeakTable<IEventMonitoredItem, AllEventsItemState> _allEventsItemStates = new();
|
||||
|
||||
/// <summary>Server-object items currently holding a refresh cut (test observation, #854 review).</summary>
|
||||
internal int ActiveRefreshCuts
|
||||
{
|
||||
get
|
||||
{
|
||||
var n = 0;
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
{
|
||||
if (!_allEventsItemStates.TryGetValue(item, out var state)) continue;
|
||||
lock (state.Gate) n += state.Cut is null ? 0 : 1;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
private AllEventsItemState AllEventsItemStateOf(IEventMonitoredItem item) =>
|
||||
_allEventsItemStates.GetValue(item, static _ => new AllEventsItemState());
|
||||
|
||||
/// <summary>
|
||||
/// Advanced (under the event-report gate) each time the deferral count returns to zero: the end of a deferral
|
||||
/// episode, after which no event a cut could withhold is still undelivered (#854 review).
|
||||
/// </summary>
|
||||
private long _deferralEpisode;
|
||||
|
||||
/// <summary>What a Server-object item's latest ConditionRefresh conveyed (#854).</summary>
|
||||
/// <param name="Watermark">Every condition event with a sequence at or below this was reported before the replay was read.</param>
|
||||
/// <param name="Refreshed">The conditions the replay re-sent to the item; the others were Retain=false (or not visible).</param>
|
||||
private sealed record RefreshCut(long Watermark, HashSet<NodeId> Refreshed)
|
||||
/// <param name="Episode">The deferral episode the cut was taken in; once it ends the cut is stale and inert.</param>
|
||||
/// <param name="Refreshed">The conditions the replay re-sent to THIS item.</param>
|
||||
/// <param name="RetainedAtCut">Every condition that was Retain at the watermark, whether or not this item was sent it
|
||||
/// (one set shared by every item of the refresh).</param>
|
||||
private sealed record RefreshCut(long Watermark, long Episode, HashSet<NodeId> Refreshed, HashSet<NodeId> RetainedAtCut)
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the refresh already told the item something at least as new as <paramref name="e"/>: a re-sent
|
||||
/// condition's current state supersedes every older event of it; for a condition the replay OMITTED (it was not
|
||||
/// retained), an older Retain=true event would wrongly re-add it, while an older Retain=false event agrees with
|
||||
/// the omission and is still delivered (a client that did not reconcile on RefreshEnd needs it).
|
||||
/// Whether the refresh already told the item something at least as new as <paramref name="e"/>. A re-sent
|
||||
/// condition: its replayed state supersedes every older event of it. A condition the replay omitted because it
|
||||
/// was NOT retained: an older Retain=true event would wrongly re-add it, so it is withheld; an older Retain=false
|
||||
/// event agrees with the omission and is delivered (a client that did not reconcile on RefreshEnd needs it). A
|
||||
/// condition that WAS retained but was omitted for this item (ACL / role at refresh time): never withheld — the
|
||||
/// refresh told the item nothing about it, so a grant added before the drain still lets its events through
|
||||
/// (decided by the ACL at delivery, as ever).
|
||||
/// </summary>
|
||||
public bool Supersedes(ConditionEventSnapshot e) =>
|
||||
e.Sequence <= Watermark && (e.Retain || Refreshed.Contains(e.ConditionId));
|
||||
e.Sequence <= Watermark
|
||||
&& (Refreshed.Contains(e.ConditionId) || (e.Retain && !RetainedAtCut.Contains(e.ConditionId)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops every refresh cut taken in an episode that has ended (#854 review), so a cut does not outlive the events it
|
||||
/// guards. Called by the drain after the deferral count reached zero, holding no lock; a cut installed concurrently
|
||||
/// for the new episode is kept.
|
||||
/// </summary>
|
||||
private void ClearStaleRefreshCuts()
|
||||
{
|
||||
var current = Interlocked.Read(ref _deferralEpisode);
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
{
|
||||
if (!_allEventsItemStates.TryGetValue(item, out var state)) continue;
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (state.Cut is { } cut && cut.Episode != current) state.Cut = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5362,7 +5458,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// unsubscribe arms, which the SDK's <c>MasterNodeManager</c> drives for every create, modify,
|
||||
/// transfer, restore and delete of an all-events item, subscription teardown included.
|
||||
/// </remarks>
|
||||
private readonly ConcurrentDictionary<IEventMonitoredItem, AllEventsItemState> _allEventsItems = new();
|
||||
private readonly ConcurrentDictionary<IEventMonitoredItem, byte> _allEventsItems = new();
|
||||
|
||||
// The SDK's base implementation attaches a Server-object item to the event
|
||||
// fan-out of every one of this manager's root notifiers, so every alarm in the server reached it
|
||||
@@ -5384,7 +5480,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
lock (Lock)
|
||||
{
|
||||
if (unsubscribe) _allEventsItems.TryRemove(monitoredItem, out _);
|
||||
else _allEventsItems.GetOrAdd(monitoredItem, static _ => new AllEventsItemState());
|
||||
else _allEventsItems[monitoredItem] = 0;
|
||||
ReconcileAllEventsMonitoring();
|
||||
}
|
||||
|
||||
@@ -5560,7 +5656,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
var reportingSession = (context as ISessionSystemContext)?.SessionId;
|
||||
var deferred = e is ConditionEventSnapshot { Deferred: true } d ? d : null;
|
||||
|
||||
foreach (var (item, state) in _allEventsItems)
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
{
|
||||
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
@@ -5572,25 +5668,36 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
continue;
|
||||
}
|
||||
|
||||
if (deferred is null)
|
||||
// #854: a synchronous event (under Lock) needs the gate only while a refresh's replay is pending, and the
|
||||
// pending marker is installed under Lock, so an item no refresh has touched has nothing to order. A deferred
|
||||
// event always takes the gate — its state is created here if need be, so a refresh racing it shares it.
|
||||
AllEventsItemState? state;
|
||||
if (deferred is not null) state = AllEventsItemStateOf(item);
|
||||
else if (!_allEventsItemStates.TryGetValue(item, out state) || state.PendingReplay == 0 || e is not ConditionEventSnapshot)
|
||||
{
|
||||
item.QueueEvent(e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// #854: a deferred event reaches this item off Lock, so a ConditionRefresh of the item may have replayed a
|
||||
// newer state since it was reported. Decided and queued under the item's gate, which the refresh holds while
|
||||
// it queues its replay and installs its cut — so it lands either before the replay or not at all.
|
||||
// A deferred event reaches this item off Lock, so a ConditionRefresh of the item may have replayed a newer
|
||||
// state since it was reported; and while a refresh's frozen replay is being queued, an event newer than it must
|
||||
// not be followed by it. Both are decided and queued under the item's gate, which the refresh also holds for
|
||||
// each replayed event it queues — so a deferred event lands before the replay or is judged against the cut.
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (state.Cut?.Supersedes(deferred) == true)
|
||||
// A cut from an episode that has ended is stale (only waiting for ClearStaleRefreshCuts).
|
||||
if (deferred is not null
|
||||
&& state.Cut is { } cut && cut.Episode == Interlocked.Read(ref _deferralEpisode) && cut.Supersedes(deferred))
|
||||
{
|
||||
Interlocked.Increment(ref _refreshSupersededDeliveries);
|
||||
OtOpcUaTelemetry.ConditionEventDeliveriesSuperseded.Add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
DeferredServerObjectDeliveryProbe?.Invoke();
|
||||
if (state.PendingReplay != 0 && e is ConditionEventSnapshot { } ce && ce.Sequence > state.PendingWatermark)
|
||||
state.NewerSinceCut.Add(ce.ConditionId);
|
||||
|
||||
if (deferred is not null) DeferredServerObjectDeliveryProbe?.Invoke();
|
||||
item.QueueEvent(e);
|
||||
}
|
||||
}
|
||||
@@ -6174,26 +6281,47 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// <inheritdoc />
|
||||
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> monitoredItems)
|
||||
{
|
||||
var direct = new List<IEventMonitoredItem>();
|
||||
var serverItems = new List<IEventMonitoredItem>();
|
||||
foreach (var item in monitoredItems)
|
||||
// The base's NodeManagerOperation guard is private; this is its equivalent for the custom paths (#854 review):
|
||||
// refused once disposal starts, and Dispose waits for the ones in flight.
|
||||
Interlocked.Increment(ref _refreshesInFlight);
|
||||
try
|
||||
{
|
||||
if (item is null) continue;
|
||||
(item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item);
|
||||
}
|
||||
if (_disposing) throw new ObjectDisposedException(GetType().Name);
|
||||
|
||||
var enforced = AclEnforced;
|
||||
var systemContext = SystemContext.Copy(context);
|
||||
if (direct.Count > 0)
|
||||
var direct = new List<IEventMonitoredItem>();
|
||||
var serverItems = new List<IEventMonitoredItem>();
|
||||
foreach (var item in monitoredItems)
|
||||
{
|
||||
if (item is null) continue;
|
||||
(item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item);
|
||||
}
|
||||
|
||||
var enforced = AclEnforced;
|
||||
var systemContext = SystemContext.Copy(context);
|
||||
if (direct.Count > 0)
|
||||
{
|
||||
if (enforced) RefreshDirectItems(systemContext, direct);
|
||||
else base.ConditionRefresh(context, direct);
|
||||
}
|
||||
|
||||
if (serverItems.Count > 0) RefreshServerObjectItems(systemContext, serverItems, enforced);
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (enforced) RefreshDirectItems(systemContext, direct);
|
||||
else base.ConditionRefresh(context, direct);
|
||||
Interlocked.Decrement(ref _refreshesInFlight);
|
||||
}
|
||||
|
||||
if (serverItems.Count > 0) RefreshServerObjectItems(systemContext, serverItems, enforced);
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
private int _refreshesInFlight;
|
||||
private volatile bool _disposing;
|
||||
|
||||
/// <summary>
|
||||
/// Test probe (#854 review): called once per Server-object refresh with whether it took the cut path and whether
|
||||
/// the calling thread held <c>Lock</c> while it queued the replay. Null in production.
|
||||
/// </summary>
|
||||
internal Action<bool, bool>? ServerObjectRefreshProbe { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ConditionRefresh (and ConditionRefresh2) for Server-object (<c>i=2253</c>) items: each condition under a root
|
||||
/// notifier once, through the item's ACL decision when <paramref name="enforced"/> (#586) — and never overtaken by
|
||||
@@ -6211,84 +6339,224 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// refresh had just dropped re-added, until the newest drained.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The cut.</b> Everything — the watermark (<see cref="_conditionEventSequence"/>), whether any event is
|
||||
/// deferred, the replay itself and its queueing — happens in ONE hold of <c>Lock</c>, under which every condition
|
||||
/// event is reported. So the replay is exactly the state at the watermark: an event at or below it is not newer
|
||||
/// than the replay, one above it was reported after it. Each item's replay is queued and its
|
||||
/// <see cref="RefreshCut"/> installed under the item's <see cref="AllEventsItemState.Gate"/>, which a deferred
|
||||
/// delivery to the item also holds (<see cref="OnConditionReportEvent"/>): a deferred event therefore lands
|
||||
/// before the replay, or is judged against the cut — withheld if superseded (<see cref="RefreshCut.Supersedes"/>).
|
||||
/// Only events marked <see cref="ConditionEventSnapshot.Deferred"/> are judged: any other was delivered to
|
||||
/// Server-object items synchronously under <c>Lock</c>, so it cannot follow a replay it predates.
|
||||
/// <b>Common case — nothing deferred: unchanged, and off <c>Lock</c>.</b> The replay is the notifier walk the SDK
|
||||
/// base does, queued as live condition nodes, with <c>Lock</c> taken only to copy the root-notifier list. It is
|
||||
/// kept only if no deferral episode existed at any moment of it: the deferral count is zero before AND after and
|
||||
/// the episode number (<see cref="_deferralEpisode"/>) did not move. Otherwise a deferred event may have reached
|
||||
/// the item after the replay, so the cut path below runs as well — its second replay, queued later inside the
|
||||
/// same RefreshStart/RefreshEnd, supersedes anything that slipped in between.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deferring — the cut.</b> In ONE hold of <c>Lock</c> (under which every condition event is reported): the
|
||||
/// watermark (<see cref="_conditionEventSequence"/>), the episode, and a frozen snapshot of each Retain
|
||||
/// condition read from <see cref="_alarmConditions"/> (O(conditions), no tree walk), then queued to each item
|
||||
/// with its <see cref="RefreshCut"/> installed under the item's <see cref="AllEventsItemState.Gate"/>. The ACL /
|
||||
/// role verdicts are computed BEFORE taking <c>Lock</c>, against the live conditions (they depend on the event
|
||||
/// type and source, not state). The cut and a "replay pending" marker are installed per item under its
|
||||
/// <see cref="AllEventsItemState.Gate"/> inside that <c>Lock</c> hold; the replay is then queued OFF
|
||||
/// <c>Lock</c>, one event per hold of the gate. While the marker is set every condition event to the item takes
|
||||
/// the gate: one newer than the cut marks its condition so its (older) frozen replay entry is dropped instead of
|
||||
/// following it; a deferred one at or below the cut is judged against the cut (<see cref="RefreshCut.Supersedes"/>)
|
||||
/// — it lands before the replay entry or not at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Per item, never global.</b> Only the refreshed item stops receiving the superseded events; the deferral
|
||||
/// queue, every other Server-object item and every folder item still get them, in order. The refresh never
|
||||
/// waits for a drain. No cut is kept while nothing is deferred (the common case).
|
||||
/// queue, every other Server-object item and every folder item still get them, in order. A refresh waits for a
|
||||
/// drain at most for one in-progress <c>QueueEvent</c> on that item (the gate), never for the drain itself.
|
||||
/// Cuts die with their episode (<see cref="ClearStaleRefreshCuts"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lock order</b> is <c>Lock</c> → item gate → the SDK item's own lock, the same as a synchronous report
|
||||
/// (<c>Lock</c> → <see cref="OnConditionReportEvent"/>); the drain takes the item gate without <c>Lock</c>, and
|
||||
/// nothing under an item gate takes <c>Lock</c> or the event-report gate.
|
||||
/// (<c>Lock</c> → <see cref="OnConditionReportEvent"/>); the drain and the replay queueing take the item gate
|
||||
/// without <c>Lock</c>, and nothing under an item gate takes <c>Lock</c> or the event-report gate. A gate is held
|
||||
/// for one <c>QueueEvent</c> at a time, so a reporter holding <c>Lock</c> waits at most for one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// With enforcement off this replaces the SDK base's Server-object loop with the same result: the base queued
|
||||
/// one copy per root notifier a condition is wired to, which the item's queue collapsed to one (they share an
|
||||
/// EventId); here it is one per condition. A replayed event that names no condition is withheld only under
|
||||
/// enforcement (fail closed), as before.
|
||||
/// enforcement (fail closed), as before. The cut path replays from <see cref="_alarmConditions"/>, which holds
|
||||
/// every condition this manager materialises (all of them sit under a root notifier); branches are replayed as
|
||||
/// the SDK's <c>ConditionState.ConditionRefresh</c> lists them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RefreshServerObjectItems(ServerSystemContext systemContext, List<IEventMonitoredItem> items, bool enforced)
|
||||
{
|
||||
lock (Lock)
|
||||
long episodeBefore;
|
||||
bool deferringBefore;
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
var watermark = Interlocked.Read(ref _conditionEventSequence);
|
||||
bool deferring;
|
||||
lock (_eventReportGate) deferring = _deferredEventReports > 0;
|
||||
episodeBefore = _deferralEpisode;
|
||||
deferringBefore = _deferredEventReports > 0;
|
||||
}
|
||||
|
||||
if (!deferringBefore)
|
||||
{
|
||||
List<NodeState> notifiers;
|
||||
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
|
||||
var events = new List<IFilterTarget>();
|
||||
foreach (var notifier in RootNotifiers?.ToList() ?? []) notifier.ConditionRefresh(systemContext, events, true);
|
||||
|
||||
// One event per condition, with its scopes resolved once.
|
||||
var replay = new List<(IFilterTarget Event, NodeState? Condition, List<NodeId>? Scopes)>();
|
||||
var seen = new HashSet<NodeState>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
foreach (var e in events)
|
||||
{
|
||||
if (ConditionOf(e) is not { } condition)
|
||||
{
|
||||
if (!enforced) replay.Add((e, null, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(condition)) continue;
|
||||
replay.Add((e, condition, enforced ? ConditionVisibilityScopes(condition) : null));
|
||||
}
|
||||
foreach (var notifier in notifiers) notifier.ConditionRefresh(systemContext, events, true);
|
||||
|
||||
var replay = DedupeReplay(events, enforced);
|
||||
ServerObjectRefreshProbe?.Invoke(false, Monitor.IsEntered(Lock));
|
||||
foreach (var item in items)
|
||||
{
|
||||
var queued = new List<IFilterTarget>();
|
||||
var refreshed = new HashSet<NodeId>();
|
||||
foreach (var (e, condition, scopes) in replay)
|
||||
// A cut-path replay of this item still being queued by a concurrent refresh is older than this live one:
|
||||
// stop it, so it cannot follow this replay with frozen states.
|
||||
if (_allEventsItemStates.TryGetValue(item, out var pending))
|
||||
{
|
||||
lock (pending.Gate)
|
||||
{
|
||||
pending.PendingReplay = 0;
|
||||
pending.NewerSinceCut.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (e, _, scopes) in replay)
|
||||
{
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
queued.Add(e);
|
||||
if (condition is not null) refreshed.Add(condition.NodeId);
|
||||
item.QueueEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_allEventsItems.TryGetValue(item, out var state))
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
if (_deferralEpisode == episodeBefore && _deferredEventReports == 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
RefreshServerObjectItemsWithCut(systemContext, items, enforced);
|
||||
}
|
||||
|
||||
/// <summary>One replay entry per condition (the first the walk produced), with its ACL scopes resolved once.</summary>
|
||||
private List<(IFilterTarget Event, NodeState? Condition, List<NodeId>? Scopes)> DedupeReplay(
|
||||
List<IFilterTarget> events, bool enforced)
|
||||
{
|
||||
var replay = new List<(IFilterTarget Event, NodeState? Condition, List<NodeId>? Scopes)>();
|
||||
var seen = new HashSet<NodeState>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
foreach (var e in events)
|
||||
{
|
||||
if (ConditionOf(e) is not { } condition)
|
||||
{
|
||||
if (!enforced) replay.Add((e, null, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(condition)) continue;
|
||||
replay.Add((e, condition, enforced ? ConditionVisibilityScopes(condition) : null));
|
||||
}
|
||||
|
||||
return replay;
|
||||
}
|
||||
|
||||
/// <summary>The cut path of <see cref="RefreshServerObjectItems"/> — see there.</summary>
|
||||
private void RefreshServerObjectItemsWithCut(ServerSystemContext systemContext, List<IEventMonitoredItem> items, bool enforced)
|
||||
{
|
||||
// ACL / role verdicts per (item, condition) before Lock: they depend on the event type, source and grants only.
|
||||
List<AlarmConditionState> conditions;
|
||||
lock (Lock) conditions = [.. _alarmConditions.Values];
|
||||
var known = new HashSet<AlarmConditionState>(conditions, System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
var allowed = new HashSet<(IEventMonitoredItem, AlarmConditionState)>();
|
||||
foreach (var condition in conditions)
|
||||
{
|
||||
var scopes = enforced ? ConditionVisibilityScopes(condition) : null;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, condition))) continue;
|
||||
allowed.Add((item, condition));
|
||||
}
|
||||
}
|
||||
|
||||
var states = items.Select(AllEventsItemStateOf).ToList();
|
||||
var perItem = new List<(IFilterTarget Event, AlarmConditionState Condition)>[items.Count];
|
||||
var generation = Interlocked.Increment(ref _refreshGeneration);
|
||||
lock (Lock)
|
||||
{
|
||||
var watermark = Interlocked.Read(ref _conditionEventSequence);
|
||||
long episode;
|
||||
bool deferring;
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
episode = _deferralEpisode;
|
||||
deferring = _deferredEventReports > 0;
|
||||
}
|
||||
|
||||
// Frozen at the watermark: the replay is exactly the state every event at or below it describes.
|
||||
var retained = new HashSet<NodeId>();
|
||||
var replay = new List<(IFilterTarget Event, AlarmConditionState Condition)>();
|
||||
var branches = new List<IFilterTarget>();
|
||||
foreach (var condition in _alarmConditions.Values)
|
||||
{
|
||||
if (condition.Retain?.Value != true) continue;
|
||||
retained.Add(condition.NodeId);
|
||||
branches.Clear();
|
||||
condition.ConditionRefresh(SystemContext, branches, true);
|
||||
foreach (var target in branches)
|
||||
{
|
||||
// Not (or no longer) subscribed: no deferred delivery can reach it, so there is nothing to cut.
|
||||
foreach (var e in queued) item.QueueEvent(e);
|
||||
continue;
|
||||
if (target is not BaseInstanceState instance) continue;
|
||||
var snapshot = new InstanceStateSnapshot();
|
||||
snapshot.Initialize(SystemContext, instance);
|
||||
replay.Add((snapshot, condition));
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
var entries = new List<(IFilterTarget Event, AlarmConditionState Condition)>();
|
||||
var refreshed = new HashSet<NodeId>();
|
||||
foreach (var (e, condition) in replay)
|
||||
{
|
||||
// A condition materialised since the verdicts were taken is decided here (rare).
|
||||
if (!allowed.Contains((item, condition)))
|
||||
{
|
||||
if (known.Contains(condition)) continue;
|
||||
if (enforced && !IsConditionVisibleTo(item, ConditionVisibilityScopes(condition))) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
}
|
||||
|
||||
entries.Add((e, condition));
|
||||
refreshed.Add(condition.NodeId);
|
||||
}
|
||||
|
||||
perItem[i] = entries;
|
||||
var state = states[i];
|
||||
lock (state.Gate)
|
||||
{
|
||||
foreach (var e in queued) item.QueueEvent(e);
|
||||
// A cut is only needed while an event at or below the watermark may still be delivered. Refreshes are
|
||||
// serialised by Lock, so a later one always carries the later watermark and simply replaces it.
|
||||
state.Cut = deferring ? new RefreshCut(watermark, refreshed) : null;
|
||||
state.Cut = deferring ? new RefreshCut(watermark, episode, refreshed, retained) : null;
|
||||
state.PendingWatermark = watermark;
|
||||
state.NewerSinceCut.Clear();
|
||||
state.PendingReplay = generation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queued off Lock, one replayed event per hold of the item gate: an event that reached the item since the cut is
|
||||
// newer than the frozen replay of its condition, so that replay entry is dropped rather than queued after it (the
|
||||
// newer event is already inside RefreshStart..RefreshEnd). A later refresh of the item supersedes this one.
|
||||
ServerObjectRefreshProbe?.Invoke(true, Monitor.IsEntered(Lock));
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
var state = states[i];
|
||||
foreach (var (e, condition) in perItem[i])
|
||||
{
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (state.PendingReplay != generation) break;
|
||||
if (state.NewerSinceCut.Contains(condition.NodeId)) continue;
|
||||
item.QueueEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (state.PendingReplay == generation)
|
||||
{
|
||||
state.PendingReplay = 0;
|
||||
state.NewerSinceCut.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user