fix(opcua): #855 review 1 — re-decide frozen replay entries; a failed delivery is not a deferral

- QueueFrozenReplay re-asks each item's verdict right before each entry's gate
  hold (Server-object and folder cut paths): a grant revoked between the
  pre-Lock verdicts and the replay no longer lets the replay through. A denied
  entry is also removed from the cut's re-sent set.
- ReportEventWithoutBlocking logs and returns on a delivery that faulted
  inline instead of booking it as a deferral episode (which ran the drain and
  ClearStaleRefreshCuts under the event-report gate while Lock was held).
- OnConditionReportEvent isolates failures per Server-object item.
- Tests: revocation between cut and replay (folder + Server object), throwing
  hop not booked as a deferral; production-overload sampling inside Lock.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 12:31:02 -05:00
parent d9b23e0b95
commit d2b1545659
3 changed files with 182 additions and 15 deletions
@@ -1529,6 +1529,16 @@ 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;
}
lock (_eventReportGate)
{
var queue = new DeferredConditionReports();
@@ -1901,6 +1911,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>
@@ -5782,17 +5795,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
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;
}
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))
{
continue;
}
QueueConditionEventToItem(item, e, deferred, DeferredServerObjectDeliveryProbe);
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);
}
}
}
@@ -6671,7 +6693,24 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
ServerObjectRefreshProbe?.Invoke(true, Monitor.IsEntered(Lock));
QueueFrozenReplay(items, states, perItem.Select(entries => entries.Select(x => (x.Event, x.Condition.NodeId)).ToList()).ToArray(), generation);
// #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) =>
{
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
{
@@ -6685,8 +6724,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
/// 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)
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++)
{
@@ -6694,9 +6740,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
var state = states[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 (!permitted)
{
state.Cut?.Refreshed.Remove(conditionId);
continue;
}
if (state.NewerSinceCut.Contains(conditionId)) continue;
item.QueueEvent(e);
}
@@ -6926,7 +6980,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
DirectRefreshProbe?.Invoke(true, Monitor.IsEntered(Lock));
QueueFrozenReplay(items, states, perItem, generation);
// #855 review: re-decided per entry right before queueing — a deploy may have revoked since the verdicts.
QueueFrozenReplay(items, states, perItem, generation, (item, e) => IsDirectRefreshEventAllowed(item, e, enforced));
}
finally
{