Merge R2-07 Surgical pure-adds (arch-review round 2) [PR #436]
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user