Merge R2-07 Surgical pure-adds (arch-review round 2) [PR #436]
v2-ci / build (push) Successful in 3m19s
v2-ci / unit-tests (push) Failing after 8m13s

Finding 03/P1: surgical in-place address-space adds/removals skip full rebuilds
(AddressSpaceChangeClassifier default-closed to Rebuild). 3 new
ISurgicalAddressSpaceSink remove members, guard-first + forwarded through
DeferredAddressSpaceSink (F10b inertness net green). T5/T6/T12/T14 over-the-wire +
docker-dev gates deferred. Auto-merged OtOpcUaNodeManager.cs with R2-08; verified
OpcUaServer.Tests 341/341 + forwarding guard 3/3. Build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 12:27:59 -04:00
22 changed files with 2465 additions and 199 deletions
@@ -619,6 +619,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
lock (Lock)
{
// R2-07 T4a: idempotent skip-if-present-and-same-kind. On a PURE-ADD apply,
// MaterialiseScriptedAlarms + the native-alarm materialise re-run over the FULL composition, so
// an existing enabled condition is re-materialised with the SAME id + kind. Skip it — KEEP the
// existing AlarmConditionState instance alive so every MonitoredItem on that condition node
// survives the deploy (the drop-and-recreate below would otherwise kill them). A genuine
// re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
// first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
// stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
if (_alarmConditions.ContainsKey(alarmNodeId) && _nativeAlarmNodeIds.Contains(alarmNodeId) == isNative)
return;
// Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
if (_alarmConditions.TryRemove(alarmNodeId, out var existing))
@@ -1681,6 +1692,59 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return e;
}
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that the node
/// at <paramref name="affectedNodeId"/> was DELETED. Mirrors <see cref="BuildNodesAddedModelChange"/>
/// exactly — the only differences are <c>Verb = NodeDeleted</c> and that the affected node has already
/// been dropped from the maps, so <c>AffectedType</c> resolves to <see cref="NodeId.Null"/> (a valid Part 3
/// "type not applicable"; clients re-browse the parent regardless). <c>internal</c> so a node-manager test
/// can assert the populated Changes structure at the nearest deterministic seam.</summary>
/// <param name="affectedNodeId">The node id of the deleted node.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId)
{
var affected = new NodeId(affectedNodeId, NamespaceIndex);
var e = new GeneralModelChangeEventState(null);
e.Initialize(
SystemContext,
source: null,
severity: EventSeverity.Medium,
message: new LocalizedText($"Node deleted: {affected}"));
// Part 3 §8.7.4: emitted by the Server object — set SourceNode/SourceName to Server explicitly
// (mirrors BuildNodesAddedModelChange).
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
var change = new ModelChangeStructureDataType
{
Affected = affected,
// The node is already gone from the maps, so its TypeDefinition is not applicable (Null).
AffectedType = ResolveAffectedTypeDefinition(affectedNodeId),
Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted,
};
e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
return e;
}
/// <summary>Report a pre-built <see cref="GeneralModelChangeEventState"/> OUTSIDE <c>Lock</c> —
/// <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding <c>Lock</c>
/// across it risks a lock-order inversion (mirrors <see cref="RaiseNodesAddedModelChange"/> /
/// <c>ReportNodeShapeChangedEvent</c>). Tolerant: swallow-and-log when eventing is disabled / there are no
/// monitored items / the server is shutting down — the node mutation already stands.</summary>
/// <param name="e">The pre-built event to report.</param>
/// <param name="affectedNodeId">The affected node id (for the diagnostic only).</param>
private void ReportModelChangeOutsideLock(GeneralModelChangeEventState e, string affectedNodeId)
{
try
{
Server.ReportEvent(SystemContext, e);
}
catch (Exception ex)
{
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {0}", affectedNodeId);
#pragma warning restore CS0618
}
}
/// <summary>Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a
/// model-change event's <c>AffectedType</c>; <see cref="NodeId.Null"/> when the id is not registered.</summary>
/// <param name="nodeId">The folder-scoped node id whose TypeDefinition is wanted.</param>
@@ -1763,6 +1827,122 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
public bool RemoveVariableNode(string variableNodeId)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
EnsureAddressSpaceCreated();
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown id ⇒ the node-manager maps drifted from what the planner believes; return false so the
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
// teardown inside RebuildAddressSpace, scoped to this one id.
if (!_variables.TryRemove(variableNodeId, out var variable)) return false;
variable.Parent?.RemoveChild(variable);
PredefinedNodes?.Remove(variable.NodeId);
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
_historizedTagnames.TryRemove(variableNodeId, out _);
e = BuildNodesRemovedModelChange(variableNodeId);
}
ReportModelChangeOutsideLock(e, variableNodeId);
return true;
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
public bool RemoveAlarmConditionNode(string alarmNodeId)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown id ⇒ map drift ⇒ false (caller rebuilds). Mirrors the condition teardown inside
// RebuildAddressSpace, scoped to this one condition. The equipment folder's event-notifier
// promotion is intentionally NOT demoted here — other conditions may still hang under it, and a
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
// equipment goes).
if (!_alarmConditions.TryRemove(alarmNodeId, out var condition)) return false;
condition.Parent?.RemoveChild(condition);
PredefinedNodes?.Remove(condition.NodeId);
_nativeAlarmNodeIds.Remove(alarmNodeId);
e = BuildNodesRemovedModelChange(alarmNodeId);
}
ReportModelChangeOutsideLock(e, alarmNodeId);
return true;
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
public bool RemoveEquipmentSubtree(string equipmentNodeId)
{
ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
EnsureAddressSpaceCreated();
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
if (!_folders.ContainsKey(equipmentNodeId)) return false;
// Folder-scoped NodeIds mean every descendant's id is the equipment id itself or begins with
// "<equipmentId>/" (sub-folders, variables, condition nodes). Snapshot each map's in-scope keys
// first (ToList) so we don't mutate while enumerating.
var prefix = equipmentNodeId + "/";
bool InScope(string id) => id == equipmentNodeId || id.StartsWith(prefix, StringComparison.Ordinal);
// Value variables (+ their historized-tagname registrations).
foreach (var id in _variables.Keys.Where(InScope).ToList())
{
if (_variables.TryRemove(id, out var v))
{
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_historizedTagnames.TryRemove(id, out _);
}
// Part 9 condition nodes (+ their native flags).
foreach (var id in _alarmConditions.Keys.Where(InScope).ToList())
{
if (_alarmConditions.TryRemove(id, out var c))
{
c.Parent?.RemoveChild(c);
PredefinedNodes?.Remove(c.NodeId);
}
_nativeAlarmNodeIds.Remove(id);
}
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
// _notifierFolders is keyed by the folder's NodeId; match on its string identifier.
foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.Identifier?.ToString() ?? string.Empty)).ToList())
{
RemoveRootNotifier(kvp.Value);
_notifierFolders.Remove(kvp.Key);
}
// Drop the event-history source registrations for the subtree (keyed by the folder id string).
foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList())
_eventNotifierSources.TryRemove(src, out _);
// Sub-folders + the equipment folder itself (children already detached above).
foreach (var id in _folders.Keys.Where(InScope).ToList())
{
if (_folders.TryRemove(id, out var f))
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
}
e = BuildNodesRemovedModelChange(equipmentNodeId);
}
ReportModelChangeOutsideLock(e, equipmentNodeId);
return true;
}
private FolderState ResolveParentFolder(string? parentNodeId)
{
EnsureAddressSpaceCreated();