Merge #854 ConditionRefresh not overtaken by deferred condition events (Server-object items)
v2-ci / unit-tests (push) Failing after 1h12m23s

This commit is contained in:
Joseph Doherty
2026-11-11 07:39:25 -05:00
5 changed files with 1278 additions and 55 deletions
@@ -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,13 @@ 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;
Interlocked.MemoryBarrier(); // the flag write must not be reordered after the in-flight read (#854 re-review)
if (!SpinWait.SpinUntil(() => Volatile.Read(ref _refreshesInFlight) == 0, TimeSpan.FromSeconds(10)))
_logger.LogWarning("Node manager disposed with {Count} Server-object ConditionRefresh call(s) still in flight after 10 s",
Volatile.Read(ref _refreshesInFlight));
Volatile.Read(ref _dataPlaneAuthority).Changed -= OnAuthorityChanged;
_liveItems.Clear();
_eventItems.Clear();
@@ -1386,7 +1394,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
// in MaterialiseAlarmCondition), whose fan-outs deliver it to direct notifier subscriptions; the
// condition's own OnReportEvent hands the same snapshot to the Server-object (all-events)
// subscriptions, per item, through the ACL (see OnConditionReportEvent).
var snapshot = new InstanceStateSnapshot();
var snapshot = new ConditionEventSnapshot(
Interlocked.Increment(ref _conditionEventSequence), alarm.NodeId, alarm.Retain?.Value == true);
snapshot.Initialize(SystemContext, alarm);
ReportEventWithoutBlocking(alarm, snapshot);
}
@@ -1434,6 +1443,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
/// <summary>Deferred condition events discarded before delivery, any reason (test observation).</summary>
internal long DiscardedEventReports => Interlocked.Read(ref _discardedEventReports);
private long _refreshSupersededDeliveries;
/// <summary>
/// Deferred condition events withheld from a Server-object item because a ConditionRefresh on that item already
/// delivered a newer state (Gitea #854) — one per item and event (test observation).
/// </summary>
internal long RefreshSupersededDeliveries => Interlocked.Read(ref _refreshSupersededDeliveries);
/// <summary>
/// Reports a condition event through the condition's notifier chain WITHOUT blocking the caller, in the order
/// this condition's events were reported, and never for a condition that is no longer live (Gitea #838).
@@ -1517,6 +1534,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
/// <summary>Queues a later event behind a condition's deferred one, coalescing past the budget. Under the gate.</summary>
private void EnqueueDeferred(AlarmConditionState condition, DeferredConditionReports queue, IFilterTarget e)
{
// Delivered off Lock from here on, so a ConditionRefresh can overtake it (#854, see OnConditionReportEvent).
if (e is ConditionEventSnapshot snapshot) snapshot.Deferred = true;
queue.Pending.Enqueue(e);
CountDeferred(+1, condition);
while (queue.Pending.Count > 1
@@ -1543,19 +1562,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();
@@ -1630,7 +1660,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)",
@@ -1651,6 +1688,138 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
public readonly Queue<IFilterTarget> Pending = new();
}
/// <summary>
/// The report order of condition events (#854): incremented under <c>Lock</c> by <see cref="ReportConditionEvent"/>,
/// read under <c>Lock</c> by a Server-object ConditionRefresh as its cut.
/// </summary>
private long _conditionEventSequence;
/// <summary>
/// A condition event as <see cref="ReportConditionEvent"/> reports it: the SDK's frozen snapshot, plus what a
/// Server-object ConditionRefresh needs to recognise it as superseded (Gitea #854). Still an
/// <see cref="InstanceStateSnapshot"/>, so the SDK's event identity, filtered-retain and queue handling treat it
/// exactly as before.
/// </summary>
/// <param name="sequence">Its place in report order (<see cref="_conditionEventSequence"/>).</param>
/// <param name="conditionId">The reporting condition's NodeId (not the instance: a cut must not pin a removed node).</param>
/// <param name="retain">The condition's <c>Retain</c> as reported.</param>
private sealed class ConditionEventSnapshot(long sequence, NodeId conditionId, bool retain) : InstanceStateSnapshot
{
public long Sequence { get; } = sequence;
public NodeId ConditionId { get; } = conditionId;
public bool Retain { get; } = retain;
/// <summary>
/// Set (under the event-report gate, before the drain can dequeue it) when the event waits in a deferral queue:
/// only such an event reaches Server-object items off <c>Lock</c>, which is the only way a refresh can be overtaken.
/// </summary>
public bool Deferred { get; set; }
}
/// <summary>
/// Per Server-object item state (#854): the gate that orders a deferred delivery to the item against a refresh of
/// the item, and the cut of the item's latest refresh while deferred events may still be older than it.
/// </summary>
private sealed class AllEventsItemState
{
/// <summary>Held across "is this deferred event superseded? → QueueEvent", and across a refresh's
/// "QueueEvent the replay → install the cut". Only ever taken innermost (nothing but the item's own lock under it).</summary>
public readonly object Gate = new();
/// <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="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: 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
&& (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; // episodes only grow: never clear a newer cut (#854 re-review)
}
}
}
/// <summary>
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
@@ -5488,6 +5657,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
var scopes = AclEnforced ? ConditionVisibilityScopes(node) : null;
var reportingSession = (context as ISessionSystemContext)?.SessionId;
var deferred = e is ConditionEventSnapshot { Deferred: true } d ? d : null;
foreach (var item in _allEventsItems.Keys)
{
@@ -5501,10 +5671,47 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
continue;
}
item.QueueEvent(e);
// #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;
}
// 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)
{
// 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;
}
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);
}
}
}
/// <summary>
/// Test probe (#854): runs on the drain thread between a deferred event's supersession check and its queueing to a
/// Server-object item, inside the item's gate — the window a refresh must not slip into. Null in production.
/// </summary>
internal Action? DeferredServerObjectDeliveryProbe { get; set; }
/// <summary>
/// The SDK's audit-event guard from <c>MonitoredNode2.OnReportEvent</c>, reproduced exactly: an audit
/// event reaches an item only while the server is auditing, and only over a SignAndEncrypt or
@@ -6068,57 +6275,311 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
// A refresh on a Server-object item is the other way it learns about conditions — it is how a
// client connecting after a transition gets the current alarm picture — and the SDK base replays
// every condition under every root notifier to it after only its own role-permission check. With
// enforcement on, those items are refreshed here instead, through the same
// IsConditionVisibleTo decision live delivery uses; an event that names no
// condition is withheld. With it off, the base runs unchanged (its per-notifier repeats of one
// condition share an EventId, which the item's queue already collapses to one copy).
// Items on one of this manager's own notifiers keep the base behaviour: a stood-down item is off
// its notifier's fan-out, which the base already treats as "not refreshed".
// client connecting after a transition gets the current alarm picture. Those items are always refreshed
// here (RefreshServerObjectItems): with enforcement on through the same IsConditionVisibleTo decision live
// delivery uses, and in both modes so the refresh can cut off deferred events it supersedes (#854).
// Items on one of this manager's own notifiers keep the base behaviour with enforcement off (a stood-down
// item is off its notifier's fan-out, which the base already treats as "not refreshed"), and
// RefreshDirectItems with it on.
/// <inheritdoc />
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> monitoredItems)
{
if (!AclEnforced) return base.ConditionRefresh(context, 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 systemContext = SystemContext.Copy(context);
if (direct.Count > 0) RefreshDirectItems(systemContext, direct);
if (serverItems.Count == 0) return ServiceResult.Good;
List<NodeState> notifiers;
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
var events = new List<IFilterTarget>();
foreach (var notifier in notifiers) notifier.ConditionRefresh(systemContext, events, true);
// One snapshot per condition, with its scopes resolved once.
var conditions = new List<(IFilterTarget Event, List<NodeId> Scopes)>();
var seen = new HashSet<NodeState>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
foreach (var e in events)
{
if (ConditionOf(e) is not { } condition || !seen.Add(condition)) continue;
conditions.Add((e, ConditionVisibilityScopes(condition)));
}
foreach (var item in serverItems)
{
foreach (var (e, scopes) in conditions)
var direct = new List<IEventMonitoredItem>();
var serverItems = new List<IEventMonitoredItem>();
foreach (var item in monitoredItems)
{
if (!IsConditionVisibleTo(item, scopes)) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
item.QueueEvent(e);
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
{
Interlocked.Decrement(ref _refreshesInFlight);
}
}
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
/// a deferred event older than what it replays (Gitea #854).
/// </summary>
/// <param name="systemContext">The refresh's system context.</param>
/// <param name="items">The Server-object items being refreshed.</param>
/// <param name="enforced">Whether the data-plane ACL is enforced.</param>
/// <remarks>
/// <para>
/// <b>The overtaking (#854).</b> While a folder subscriber's channel is full, a condition's later events wait
/// in its deferral queue (#838) and reach Server-object items only when the drain gets to them, off
/// <c>Lock</c>. A refresh in that window replays the condition's CURRENT state between RefreshStart and
/// RefreshEnd; the waiting events, all older, then followed RefreshEnd — a stale state, or a condition the
/// refresh had just dropped re-added, until the newest drained.
/// </para>
/// <para>
/// <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. 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 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. 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)
{
long episodeBefore;
bool deferringBefore;
lock (_eventReportGate)
{
episodeBefore = _deferralEpisode;
deferringBefore = _deferredEventReports > 0;
}
if (!deferringBefore)
{
List<NodeState> notifiers;
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
var events = new List<IFilterTarget>();
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)
{
// 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;
item.QueueEvent(e);
}
}
lock (_eventReportGate)
{
if (_deferralEpisode == episodeBefore && _deferredEventReports == 0) return;
}
}
return ServiceResult.Good;
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);
// #854 re-review: a throw between installing the "replay pending" markers and finishing the replay must not leave
// a marker set (every sync event to that item would keep taking the gate until its next refresh).
try
{
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)
{
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)
{
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();
}
}
}
}
finally
{
foreach (var state in states)
{
lock (state.Gate)
{
if (state.PendingReplay != generation) continue;
state.PendingReplay = 0;
state.NewerSinceCut.Clear();
}
}
}
}
/// <summary>