fix(address-space): announce surgically-added raw nodes; equipment-with-references removal stays surgical (#599)

- AnnounceAddedNodes raises NodeAdded in the Raw realm for the existing parents of
  added raw containers/tags (ComputeRawAddAnnouncements); Uns side unchanged.
- ApplyPureRemove removes UNS reference variables BEFORE the equipment subtree, so a
  reference the subtree's id-prefix scope reaches no longer ratchets to a full rebuild
  (03/P5). A NotSubsumed filter was not used: in production a reference NodeId is
  Area/Line/Equipment/Name, outside the subtree scope, so filtering would leak it.
  The matching RemoveEquipmentSubtree severing (03/S20) is in the node-manager commit.
- UnsReferenceBackingParityTests: reflection guard discovering the classifier's surgical
  raw-tag whitelist and requiring a same-typed mirror field (03/U11).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:12:29 -04:00
parent 3baa332d6b
commit 0bd3ec00e4
6 changed files with 414 additions and 38 deletions
@@ -397,29 +397,40 @@ public sealed class AddressSpaceApplier
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId, AddressSpaceRealm.Uns)) return false;
}
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
// top-of-Apply block already wrote the equipment id's terminal condition state.
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed UNS reference variables (Uns realm, plain value nodes): terminal Bad then
// in-place remove.
//
// WHAT REACHES THIS LOOP (the "→ rebuild" this comment used to claim is long dead — re-points are
// surgical now): only genuine DEREFERENCES, i.e. the equipment dropped the reference row, so the node
// is gone for good. A CHANGED reference never lands in RemovedUnsReferenceVariables at all — the
// planner reports it as a UnsReferenceDelta, which Apply routes above either to an in-place
// UpdateTagAttributes (identity unchanged) or to its own remove-the-previous + recreate-the-current
// pair (identity changed). That second shape does remove a node, but it removes it from Apply's delta
// loop, not from here.
// is gone for good — INCLUDING every reference of a removed equipment, which the planner lists here
// too. A CHANGED reference never lands in RemovedUnsReferenceVariables at all — the planner reports it
// as a UnsReferenceDelta, which Apply routes above either to an in-place UpdateTagAttributes (identity
// unchanged) or to its own remove-the-previous + recreate-the-current pair (identity changed). That
// second shape does remove a node, but it removes it from Apply's delta loop, not from here.
//
// Gitea #599 (archreview 03/P5): this runs BEFORE the equipment-subtree removal below, deliberately,
// instead of skipping a removed equipment's references with the NotSubsumed filter the loops above
// use. Whether the subtree removal reaches a reference depends on its NodeId, not on its parent: the
// node manager scopes a subtree by id prefix, and a reference's NodeId is Area/Line/Equipment/Name
// while the equipment folder's is its EquipmentId — so in production the subtree does NOT reach it,
// and a filter would leak the node. Where an id does sit under the equipment id, the old order had the
// subtree take the node first, this loop then reported it unknown, and the deploy ratcheted to a full
// rebuild — severing every subscription on the server to remove one equipment. Removing references
// first is correct for both shapes: each is still present, gets its terminal Bad, and goes through
// RemoveVariableNode, which also severs its inverse Organizes edge on the surviving raw node.
foreach (var v in plan.RemovedUnsReferenceVariables)
{
SafeWriteValue(v.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
}
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
// top-of-Apply block already wrote the equipment id's terminal condition state.
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed RAW tags (Raw realm): an alarm-bearing raw tag is a condition node (terminal
// RemovedConditionState + RemoveAlarmConditionNode); a plain raw value tag is a variable (terminal Bad
// + RemoveVariableNode).
@@ -522,10 +533,8 @@ public sealed class AddressSpaceApplier
foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
// v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its
// equipment folder (MaterialiseUnsReferences passes EquipmentId as the parent), and the equipment
// folder is a UNS-realm node — the realm AnnounceAddedNodes hardcodes. So it announces correctly with
// no realm plumbing. (Added RAW containers/tags are still NOT announced: their parents are Raw-realm
// nodes, which needs a realm-aware RaiseNodesAddedModelChange call — see the gap note in
// OpcUaPublishActor.HandleRebuild.)
// folder is a UNS-realm node, announced in the Uns realm like every id this method returns. Added RAW
// containers/tags have Raw-realm parents and are computed separately (ComputeRawAddAnnouncements).
foreach (var v in plan.AddedUnsReferenceVariables) ids.Add(v.EquipmentId);
foreach (var eq in plan.AddedEquipment)
ids.Add(string.IsNullOrWhiteSpace(eq.UnsLineId) ? eq.EquipmentId : eq.UnsLineId);
@@ -535,6 +544,42 @@ public sealed class AddressSpaceApplier
return list;
}
/// <summary>
/// Gitea #599 (archreview 01/U-8 = 03/P6) — the Raw-realm counterpart of
/// <see cref="ComputeAddAnnouncements"/>: the deduplicated, ordinal-sorted RAW parent node ids to announce
/// after a non-rebuild apply materialised added raw containers (Folder/Driver/Device/TagGroup) and raw
/// tags. Each added node announces its <c>ParentNodeId</c> — the exact parent
/// <c>MaterialiseRawSubtree</c> hangs it under. A parent that is ITSELF added in the same plan is skipped:
/// its own parent is announced, and a client re-browsing there discovers the whole new subtree, so the
/// inner announcements would only be noise. A top-level container (no parent — it hangs under the shared
/// root folder, which has no realm-qualified id to announce) announces itself, the same convention an
/// added equipment with no line uses.
/// </summary>
/// <param name="plan">The plan whose added raw nodes' parents to announce.</param>
/// <returns>The distinct affected Raw-realm parent node ids, ordinal-sorted.</returns>
public IReadOnlyList<string> ComputeRawAddAnnouncements(AddressSpacePlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var added = new HashSet<string>(StringComparer.Ordinal);
foreach (var c in plan.AddedRawContainers) added.Add(c.NodeId);
foreach (var t in plan.AddedRawTags) added.Add(t.NodeId);
var ids = new HashSet<string>(StringComparer.Ordinal);
void Announce(string nodeId, string? parentNodeId)
{
if (string.IsNullOrWhiteSpace(parentNodeId)) ids.Add(nodeId);
else if (!added.Contains(parentNodeId)) ids.Add(parentNodeId);
}
foreach (var c in plan.AddedRawContainers) Announce(c.NodeId, c.ParentNodeId);
foreach (var t in plan.AddedRawTags) Announce(t.NodeId, t.ParentNodeId);
var list = ids.ToList();
list.Sort(StringComparer.Ordinal);
return list;
}
/// <summary>The materialise-parent node id a tag/vtag's variable (or native-alarm condition) hangs
/// under: the equipment folder when <paramref name="folderPath"/> is null/empty, else the FolderPath
/// sub-folder. Mirrors the parent derivation the Materialise passes use.</summary>
@@ -561,21 +606,29 @@ public sealed class AddressSpaceApplier
/// <summary>
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
/// <see cref="ComputeAddAnnouncements"/>, so model-aware clients re-browse the parents the new nodes
/// were added under. Called by <c>OpcUaPublishActor</c> AFTER the Materialise passes (the nodes exist
/// by then) and ONLY on a non-rebuild apply — after a full rebuild the announcement is moot
/// (subscriptions are dead anyway). Uses the existing, guard-covered
/// <see cref="ComputeAddAnnouncements"/> (Uns realm) and <see cref="ComputeRawAddAnnouncements"/>
/// (Raw realm), so model-aware clients re-browse the parents the new nodes were added under. Called by
/// <c>OpcUaPublishActor</c> AFTER the Materialise passes (the nodes exist by then) and ONLY on a
/// non-rebuild apply — after a full rebuild the announcement is moot (subscriptions are dead anyway).
/// Uses the existing, guard-covered, realm-aware
/// <see cref="IOpcUaAddressSpaceSink.RaiseNodesAddedModelChange"/> — no new sink member. Each call is
/// Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand).
/// <para>Gitea #599: until the raw half existed, surgically-added raw containers/tags were materialised
/// but never announced, so a connected model-aware client could not discover them until it happened to
/// re-browse. (Before surgical deploys every raw add rebuilt the address space, which hid the gap.)</para>
/// </summary>
/// <param name="plan">The plan whose added nodes to announce.</param>
public void AnnounceAddedNodes(AddressSpacePlan plan)
{
foreach (var id in ComputeAddAnnouncements(plan))
{
try { _sink.RaiseNodesAddedModelChange(id, AddressSpaceRealm.Uns); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
}
foreach (var id in ComputeAddAnnouncements(plan)) SafeAnnounce(id, AddressSpaceRealm.Uns);
foreach (var id in ComputeRawAddAnnouncements(plan)) SafeAnnounce(id, AddressSpaceRealm.Raw);
}
/// <summary>Raise one NodeAdded model change, swallowing (and Warning-logging) any sink fault.</summary>
private void SafeAnnounce(string nodeId, AddressSpaceRealm realm)
{
try { _sink.RaiseNodesAddedModelChange(nodeId, realm); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node} ({Realm})", nodeId, realm); }
}
/// <summary>
@@ -592,15 +592,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// (subscriptions are dead anyway) — the guard skips it. AnnounceAddedNodes is Safe-wrapped
// internally, so a faulting announce can never break the deploy.
//
// KNOWN GAP, and NOT a neutral pre-existing one — the delta work made it matter. Before this
// branch, a plan carrying raw adds went through a full RebuildAddressSpace, so every client had to
// re-browse anyway and the missing announcement changed nothing observable. Now those adds are
// materialised surgically while existing subscriptions stay alive, so an unannounced add is
// genuinely INVISIBLE until a client happens to browse again.
// Added UNS reference variables ARE announced (their equipment folder is the parent, which is the
// realm AnnounceAddedNodes hardcodes). Added RAW containers/tags are still not: the announce is
// Uns-only, and a raw parent needs a realm-aware RaiseNodesAddedModelChange call. That is a real
// follow-up, not an accepted limitation.
// The announce is realm-aware (Gitea #599): UNS parents in the Uns realm, and the parents of
// added RAW containers/tags in the Raw realm. It matters because of the delta work — before it, a
// plan carrying raw adds went through a full RebuildAddressSpace, so every client re-browsed
// anyway; now those adds are materialised surgically while existing subscriptions stay alive, and
// an unannounced add would stay invisible until a client happened to browse again.
if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));