Merge #855 folder event items: per-item delivery under the node manager, ConditionRefresh cut matches the Server object
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -1476,7 +1476,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// synchronous: the walk completes inline while every channel has room.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deferral.</b> When a channel is full the walk continues asynchronously (holding no thread or lock),
|
||||
/// <b>Since Gitea #855 no notifier of ours is a channel.</b> At a notifier this manager owns, the walk serves
|
||||
/// the items itself (<see cref="DeliverToNotifierItems"/>) instead of writing to the SDK's
|
||||
/// <c>MonitoredNode2</c> channel, so it always completes inline and the deferral below is reached only through
|
||||
/// a hop that is not ours (none exists today) — or the test stall (<see cref="NotifierDeliveryStall"/>). It is
|
||||
/// kept as the safety net, and the refresh cut (#854) covers folder items too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deferral.</b> When a hop suspends the walk continues asynchronously (holding no thread or lock),
|
||||
/// and the condition gets a queue: its later events wait behind it, so ONE condition's events stay in
|
||||
/// order, while other conditions whose channels have room are not held up (per-condition, not global).
|
||||
/// Events of different conditions may interleave differently than reported — each carries its own
|
||||
@@ -1522,13 +1529,29 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
if (delivery.IsCompletedSuccessfully) return;
|
||||
|
||||
// #855 review: a delivery that FAILED inline is over, not suspended. Booking it as a deferral used to open and close
|
||||
// a deferral episode synchronously (moving the episode a concurrent plain refresh checks, and clearing cuts under
|
||||
// the event-report gate while Lock was held). Only an incomplete delivery is deferred.
|
||||
if (delivery.IsCompleted)
|
||||
{
|
||||
_logger.LogError(delivery.AsTask().Exception?.GetBaseException(),
|
||||
"OtOpcUaNodeManager: failed to report Part 9 condition event for {NodeId}", condition.NodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredConditionReports queue;
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
var queue = new DeferredConditionReports();
|
||||
queue = new DeferredConditionReports();
|
||||
_deferredReports[condition] = queue;
|
||||
CountDeferred(+1, condition);
|
||||
_ = DrainDeferredAsync(condition, queue, delivery.AsTask());
|
||||
}
|
||||
|
||||
// #855 review 2: started on the pool, never inline. The suspended delivery may complete between the check above and
|
||||
// here; an inline drain would then run (and end the episode, and clear cuts over every item gate) on this thread,
|
||||
// under Lock. The queue is registered first, so this condition's later reports wait behind it either way.
|
||||
var inFlight = delivery.AsTask();
|
||||
_ = Task.Run(() => DrainDeferredAsync(condition, queue, inFlight));
|
||||
}
|
||||
|
||||
/// <summary>Queues a later event behind a condition's deferred one, coalescing past the budget. Under the gate.</summary>
|
||||
@@ -1615,7 +1638,42 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
node.OnReportEvent?.Invoke(SystemContext, node, e);
|
||||
if (node.OnReportEventAsync is { } handler)
|
||||
await handler(SystemContext, node, e, CancellationToken.None).ConfigureAwait(false);
|
||||
{
|
||||
if (OwnNotifierFanOut(node, handler) is { } monitored)
|
||||
{
|
||||
// Gitea #855: one of our notifiers. Its items are served here, per item and per event, exactly as the
|
||||
// Server-object items are (OnConditionReportEvent) — never through the SDK's channel, whose consumer
|
||||
// needed Lock (#838) and gave no per-event, per-item point at which a refresh cut could apply.
|
||||
if (NotifierDeliveryStall?.Invoke(node) is { IsCompleted: false } stall)
|
||||
{
|
||||
MarkDeferred(e);
|
||||
await stall.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Re-checked: a stall may have outlived the condition (the liveness check above ran before it).
|
||||
if (IsLiveCondition(condition))
|
||||
{
|
||||
// #855 review 3: one hop's failure must not cost the hops after it (before #855 this hop was a channel
|
||||
// write, which cannot throw; the SDK isolated failures per item).
|
||||
try
|
||||
{
|
||||
DeliverToNotifierItems(monitored, e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OtOpcUaNodeManager: failed to deliver a condition event at notifier {NodeId}", node.NodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not a fan-out of ours (none exists today): the SDK path, which may suspend. Whatever is delivered after
|
||||
// the suspension runs off Lock, so it must be judged as a deferred event (#855).
|
||||
var pending = handler(SystemContext, node, e, CancellationToken.None);
|
||||
if (!pending.IsCompleted) MarkDeferred(e);
|
||||
await pending.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var notifiers = new List<NodeState.Notifier>();
|
||||
node.GetNotifiers(SystemContext, notifiers);
|
||||
@@ -1631,6 +1689,101 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
await DeliverConditionEventAsync(condition, parent, e).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Marks an event whose remaining delivery will run off <c>Lock</c> (#855) — before the walk suspends.</summary>
|
||||
private static void MarkDeferred(IFilterTarget e)
|
||||
{
|
||||
if (e is ConditionEventSnapshot snapshot) snapshot.Deferred = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SDK <see cref="MonitoredNode2"/> serving event items on <paramref name="node"/>, when that node is one of
|
||||
/// ours and <paramref name="handler"/> is the fan-out's own <c>OnReportEventAsync</c> (what
|
||||
/// <c>MonitoredNode2.Add(IEventMonitoredItem)</c> installs); otherwise null (Gitea #855).
|
||||
/// </summary>
|
||||
private MonitoredNode2? OwnNotifierFanOut(NodeState node, NodeStateReportEventAsyncHandler handler) =>
|
||||
handler.Target is MonitoredNode2 monitored && ReferenceEquals(monitored.Node, node) && OwnsNamespace(node.NodeId)
|
||||
? monitored
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Test probe (#855): consulted before a condition event is delivered to the items of one of our notifiers; an
|
||||
/// incomplete task is awaited first, so the walk suspends there exactly as it would behind a slow notifier — the
|
||||
/// way tests produce a deferral now that no notifier of ours can block. Null in production.
|
||||
/// </summary>
|
||||
internal Func<NodeState, Task?>? NotifierDeliveryStall { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Delivers one condition event to the event items attached to one of this manager's notifiers (an equipment or
|
||||
/// raw device folder), item by item (Gitea #855) — the folder twin of <see cref="OnConditionReportEvent"/>.
|
||||
/// </summary>
|
||||
/// <param name="monitored">The notifier's SDK fan-out; only its attached-item list is used.</param>
|
||||
/// <param name="e">The condition's snapshot.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mirrors the SDK's <c>MonitoredNode2.ProcessEventForItemAsync</c> — its audit guard, then the verdict through
|
||||
/// <see cref="INodeManager3.ValidateEventRolePermissions"/> (the SDK's type + SourceNode <c>ReceiveEvents</c>
|
||||
/// check and, under enforcement, #826's live-notifier + grant check), then <c>QueueEvent</c> — but per event:
|
||||
/// nothing is memoised, so a deploy, revocation or reference removal decides the very next event. The item's
|
||||
/// gate and refresh cut apply exactly as on the Server object (<see cref="QueueConditionEventToItem"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Synchronous. On a report it runs on the reporter's thread under <c>Lock</c> (as the Server-object delivery
|
||||
/// does), and <c>QueueEvent</c> never waits for a consumer — the item's own queue discards oldest at its
|
||||
/// <c>QueueSize</c> — so no notifier of ours defers any more. Off <c>Lock</c> (a deferred event) the verdict,
|
||||
/// which takes <c>Lock</c>, is computed before the item gate is taken: nothing under a gate takes <c>Lock</c>.
|
||||
/// A stood-down item (#562) is not in the attached list and receives nothing, as before.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void DeliverToNotifierItems(MonitoredNode2 monitored, IFilterTarget e)
|
||||
{
|
||||
var deferred = e is ConditionEventSnapshot { Deferred: true } d ? d : null;
|
||||
|
||||
// #855 review 2: the #826 decision's item-independent part — the live condition, whether this notifier is still a
|
||||
// legitimate notifier of it, and its visibility scopes — is resolved ONCE per hop, not per item (it is what
|
||||
// IsNotifierEventVisibleTo resolves for an item attached here). Only the grant check and the SDK's role check
|
||||
// remain per item.
|
||||
var enforced = AclEnforced;
|
||||
List<NodeId>? scopes = null;
|
||||
if (enforced)
|
||||
{
|
||||
if (EventConditionOf(e) is not { } named
|
||||
|| !_alarmConditions.TryGetValue(MapKey(named.NodeId), out var live)
|
||||
|| !IsLiveNotifierOf(live, monitored.Node))
|
||||
{
|
||||
return; // fail closed for every item on this notifier
|
||||
}
|
||||
|
||||
scopes = ConditionVisibilityScopes(live);
|
||||
}
|
||||
|
||||
foreach (var item in monitored.EventMonitoredItems.Values)
|
||||
{
|
||||
if (item is null) continue;
|
||||
try
|
||||
{
|
||||
if (IsAuditEvent(e) && !IsAuditDeliverableTo(item)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
QueueConditionEventToItem(item, e, deferred, DeferredFolderDeliveryProbe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// As the SDK's per-item delivery: one item's failure must not cost the others (or the rest of the walk).
|
||||
_logger.LogWarning(ex, "OtOpcUaNodeManager: failed to deliver a condition event to event item {ItemId}", item.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The SDK's audit test from <c>MonitoredNode2.ProcessEventForItemAsync</c>: the event, or the snapshot's handle.</summary>
|
||||
private static bool IsAuditEvent(IFilterTarget e) =>
|
||||
e is AuditEventState || e is InstanceStateSnapshot { Handle: AuditEventState };
|
||||
|
||||
/// <summary>
|
||||
/// Test probe (#855): the folder twin of <see cref="DeferredServerObjectDeliveryProbe"/> — runs between a deferred
|
||||
/// event's supersession check and its queueing to a folder item, inside the item's gate. Null in production.
|
||||
/// </summary>
|
||||
internal Action? DeferredFolderDeliveryProbe { get; set; }
|
||||
|
||||
/// <summary>Whether <paramref name="condition"/> is the instance the address space currently holds for its NodeId.</summary>
|
||||
private bool IsLiveCondition(NodeState condition) =>
|
||||
_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live) && ReferenceEquals(live, condition);
|
||||
@@ -1653,9 +1806,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
{
|
||||
_lastDeferralWarningUtc = now;
|
||||
_logger.LogWarning(
|
||||
"OtOpcUaNodeManager: condition events are being deferred — an event notifier's delivery channel is "
|
||||
+ "full (first: {NodeId}). Folder event subscribers are not keeping up, or Lock is held across a large "
|
||||
+ "burst; events stay ordered per condition, and past {PerCondition} waiting per condition the oldest "
|
||||
"OtOpcUaNodeManager: condition events are being deferred — a notifier hop the node manager does not "
|
||||
+ "serve itself suspended (first: {NodeId}); since #855 no folder of ours does, so this is unexpected. "
|
||||
+ "Events stay ordered per condition, and past {PerCondition} waiting per condition the oldest "
|
||||
+ "give way to the newest (Gitea #838)",
|
||||
condition.NodeId, MaxDeferredEventsPerCondition);
|
||||
}
|
||||
@@ -1711,18 +1864,27 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
public bool Retain { get; } = retain;
|
||||
|
||||
private volatile bool _deferred;
|
||||
|
||||
/// <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.
|
||||
/// Set when the event's delivery continues off <c>Lock</c>: under the event-report gate, before the drain can
|
||||
/// dequeue it, when it waits in a deferral queue; or by the walk itself, before it suspends at a hop (#855), for
|
||||
/// the in-flight event whose remaining hops then run on another thread. Only such an event reaches an item off
|
||||
/// <c>Lock</c>, which is the only way a refresh can be overtaken.
|
||||
/// </summary>
|
||||
public bool Deferred { get; set; }
|
||||
public bool Deferred
|
||||
{
|
||||
get => _deferred;
|
||||
set => _deferred = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Per event item state — Server-object (#854) and, since #855, folder items: 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
|
||||
private sealed class EventItemState
|
||||
{
|
||||
/// <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>
|
||||
@@ -1749,21 +1911,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
private int _refreshGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// One <see cref="AllEventsItemState"/> per Server-object item for the item's whole lifetime (#854 review): keyed by
|
||||
/// One <see cref="EventItemState"/> per event 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();
|
||||
private readonly ConditionalWeakTable<IEventMonitoredItem, EventItemState> _eventItemStates = new();
|
||||
|
||||
/// <summary>Server-object items currently holding a refresh cut (test observation, #854 review).</summary>
|
||||
/// <summary>Server-object and folder event items currently holding a refresh cut (test observation, #854 review).</summary>
|
||||
internal int ActiveRefreshCuts
|
||||
{
|
||||
get
|
||||
{
|
||||
var n = 0;
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
foreach (var item in TrackedEventItems())
|
||||
{
|
||||
if (!_allEventsItemStates.TryGetValue(item, out var state)) continue;
|
||||
if (!_eventItemStates.TryGetValue(item, out var state)) continue;
|
||||
lock (state.Gate) n += state.Cut is null ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -1771,8 +1933,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
}
|
||||
|
||||
private AllEventsItemState AllEventsItemStateOf(IEventMonitoredItem item) =>
|
||||
_allEventsItemStates.GetValue(item, static _ => new AllEventsItemState());
|
||||
private EventItemState EventItemStateOf(IEventMonitoredItem item) =>
|
||||
_eventItemStates.GetValue(item, static _ => new EventItemState());
|
||||
|
||||
/// <summary>
|
||||
/// Every live event item this manager tracks: the Server-object items and the direct (folder) items (#855). An
|
||||
/// item that left both maps keeps its state in <see cref="_eventItemStates"/> until collected, but any cut it holds
|
||||
/// is from an ended episode by the time it could deliver again, and so inert.
|
||||
/// </summary>
|
||||
private IEnumerable<IEventMonitoredItem> TrackedEventItems() => _allEventsItems.Keys.Concat(_eventItems.Keys);
|
||||
|
||||
/// <summary>
|
||||
/// Advanced (under the event-report gate) each time the deferral count returns to zero: the end of a deferral
|
||||
@@ -1780,6 +1949,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// </summary>
|
||||
private long _deferralEpisode;
|
||||
|
||||
/// <summary>Deferral episodes ended since start (test observation, #855 review).</summary>
|
||||
internal long DeferralEpisodes => Interlocked.Read(ref _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>
|
||||
@@ -1810,9 +1982,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
private void ClearStaleRefreshCuts()
|
||||
{
|
||||
var current = Interlocked.Read(ref _deferralEpisode);
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
foreach (var item in TrackedEventItems())
|
||||
{
|
||||
if (!_allEventsItemStates.TryGetValue(item, out var state)) continue;
|
||||
if (!_eventItemStates.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)
|
||||
@@ -5655,54 +5827,87 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
// through its OLD equipment folder (#808).
|
||||
if (!IsLiveCondition(node)) return;
|
||||
|
||||
var scopes = AclEnforced ? ConditionVisibilityScopes(node) : null;
|
||||
List<NodeId>? scopes;
|
||||
try
|
||||
{
|
||||
scopes = AclEnforced ? ConditionVisibilityScopes(node) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// #855 review 3: withheld from the Server-object items (fail closed), but the walk goes on to the folders.
|
||||
_logger.LogWarning(ex, "OtOpcUaNodeManager: failed to decide a condition event for Server-object items ({NodeId})", node.NodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
var reportingSession = (context as ISessionSystemContext)?.SessionId;
|
||||
var deferred = e is ConditionEventSnapshot { Deferred: true } d ? d : null;
|
||||
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
{
|
||||
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
if (reportingSession is { IsNull: false } reportingId
|
||||
&& item.Session?.Id is { } own
|
||||
&& !own.Equals(reportingId))
|
||||
try
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// #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))
|
||||
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
if (reportingSession is { IsNull: false } reportingId
|
||||
&& item.Session?.Id is { } own
|
||||
&& !own.Equals(reportingId))
|
||||
{
|
||||
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);
|
||||
QueueConditionEventToItem(item, e, deferred, DeferredServerObjectDeliveryProbe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// #855 review: one item's failure (an item disposed under us, the SDK's operation guard at shutdown) must
|
||||
// not cost the other Server-object items, nor abort the walk before the folder hops.
|
||||
_logger.LogWarning(ex, "OtOpcUaNodeManager: failed to deliver a condition event to Server-object item {ItemId}", item.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a condition event to one event item — Server-object or folder (#855) — under the item's refresh gate and
|
||||
/// cut (#854). The caller has already decided the item may receive it, and holds no item gate.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="e">The event.</param>
|
||||
/// <param name="deferred">The event, when its delivery runs off <c>Lock</c> (<see cref="ConditionEventSnapshot.Deferred"/>).</param>
|
||||
/// <param name="probe">Test probe run inside the gate, before queueing a deferred event.</param>
|
||||
private void QueueConditionEventToItem(IEventMonitoredItem item, IFilterTarget e, ConditionEventSnapshot? deferred, Action? probe)
|
||||
{
|
||||
// #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.
|
||||
EventItemState? state;
|
||||
if (deferred is not null) state = EventItemStateOf(item);
|
||||
else if (!_eventItemStates.TryGetValue(item, out state) || state.PendingReplay == 0 || e is not ConditionEventSnapshot)
|
||||
{
|
||||
item.QueueEvent(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.PendingReplay != 0 && e is ConditionEventSnapshot { } ce && ce.Sequence > state.PendingWatermark)
|
||||
state.NewerSinceCut.Add(ce.ConditionId);
|
||||
|
||||
if (deferred is not null) probe?.Invoke();
|
||||
item.QueueEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6080,8 +6285,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// would be.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Runs on the SDK's per-node delivery task, where the inherited role-permission check already takes
|
||||
/// <c>Lock</c> (via <c>GetNodeMetadata</c>); the scope resolution here takes it the same way.
|
||||
/// Runs per event, uncached, from the per-item folder delivery (#855) — on the reporter's thread under
|
||||
/// <c>Lock</c>, or off it for a deferred event (before the item gate is taken) — and, for the events that still
|
||||
/// travel the SDK channel (a condition's method audits), on the SDK's per-node delivery task, where the
|
||||
/// inherited role-permission check already takes <c>Lock</c> (via <c>GetNodeMetadata</c>); the scope
|
||||
/// resolution here takes it the same way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool IsNotifierEventVisibleTo(IEventMonitoredItem item, IFilterTarget e)
|
||||
@@ -6146,13 +6354,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// <returns>Good to deliver; otherwise the veto.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The SDK's <c>MonitoredNode2</c> asks this, per event and per item, before it queues an event to an item
|
||||
/// attached to one of this manager's notifiers. The Server-object path (#586) and ConditionRefresh decide
|
||||
/// the same question themselves, so a folder's subscribers stop receiving a moved alarm (or a native alarm
|
||||
/// whose reference was dropped) the moment the generation says so, not when the rebuild lands.
|
||||
/// Condition events to an item attached to one of this manager's notifiers are decided here per event and per
|
||||
/// item by <see cref="DeliverToNotifierItems"/> (#855, uncached); the SDK's <c>MonitoredNode2</c> still asks it
|
||||
/// for what travels its channel (a condition's method audit events). The Server-object path (#586) and
|
||||
/// ConditionRefresh decide the same question themselves, so a folder's subscribers stop receiving a moved
|
||||
/// alarm (or a native alarm whose reference was dropped) the moment the generation says so, not when the
|
||||
/// rebuild lands.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ The SDK MEMOIZES the verdict per <c>(item, event type, SourceNode)</c> until the monitored node's
|
||||
/// ⚠️ On the SDK channel path the SDK MEMOIZES the verdict per <c>(item, event type, SourceNode)</c> until the monitored node's
|
||||
/// permission generation moves, so <see cref="InvalidateFolderEventVerdicts"/> must run whenever the inputs
|
||||
/// change: a new authority generation or authorizer, the revalidation tick, and the end of every apply. A
|
||||
/// condition's SourceNode is the condition itself, so a verdict is never shared between two conditions.
|
||||
@@ -6278,9 +6488,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
// 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.
|
||||
// Items on one of this manager's own notifiers go through RefreshDirectItems in both modes since #855 — the
|
||||
// base's loop (a stood-down item is off its notifier's fan-out, so not refreshed) plus the #826 decision with
|
||||
// enforcement on, and the same cut against deferred events as the Server object.
|
||||
/// <inheritdoc />
|
||||
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> monitoredItems)
|
||||
{
|
||||
@@ -6301,11 +6511,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
var enforced = AclEnforced;
|
||||
var systemContext = SystemContext.Copy(context);
|
||||
if (direct.Count > 0)
|
||||
{
|
||||
if (enforced) RefreshDirectItems(systemContext, direct);
|
||||
else base.ConditionRefresh(context, direct);
|
||||
}
|
||||
if (direct.Count > 0) RefreshDirectItems(systemContext, direct, enforced);
|
||||
|
||||
if (serverItems.Count > 0) RefreshServerObjectItems(systemContext, serverItems, enforced);
|
||||
return ServiceResult.Good;
|
||||
@@ -6339,7 +6545,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// 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.
|
||||
/// refresh had just dropped re-added, until the newest drained. (Since #855 folder items are no longer a
|
||||
/// channel, so nothing of ours defers today; the cut stays as the safety net, and folder items share it —
|
||||
/// <see cref="RefreshDirectItems"/>.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Common case — nothing deferred: unchanged, and off <c>Lock</c>.</b> The replay is the notifier walk the SDK
|
||||
@@ -6353,10 +6561,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// <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 /
|
||||
/// with its <see cref="RefreshCut"/> installed under the item's <see cref="EventItemState.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
|
||||
/// <see cref="EventItemState.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"/>)
|
||||
@@ -6406,17 +6614,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
{
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
StopPendingReplay(item);
|
||||
|
||||
foreach (var (e, _, scopes) in replay)
|
||||
{
|
||||
// #855 review 3: never replay a condition a deploy removed or replaced since the walk collected it.
|
||||
if (ConditionOf(e) is AlarmConditionState walked && !IsLiveCondition(walked)) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
item.QueueEvent(e);
|
||||
@@ -6472,7 +6675,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
}
|
||||
|
||||
var states = items.Select(AllEventsItemStateOf).ToList();
|
||||
var states = items.Select(EventItemStateOf).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
|
||||
@@ -6540,20 +6743,69 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
// #855 review: the verdicts above were taken before Lock; a deploy may have revoked a grant since. Each replay entry
|
||||
// is re-decided right before it is queued (scopes resolved once per condition).
|
||||
var scopeCache = new Dictionary<NodeId, List<NodeId>>();
|
||||
QueueFrozenReplay(
|
||||
items, states, perItem.Select(entries => entries.Select(x => (x.Event, x.Condition.NodeId)).ToList()).ToArray(),
|
||||
generation,
|
||||
(item, e) =>
|
||||
{
|
||||
// #855 review 2: never replay a condition a deploy has removed or replaced since the cut — its frozen
|
||||
// state would be a ghost (and, with the ACL on, a replaced scripted alarm would be judged on its old folder).
|
||||
if (ConditionOf(e) is { } frozen && !IsLiveCondition(frozen)) return false;
|
||||
if (enforced)
|
||||
{
|
||||
if (ConditionOf(e) is not { } condition) return false;
|
||||
if (!scopeCache.TryGetValue(condition.NodeId, out var scopes))
|
||||
scopeCache[condition.NodeId] = scopes = ConditionVisibilityScopes(condition);
|
||||
if (!IsConditionVisibleTo(item, scopes)) return false;
|
||||
}
|
||||
|
||||
return !ServiceResult.IsBad(ValidateEventRolePermissions(item, e));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearPendingReplays(states, generation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a cut-path refresh's frozen replay to its items OFF <c>Lock</c>, one replayed event per hold of the item
|
||||
/// gate (#854; folder items too since #855): 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.
|
||||
/// </summary>
|
||||
/// <param name="allowed">
|
||||
/// The item's verdict for one entry, asked right before the entry's gate hold (never under it: it takes <c>Lock</c>),
|
||||
/// against the CURRENT generation — the verdicts the cut was built on were taken earlier (#855 review). An entry
|
||||
/// denied here is dropped AND removed from the cut's re-sent set, so the condition counts as "omitted for the
|
||||
/// grants" and its later events are left to the ACL rather than withheld as superseded.
|
||||
/// </param>
|
||||
private static void QueueFrozenReplay(
|
||||
List<IEventMonitoredItem> items, List<EventItemState> states, List<(IFilterTarget Event, NodeId ConditionId)>[] perItem,
|
||||
int generation, Func<IEventMonitoredItem, IFilterTarget, bool> allowed)
|
||||
{
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
var state = states[i];
|
||||
foreach (var (e, condition) in perItem[i])
|
||||
foreach (var (e, conditionId) in perItem[i])
|
||||
{
|
||||
if (state.PendingReplay != generation) break;
|
||||
var permitted = allowed(item, e);
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (state.PendingReplay != generation) break;
|
||||
if (state.NewerSinceCut.Contains(condition.NodeId)) continue;
|
||||
if (!permitted)
|
||||
{
|
||||
state.Cut?.Refreshed.Remove(conditionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.NewerSinceCut.Contains(conditionId)) continue;
|
||||
item.QueueEvent(e);
|
||||
}
|
||||
}
|
||||
@@ -6567,51 +6819,234 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #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).
|
||||
/// </summary>
|
||||
private static void ClearPendingReplays(List<EventItemState> states, int generation)
|
||||
{
|
||||
foreach (var state in states)
|
||||
{
|
||||
foreach (var state in states)
|
||||
lock (state.Gate)
|
||||
{
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (state.PendingReplay != generation) continue;
|
||||
state.PendingReplay = 0;
|
||||
state.NewerSinceCut.Clear();
|
||||
}
|
||||
if (state.PendingReplay != generation) continue;
|
||||
state.PendingReplay = 0;
|
||||
state.NewerSinceCut.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConditionRefresh for event items on this manager's own notifiers, under enforcement (Gitea #826): the SDK
|
||||
/// base's loop exactly — an item no longer attached (stood down by revalidation, or tombstoned) is not
|
||||
/// refreshed — plus the per-event decision live delivery makes (<see cref="IsNotifierEventVisibleTo"/>), so a
|
||||
/// refresh cannot replay to a folder's subscriber an alarm that folder's live events withhold.
|
||||
/// Stops a cut-path replay of <paramref name="item"/> still being queued by a concurrent refresh: a live replay that
|
||||
/// is about to be queued is newer, and the frozen one must not follow it.
|
||||
/// </summary>
|
||||
private void StopPendingReplay(IEventMonitoredItem item)
|
||||
{
|
||||
if (!_eventItemStates.TryGetValue(item, out var pending)) return;
|
||||
lock (pending.Gate)
|
||||
{
|
||||
pending.PendingReplay = 0;
|
||||
pending.NewerSinceCut.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConditionRefresh (and ConditionRefresh2) for event items on this manager's own notifiers — equipment and raw device
|
||||
/// folders — flag on and off: never overtaken by a deferred event older than what it replays (Gitea #855), the folder
|
||||
/// twin of <see cref="RefreshServerObjectItems"/>.
|
||||
/// </summary>
|
||||
/// <param name="systemContext">The refresh's system context.</param>
|
||||
/// <param name="items">The direct (non-Server-object) items being refreshed.</param>
|
||||
private void RefreshDirectItems(ServerSystemContext systemContext, List<IEventMonitoredItem> items)
|
||||
/// <param name="enforced">Whether the data-plane ACL is enforced.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nothing deferred (the only case today — no notifier of ours defers since #855): unchanged, off
|
||||
/// <c>Lock</c>.</b> The SDK base's loop — an item no longer attached (stood down by revalidation, or tombstoned)
|
||||
/// is not refreshed; the item's notifier is walked and each live condition queued through the SDK's role check —
|
||||
/// plus, under enforcement, the per-event decision live delivery makes (<see cref="IsNotifierEventVisibleTo"/>,
|
||||
/// #826), so a refresh cannot replay to a folder's subscriber an alarm that folder's live events withhold. It
|
||||
/// stands only if no deferral episode existed at any moment of it; the closing check takes <c>Lock</c> once, so a
|
||||
/// report whose walk suspended (it marks the event before suspending, under <c>Lock</c>) has been counted by then.
|
||||
/// Otherwise the cut path runs as well, and its replay — later inside the same RefreshStart..RefreshEnd —
|
||||
/// supersedes anything that slipped in between.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deferring — the cut</b>, exactly as on the Server object: verdicts per (item, condition) first, off
|
||||
/// <c>Lock</c>, from a live walk; then one <c>Lock</c> hold takes the watermark, the episode, the Retain set and,
|
||||
/// per item, the same walk as frozen snapshots, and installs the cut and the "replay pending" marker under the
|
||||
/// item's gate; the replay is queued off <c>Lock</c> (<see cref="QueueFrozenReplay"/>). Deferred events reaching
|
||||
/// the item afterwards are judged against the cut (<see cref="QueueConditionEventToItem"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RefreshDirectItems(ServerSystemContext systemContext, List<IEventMonitoredItem> items, bool enforced)
|
||||
{
|
||||
foreach (var item in items)
|
||||
long episodeBefore;
|
||||
bool deferringBefore;
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
NodeState? notifier;
|
||||
lock (Lock)
|
||||
episodeBefore = _deferralEpisode;
|
||||
deferringBefore = _deferredEventReports > 0;
|
||||
}
|
||||
|
||||
if (!deferringBefore)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!MonitoredItems.ContainsKey(item.Id)) continue;
|
||||
notifier = (item.ManagerHandle as NodeHandle)?.Node;
|
||||
var notifier = AttachedNotifierOf(item);
|
||||
if (notifier is null) continue;
|
||||
|
||||
StopPendingReplay(item);
|
||||
var events = new List<IFilterTarget>();
|
||||
notifier.ConditionRefresh(systemContext, events, true);
|
||||
DirectRefreshProbe?.Invoke(false, Monitor.IsEntered(Lock));
|
||||
foreach (var e in events)
|
||||
{
|
||||
// #855 review 3: never replay a condition a deploy removed or replaced since the walk collected it.
|
||||
if (ConditionOf(e) is AlarmConditionState walked && !IsLiveCondition(walked)) continue;
|
||||
if (!IsDirectRefreshEventAllowed(item, e, enforced)) continue;
|
||||
item.QueueEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (notifier is null) continue;
|
||||
// Lock first: a report that suspended under it has registered its deferral by the time Lock is free.
|
||||
lock (Lock)
|
||||
{
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
if (_deferralEpisode == episodeBefore && _deferredEventReports == 0) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RefreshDirectItemsWithCut(systemContext, items, enforced);
|
||||
}
|
||||
|
||||
/// <summary>The notifier an attached direct item subscribes to, or null for one that is not attached (not refreshed).</summary>
|
||||
private NodeState? AttachedNotifierOf(IEventMonitoredItem item)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
return MonitoredItems.ContainsKey(item.Id) ? (item.ManagerHandle as NodeHandle)?.Node : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A direct item's refresh verdict for one replayed event: the #826 decision under enforcement, then the SDK's.</summary>
|
||||
private bool IsDirectRefreshEventAllowed(IEventMonitoredItem item, IFilterTarget e, bool enforced) =>
|
||||
(!enforced || IsNotifierEventVisibleTo(item, e)) && !ServiceResult.IsBad(ValidateEventRolePermissions(item, e));
|
||||
|
||||
/// <summary>
|
||||
/// Test probe (#855): called by a direct-item 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>? DirectRefreshProbe { get; set; }
|
||||
|
||||
/// <summary>The cut path of <see cref="RefreshDirectItems"/> — see there.</summary>
|
||||
private void RefreshDirectItemsWithCut(ServerSystemContext systemContext, List<IEventMonitoredItem> items, bool enforced)
|
||||
{
|
||||
// Verdicts per (item, condition) before Lock, from a live walk: they depend on the event type, source, notifier
|
||||
// wiring and grants, not on the condition's state.
|
||||
var allowed = new HashSet<(IEventMonitoredItem, NodeState)>();
|
||||
// Per (item, condition): a condition one item's walk saw is still undecided for another item (#855 review 4).
|
||||
var known = new HashSet<(IEventMonitoredItem, NodeState)>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (AttachedNotifierOf(item) is not { } notifier) continue;
|
||||
var events = new List<IFilterTarget>();
|
||||
notifier.ConditionRefresh(systemContext, events, true);
|
||||
foreach (var e in events)
|
||||
{
|
||||
if (!IsNotifierEventVisibleTo(item, e)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
item.QueueEvent(e);
|
||||
if (ConditionOf(e) is not { } node) continue;
|
||||
known.Add((item, node));
|
||||
if (IsDirectRefreshEventAllowed(item, e, enforced)) allowed.Add((item, node));
|
||||
}
|
||||
}
|
||||
|
||||
var states = items.Select(EventItemStateOf).ToList();
|
||||
var perItem = new List<(IFilterTarget Event, NodeId ConditionId)>[items.Count];
|
||||
var generation = Interlocked.Increment(ref _refreshGeneration);
|
||||
try
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
var watermark = Interlocked.Read(ref _conditionEventSequence);
|
||||
long episode;
|
||||
bool deferring;
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
episode = _deferralEpisode;
|
||||
deferring = _deferredEventReports > 0;
|
||||
}
|
||||
|
||||
var retained = new HashSet<NodeId>();
|
||||
foreach (var condition in _alarmConditions.Values)
|
||||
{
|
||||
if (condition.Retain?.Value == true) retained.Add(condition.NodeId);
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
var entries = new List<(IFilterTarget Event, NodeId ConditionId)>();
|
||||
perItem[i] = entries;
|
||||
var state = states[i];
|
||||
|
||||
// Lock is held, so this is the attachment the walk below sees. An unattached item is not refreshed and
|
||||
// gets no cut (it receives nothing until re-attached, and then every event is decided as it arrives).
|
||||
var notifier = MonitoredItems.ContainsKey(item.Id) ? (item.ManagerHandle as NodeHandle)?.Node : null;
|
||||
if (notifier is null)
|
||||
{
|
||||
lock (state.Gate)
|
||||
{
|
||||
state.PendingReplay = 0;
|
||||
state.NewerSinceCut.Clear();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Frozen at the watermark: the replay is exactly the state every event at or below it describes.
|
||||
var live = new List<IFilterTarget>();
|
||||
notifier.ConditionRefresh(SystemContext, live, true);
|
||||
var refreshed = new HashSet<NodeId>();
|
||||
foreach (var target in live)
|
||||
{
|
||||
if (target is not BaseInstanceState instance) continue;
|
||||
// A condition materialised since the verdicts were taken is decided here (rare).
|
||||
if (!allowed.Contains((item, instance)))
|
||||
{
|
||||
if (known.Contains((item, instance))) continue;
|
||||
if (!IsDirectRefreshEventAllowed(item, target, enforced)) continue;
|
||||
}
|
||||
|
||||
var snapshot = new InstanceStateSnapshot();
|
||||
snapshot.Initialize(SystemContext, instance);
|
||||
entries.Add((snapshot, instance.NodeId));
|
||||
refreshed.Add(instance.NodeId);
|
||||
}
|
||||
|
||||
lock (state.Gate)
|
||||
{
|
||||
state.Cut = deferring ? new RefreshCut(watermark, episode, refreshed, retained) : null;
|
||||
state.PendingWatermark = watermark;
|
||||
state.NewerSinceCut.Clear();
|
||||
state.PendingReplay = generation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DirectRefreshProbe?.Invoke(true, Monitor.IsEntered(Lock));
|
||||
// #855 review: re-decided per entry right before queueing — a deploy may have revoked since the verdicts.
|
||||
QueueFrozenReplay(
|
||||
items, states, perItem, generation,
|
||||
(item, e) => (ConditionOf(e) is not AlarmConditionState frozen || IsLiveCondition(frozen))
|
||||
&& IsDirectRefreshEventAllowed(item, e, enforced));
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearPendingReplays(states, generation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The condition a refresh event describes, or null when it names none (withheld: fail closed).</summary>
|
||||
|
||||
Reference in New Issue
Block a user