fix(opcua): #855 review 3 — plain refresh skips removed conditions; per-hop failure isolation

- Both plain ConditionRefresh paths (Server object and folder) skip a
  condition a deploy removed or replaced between the off-Lock walk and the
  queueing (the SDK base did not either; only the cut path checked).
- A failure delivering at one notifier hop is logged and the walk continues
  to the remaining hops; a failure deciding the Server-object items withholds
  only them.
- Test: a condition removed during a plain refresh (folder + Server object).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 12:51:03 -05:00
parent f775625805
commit 7783fadae1
2 changed files with 72 additions and 2 deletions
@@ -1651,7 +1651,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
// Re-checked: a stall may have outlived the condition (the liveness check above ran before it).
if (IsLiveCondition(condition)) DeliverToNotifierItems(monitored, e);
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
{
@@ -5815,7 +5827,18 @@ 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;
@@ -6595,6 +6618,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
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);
@@ -6878,6 +6903,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
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);
}