feat(opcua): route AddressSpaceApplier through the classifier — pure-add deploys no longer rebuild (R2-07 T2)

This commit is contained in:
Joseph Doherty
2026-07-13 11:46:24 -04:00
parent bb226f45ed
commit 0d717e4785
5 changed files with 210 additions and 85 deletions
@@ -7,22 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an
/// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
/// <see cref="AddressSpaceComposition"/> snapshots:
/// <see cref="AddressSpaceComposition"/> snapshots, routing each delta class to its MINIMAL mutation
/// (R2-07 03/P1) via <see cref="AddressSpaceChangeClassifier"/> instead of rebuilding the whole
/// address space for any topology change (which would kill every client's server-wide subscriptions):
///
/// <list type="bullet">
/// <item>RemovedEquipment / RemovedAlarms — write Bad-quality on every removed
/// node id then call <c>RebuildAddressSpace</c> at the end so the sink can
/// actually tear down the OPC UA folders + variables.</item>
/// <item>AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager
/// will repopulate from the persisted artifact). For now we record the work.</item>
/// <item>ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter
/// that lands in F10b will decide between in-place property writes and
/// tear-down + rebuild.</item>
/// <item><b>PureAdd</b> — no rebuild. <c>OpcUaPublishActor</c>'s idempotent Materialise passes
/// create exactly the added folders/variables/conditions (no-op'ing existing nodes), then
/// <c>AnnounceAddedNodes</c> raises a Part 3 <c>NodeAdded</c> per affected parent so
/// model-aware clients re-browse. Existing MonitoredItems are untouched.</item>
/// <item><b>AttributeOnly</b> — no rebuild. Surgical-eligible changed tags + UNS folder renames
/// are applied IN PLACE via <see cref="ISurgicalAddressSpaceSink"/> (F10b). Any <c>false</c>
/// or throw from a surgical call falls back to a full rebuild (safe default).</item>
/// <item><b>PureRemove</b> — scoped in-place teardown of only the removed subtree (writes a
/// terminal Bad / <c>RemovedConditionState</c> first so in-flight MonitoredItems observe the
/// removal). Maps to a full rebuild until R2-07 Phase 2 ships.</item>
/// <item><b>AddRemoveMix</b> — removes-then-adds within one apply. Maps to a full rebuild until
/// R2-07 Phase 3 ships.</item>
/// <item><b>Rebuild</b> — the default-closed safety valve for any node-affecting change
/// (ChangedEquipment / ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant
/// ChangedEquipmentVirtualTags), so the worst outcome of any misclassification is today's
/// full-rebuild behaviour.</item>
/// </list>
///
/// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
/// and tests can capture every call.
/// Stays pure-of-SDK so production binds a real SDK sink, dev/Mac binds
/// <see cref="NullOpcUaAddressSpaceSink"/>, and tests can capture every call.
/// </summary>
public sealed class AddressSpaceApplier
{
@@ -111,36 +120,26 @@ public sealed class AddressSpaceApplier
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count;
// Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment
// VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive
// every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a
// re-severitied alarm, a flipped tag dataType) must still rebuild or the running server keeps
// the stale node.
// ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the
// address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which
// re-spawns the affected driver actor without re-materialising any nodes.
// F10b (vtag skip): a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/
// Historize is node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is
// byte-identical and the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT
// respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every
// client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/
// FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild.
// F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
// IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no
// alarm condition node) can be updated IN PLACE on the existing node via
// ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full
// rebuild and preserving subscriptions. The shape fields (DataType/IsArray/ArrayLength) are now
// surgical because the sink swaps DataType + ValueRank + ArrayDimensions in place and raises a
// GeneralModelChangeEvent. Any other tag difference (FullName/Name/DriverInstanceId/identity/alarm) —
// or a sink that lacks the surgical capability, or a node that turns out missing — falls back to a
// full rebuild (safe default).
var structuralRebuild =
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
plan.ChangedEquipmentTags.Any(d => !AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible(d)) ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 ||
plan.ChangedEquipmentVirtualTags.Any(d => !AddressSpaceChangeClassifier.VtagDeltaIsNodeIrrelevant(d));
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
// the planner's diff (the planner stays a pure diff). The routing here:
// • PureAdd / AttributeOnly ⇒ NO rebuild. The idempotent Materialise passes in
// OpcUaPublishActor.HandleRebuild create exactly the added nodes (no-op'ing existing ones), so
// every client subscription server-wide survives; coincident surgical tag deltas + folder
// renames are applied IN PLACE via ISurgicalAddressSpaceSink below (any false/throw there falls
// back to a full rebuild — the F10b contract).
// • PureRemove / AddRemoveMix ⇒ full rebuild UNTIL their phases ship (R2-07 T11 / T13); mapping
// them here keeps each phase independently shippable with today's behaviour as the floor.
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
// — the classifier's rule 2, which also catches any future plan field that makes a changed
// record unequal, so the worst outcome of a misclassification is today's full rebuild.
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
// a driver-only plan AttributeOnly, so it never rebuilds here.
var kind = AddressSpaceChangeClassifier.Classify(plan);
var mustRebuild = kind is AddressSpaceChangeKind.Rebuild
or AddressSpaceChangeKind.PureRemove
or AddressSpaceChangeKind.AddRemoveMix;
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
@@ -151,7 +150,7 @@ public sealed class AddressSpaceApplier
var rebuilt = false;
var rebuildFailed = false;
if (structuralRebuild)
if (mustRebuild)
{
rebuildFailed = !SafeRebuild();
rebuilt = true;
@@ -203,8 +202,8 @@ public sealed class AddressSpaceApplier
}
_logger.LogInformation(
"AddressSpaceApplier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);
// After the address-space work has completed, auto-provision the historian for the added
// historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block