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:
Joseph Doherty
2026-11-11 13:07:21 -05:00
10 changed files with 1654 additions and 228 deletions
@@ -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>