From 502d7650d08ad67af40c167d043604231f66b0b5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 12:18:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(opcua):=20surgical=20pure-remove=20deploys?= =?UTF-8?q?=20=E2=80=94=20scoped=20teardown,=20no=20full=20rebuild=20(R2-0?= =?UTF-8?q?7=20T11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2-07-surgical-pure-adds-plan.md.tasks.json | 2 +- .../AddressSpaceApplier.cs | 120 +++++++++++++- .../AddressSpaceApplierTests.cs | 153 ++++++++++++++++-- 3 files changed, 257 insertions(+), 18 deletions(-) diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json index 6e179a63..32d39733 100644 --- a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json +++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json @@ -96,7 +96,7 @@ { "id": "T11", "subject": "Phase 2: applier PureRemove arm \u2014 terminal Bad/RemovedConditionState writes, equipment subsumption, false-to-rebuild ratchet (high-risk)", - "status": "pending", + "status": "completed", "blockedBy": [ "T10" ] diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 677ffb15..61a963a7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -128,8 +128,12 @@ public sealed class AddressSpaceApplier // 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. + // • PureRemove ⇒ NO full rebuild. The removed nodes are torn down IN PLACE, scoped to the affected + // subtree, via the ISurgicalAddressSpaceSink remove members below (each preceded by a terminal + // Bad / RemovedConditionState write so in-flight MonitoredItems observe the removal); subscribers + // of OTHER nodes are untouched. Any false/throw from a remove ⇒ full-rebuild fallback (ratchet). + // • AddRemoveMix ⇒ full rebuild UNTIL Phase 3 ships (R2-07 T13); mapping it 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 @@ -138,7 +142,6 @@ public sealed class AddressSpaceApplier // 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(); @@ -155,7 +158,7 @@ public sealed class AddressSpaceApplier rebuildFailed = !SafeRebuild(); rebuilt = true; } - else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0) + else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || kind == AddressSpaceChangeKind.PureRemove) { if (_sink is ISurgicalAddressSpaceSink surgical) { @@ -191,6 +194,13 @@ public sealed class AddressSpaceApplier } if (!ok) { allApplied = false; break; } } + // R2-07 Phase 2 — PureRemove scoped teardown (runs after any coincident surgical + // attribute updates / renames succeeded). Terminal Bad / RemovedConditionState writes then + // in-place removes; any false/throw flips allApplied → the rebuild ratchet below. + if (allApplied && kind == AddressSpaceChangeKind.PureRemove) + { + allApplied = ApplyPureRemove(surgical, plan, ts, ref failedNodes); + } if (!allApplied) { rebuildFailed = !SafeRebuild(); rebuilt = true; } } else @@ -218,6 +228,108 @@ public sealed class AddressSpaceApplier return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes); } + /// + /// R2-07 Phase 2 — apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full + /// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose + /// EquipmentId is itself in is SUBSUMED by the + /// subtree removal and skipped individually. For surviving-equipment removals: a removed value + /// variable gets a terminal Bad WriteValue then ; + /// a removed alarm-bearing tag (the pre-R2-07 today-gap: it got no terminal condition write) and a + /// removed scripted alarm get a terminal write then + /// ; each removed equipment gets one + /// . The terminal write is what an + /// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets + /// BadNodeIdUnknown. Returns false on the FIRST remove that reports the node unknown / throws + /// (the caller's one-way rebuild ratchet takes over), leaving no further surgical work attempted. + /// + /// The surgical sink to route removes through. + /// The PureRemove plan. + /// The timestamp for the terminal writes. + /// Accumulator for swallowed terminal-write failures (archreview 01/S-1). + /// True when every removal succeeded; false on the first failure (caller rebuilds). + private bool ApplyPureRemove(ISurgicalAddressSpaceSink surgical, AddressSpacePlan plan, DateTime ts, ref int failedNodes) + { + var removedEquipmentIds = new HashSet( + plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal); + bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId); + + // Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment. + foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId))) + { + var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); + if (tag.Alarm is not null) + { + // Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap + // closed), then remove the condition in place. + if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++; + if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false; + } + else + { + // Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal. + SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts); + if (!SafeRemoveVariable(surgical, nodeId)) return false; + } + } + + // Removed VirtualTags (always plain value variables) for surviving equipment. + foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId))) + { + var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); + SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts); + if (!SafeRemoveVariable(surgical, nodeId)) return false; + } + + // Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already + // written by the top-of-Apply removal block; here we tear the condition node down in place. + foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId))) + { + if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId)) 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)) return false; + } + + return true; + } + + /// Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback). + /// true when the node was removed; false when unknown or the sink threw. + private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId) + { + try { return surgical.RemoveVariableNode(nodeId); } + catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; } + } + + /// Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback). + /// true when the node was removed; false when unknown or the sink threw. + private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId) + { + try { return surgical.RemoveAlarmConditionNode(nodeId); } + catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; } + } + + /// Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback). + /// true when the subtree was removed; false when unknown or the sink threw. + private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId) + { + try { return surgical.RemoveEquipmentSubtree(nodeId); } + catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; } + } + + /// Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad + /// on a removed variable so an in-flight MonitoredItem observes the removal. + /// true when the write landed; false when the sink threw. + private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) + { + try { _sink.WriteValue(nodeId, value, quality, ts); return true; } + catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; } + } + /// /// Compute the deduplicated, deterministically-ordered set of affected PARENT node ids to announce /// (Part 3 NodeAdded) after a pure-add apply's Materialise passes have created the new nodes. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index 86ac2f37..72021ec8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -25,9 +25,12 @@ public sealed class AddressSpaceApplierTests sink.AlarmWrites.ShouldBeEmpty(); } - /// Verifies that removed equipment writes inactive alarm state and triggers rebuild. + /// R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal + /// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via + /// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the + /// pre-R2-07 "removed equipment ⇒ rebuild" pin.) [Fact] - public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild() + public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); @@ -36,11 +39,13 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); outcome.RemovedNodes.ShouldBe(2); - outcome.RebuildCalled.ShouldBeTrue(); + outcome.RebuildCalled.ShouldBeFalse(); + sink.RebuildCalls.ShouldBe(0); + // Terminal "no-event" condition state written per id (inactive + acked + confirmed). sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" }); - // Removed nodes are reset to the "no-event" state: inactive + acked + confirmed + enabled. sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); + // Each equipment torn down as a subtree. + sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") }); } /// R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent @@ -789,10 +794,11 @@ public sealed class AddressSpaceApplierTests sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first } - /// R2-07 T2 phase pin — a remove-only plan still triggers a full rebuild until Phase 2 - /// (PureRemove) ships. Flipped in T11. + /// R2-07 T11 — a remove-one-tag plan is a PureRemove: the applier writes a terminal Bad to the + /// removed variable then removes it in place via RemoveVariableNode — NO rebuild. (Supersedes the T2 + /// phase pin.) [Fact] - public void Removed_equipment_tags_only_still_rebuild_phase_pin() + public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); @@ -807,6 +813,116 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); + outcome.RebuildCalled.ShouldBeFalse(); + sink.RebuildCalls.ShouldBe(0); + var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed"); + // Terminal Bad written to the removed variable BEFORE the removal. + sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad)); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId)); + } + + /// R2-07 T11 — a removed alarm-bearing tag writes the terminal RemovedConditionState (closing + /// the pre-R2-07 today-gap where a removed alarm tag got no condition write) then removes the CONDITION + /// node in place (not a value variable). + [Fact] + public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition() + { + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var plan = EmptyPlan with + { + RemovedEquipmentTags = new[] + { + new EquipmentTagPlan("tag-alm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, + Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), + }, + }; + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeFalse(); + var nodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp"); + // Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node. + var write = sink.AlarmWrites.ShouldHaveSingleItem(); + write.NodeId.ShouldBe(nodeId); + (!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue(); + // Removed as a condition node, not a value variable. + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId)); + sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId); + } + + /// R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by + /// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove. + [Fact] + public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal() + { + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var plan = EmptyPlan with + { + RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") }, + RemovedEquipmentTags = new[] + { + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), + }, + RemovedEquipmentVirtualTags = new[] + { + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }), + }, + }; + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeFalse(); + // ONLY the subtree removal fires — no individual var removal for the subsumed child tag/vtag. + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1")); + } + + /// R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier + /// falls back to a full rebuild (the one-way ratchet) and attempts no further surgical removes. + [Fact] + public void Pure_remove_with_remove_returns_false_falls_back_to_rebuild() + { + var sink = new RecordingSink { RemoveReturns = false }; + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var plan = EmptyPlan with + { + RemovedEquipmentTags = new[] + { + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), + new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), + }, + }; + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeTrue(); + sink.RebuildCalls.ShouldBe(1); + // Ratchet: stopped at the FIRST failed remove — did not attempt the second. + sink.RemoveCalls.Count.ShouldBe(1); + } + + /// R2-07 T11 — a PureRemove on a sink lacking the surgical capability falls back to a full + /// rebuild (safe default), same as the F10b attribute path. + [Fact] + public void Pure_remove_on_non_surgical_sink_rebuilds() + { + var sink = new PlainRecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var plan = EmptyPlan with + { + RemovedEquipmentTags = new[] + { + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), + }, + }; + + var outcome = applier.Apply(plan); + outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } @@ -1276,9 +1392,14 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - outcome.RemovedNodes.ShouldBe(2); // both removals counted (was 0 before the fix) + // R2-07 T11 — a removed-tag + removed-vtag plan is a PureRemove: both variables are torn down in + // place (RemoveVariableNode ×2), NO full rebuild; both removals are still counted. + outcome.RebuildCalled.ShouldBeFalse(); + sink.RebuildCalls.ShouldBe(0); + outcome.RemovedNodes.ShouldBe(2); // both removals counted + sink.RemoveCalls.Count.ShouldBe(2); + sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Speed"))); + sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency"))); } // ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) ----- @@ -2072,12 +2193,18 @@ public sealed class AddressSpaceApplierTests /// Gets the list of recorded alarm-condition materialise calls. public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList(); - /// Records a value write (no-op in this recording sink). + /// Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad. + public ConcurrentQueue<(string NodeId, OpcUaQuality Quality)> ValueWriteQueue { get; } = new(); + /// Gets the list of recorded value writes. + public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList(); + + /// Records a value write (NodeId + quality). /// The node ID. /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + => ValueWriteQueue.Enqueue((nodeId, quality)); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot.