fix(opcua): #855 review 4 — per-item "known" in the folder cut; pin the post-stall liveness check

- RefreshDirectItemsWithCut keys its "decided off Lock" set per (item,
  condition), so a condition one item's walk saw is still decided for another.
- The deferral warning no longer blames slow folder subscribers (no folder of
  ours defers since #855).
- FolderEventFloodWireTests' removal test now asserts the folder item got
  nothing and the Server object exactly the pre-stall event (it passed with
  the post-stall liveness re-check deleted).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 13:05:06 -05:00
parent 7783fadae1
commit ec17a8797b
2 changed files with 11 additions and 7 deletions
@@ -1806,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);
}
@@ -6948,7 +6948,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
// 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)>();
var known = new HashSet<NodeState>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
// 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;
@@ -6957,7 +6958,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
foreach (var e in events)
{
if (ConditionOf(e) is not { } node) continue;
known.Add(node);
known.Add((item, node));
if (IsDirectRefreshEventAllowed(item, e, enforced)) allowed.Add((item, node));
}
}
@@ -7015,7 +7016,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
// A condition materialised since the verdicts were taken is decided here (rare).
if (!allowed.Contains((item, instance)))
{
if (known.Contains(instance)) continue;
if (known.Contains((item, instance))) continue;
if (!IsDirectRefreshEventAllowed(item, target, enforced)) continue;
}
@@ -193,7 +193,10 @@ public sealed class FolderEventFloodWireTests
Flooded(onServer).ShouldNotBeEmpty();
// None of the deferred tail — which was still waiting when the condition was removed — reached anyone.
Flooded(onServer).Max().ShouldBeLessThan(FloodSize - 1, "no ghost reached the Server-object subscriber");
Flooded(onA).DefaultIfEmpty(-1).Max().ShouldBeLessThan(FloodSize - 1, "no ghost reached the folder subscriber");
// Only the in-flight f0 ever reached the stalled EqA hop, and the condition was gone when the stall released: the
// post-stall liveness re-check must have withheld it (#855 review 4 — "< FloodSize - 1" passed without that check).
Flooded(onA).ShouldBeEmpty("no ghost reached the folder subscriber");
Flooded(onServer).ShouldBe([0], "only the event reported before the stall reached the Server object");
}
private static AlarmConditionSnapshot State(string message) =>