fix(opcuaserver): degraded applies re-diff instead of losing nodes; in-place UNS mirror updates; sever inverse edges
C1 — a non-rebuild apply reporting failedNodes > 0 no longer advances _lastApplied. Pre-delta-materialisation the full passes re-ensured every node on every apply, which silently repaired any prior swallowed sink fault. The delta path removed that accidental self-healing: a failed node sat inside _lastApplied, never appeared in a diff set again, and EnsureVariable early-returns on the ones that landed — so it was lost until an unrelated change forced a rebuild. The advance also moved to AFTER the materialise passes, so a throwing pass leaves _lastApplied at the previous composition too. A rebuild still advances even with failures (the ratchet is its resync path). I2 — a UnsReferenceDelta whose identity is unchanged (same NodeId, EquipmentId and BackingRawPath) is now applied in place via UpdateTagAttributes(Uns) instead of remove+recreate, so the mirror's MonitoredItems survive an operator historizing or reshaping the raw tag behind them. Remove+recreate stays for identity-changing deltas (re-point, effective-name edit). I3 — RemoveVariableNode severs the inverse half of every outgoing Organizes edge, the variable-node counterpart of UnwireAlarmNotifiers. Rebuilds used to sweep these up; surgical applies made rebuilds rare, so they accumulated on the raw node for the life of the process. I5 — added UNS reference variables are announced (their equipment folder is the parent, already the Uns realm). Raw adds still are not; the gap comment now says so honestly rather than calling it neutral and pre-existing. I4 + M1 — corrected two comments that no longer described the code: the "re-point → rebuild" claim in ApplyPureRemove, and the "N lock acquisitions" rationale for the delta passes (Ensure* early-return on a dictionary probe before the lock).
This commit is contained in:
@@ -2618,6 +2618,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
|
||||
// teardown inside RebuildAddressSpace, scoped to this one id.
|
||||
if (!_variables.TryRemove(key, out var variable)) return false;
|
||||
// 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds
|
||||
// BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a
|
||||
// condition removal. AddReference wires the edge bidirectionally: forward on the UNS reference
|
||||
// variable, inverse (OrganizedBy) on the backing RAW node. The raw node SURVIVES this scoped
|
||||
// remove, so without this it keeps a dangling inverse reference to a node that no longer exists —
|
||||
// browsable, unresolvable, and permanent.
|
||||
//
|
||||
// Why it matters more now than it used to: a full RebuildAddressSpace clears every node and every
|
||||
// reference with it, so these edges were swept up on the next structural deploy. Surgical applies
|
||||
// made rebuilds rare, so the dangling edges now ACCUMULATE on the raw node for the life of the
|
||||
// process — one per re-point or effective-name edit of any reference that ever pointed at it.
|
||||
SeverOutgoingReferences(variable);
|
||||
variable.Parent?.RemoveChild(variable);
|
||||
PredefinedNodes?.Remove(variable.NodeId);
|
||||
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
|
||||
@@ -2628,6 +2640,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 review I3 — remove the INVERSE half, held on the far node, of every FORWARD reference
|
||||
/// <paramref name="node"/> carries to another node this manager owns. The exact reverse of the
|
||||
/// bidirectional wiring in <see cref="AddReference"/>, and the variable-node counterpart of
|
||||
/// <see cref="UnwireAlarmNotifiers"/>. MUST be called under <c>Lock</c>.
|
||||
/// <para>Only FORWARD references are walked. A node's link to its own parent manifests as an INVERSE
|
||||
/// reference (the parent owns the forward half through <c>AddChild</c>), and the parent's side of that
|
||||
/// is severed by <c>Parent.RemoveChild</c> — walking inverse references too would try to unwire the
|
||||
/// parent link a second time. A forward reference whose target is not one of this manager's nodes is
|
||||
/// skipped: there is nothing here to mutate, and the target's own owner is responsible for it.</para>
|
||||
/// </summary>
|
||||
/// <param name="node">The node being removed, whose outgoing edges' inverse halves must be severed.</param>
|
||||
private void SeverOutgoingReferences(NodeState node)
|
||||
{
|
||||
var references = new List<IReference>();
|
||||
node.GetReferences(SystemContext, references);
|
||||
foreach (var r in references)
|
||||
{
|
||||
if (r.IsInverse || r.TargetId is null || r.TargetId.IsAbsolute) continue;
|
||||
var targetNodeId = (NodeId)r.TargetId;
|
||||
if (NodeId.IsNull(targetNodeId)) continue;
|
||||
if (ResolveNodeState(MapKey(targetNodeId)) is not { } target) continue;
|
||||
if (!target.RemoveReference(r.ReferenceTypeId, isInverse: true, node.NodeId)) continue;
|
||||
target.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user