From bfbdce32e4d2593a21d5250190448d5b7e2c6620 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 17:26:53 -0400 Subject: [PATCH] perf(opcuaserver): non-rebuild applies materialise the delta, not the composition --- .../AddressSpaceApplier.cs | 135 +++++--- .../OpcUa/OpcUaPublishActor.cs | 173 +++++++++-- .../AddressSpaceApplierRawUnsTests.cs | 94 ++++++ .../OpcUa/OpcUaPublishActorRebuildTests.cs | 289 +++++++++++++++++- 4 files changed, 626 insertions(+), 65 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 06948558..ef3abb96 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -256,10 +256,11 @@ public sealed class AddressSpaceApplier // today's cost of a full rebuild killing every subscription server-wide. // // WHERE the recreate happens: the publish actor's materialise passes, which run after Apply - // returns — full-composition MaterialiseUnsReferences today, narrowed to the plan's added + - // changed-current references by Task 6 of the surgical-deploys plan. Either way the ordering - // holds (remove here, recreate strictly after), and the recreated node inherits its shape / - // historian tagname from the FULL composition's backing raw tag. + // returns. On a NON-rebuild apply that pass is now narrowed to the plan's added references + // PLUS the CURRENT side of every delta removed here (surgical-deploys Task 6) — dropping the + // currents from that subset would leave a re-pointed / renamed reference removed and never + // recreated. Either way the ordering holds (remove here, recreate strictly after), and the + // recreated node inherits its shape / historian tagname from the UnsReferenceVariable record. foreach (var d in allApplied ? unsRefDeltas : Enumerable.Empty()) { if (!SafeWriteValue(d.Previous.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns)) failedNodes++; @@ -699,6 +700,14 @@ public sealed class AddressSpaceApplier /// clients browsing the server see proper folder structure instead of flat tag ids. /// Idempotent: each EnsureFolder call returns the existing folder if already /// present, so re-applies are cheap. + /// This pass has NO subset overload, deliberately — it always runs over the FULL + /// composition, unlike the five node-materialising passes below (which take an optional + /// only* delta subset so a non-rebuild apply touches the plan's added nodes instead of the + /// whole address space). Two reasons: it is O(areas + lines + equipment), which is a rounding error + /// next to the tag/reference counts the subsetting exists to bound; and every added node materialised + /// by the other passes needs its ANCESTOR folders to already exist, so narrowing this one to the + /// plan's delta would mean re-deriving the ancestor closure of every add — strictly more work than + /// re-ensuring the folders. /// /// The composition result containing the hierarchy to materialise. /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor @@ -739,18 +748,33 @@ public sealed class AddressSpaceApplier /// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle /// arrays). Every sink call passes EXPLICITLY — the driver binds /// values to these raw NodeIds, and every referencing UNS node fans out from them. Idempotent. + /// Subset overload (2026-08-15 surgical-deploys). / + /// narrow WHAT is materialised to a plan's delta while + /// stays the FULL composition for context lookups — specifically the + /// native-alarm notifier map (), which must see every + /// equipment on the node, not just the ones this deploy touched. A null subset means "the whole + /// composition" (the rebuild path), so every pre-existing call site is unchanged. Ordering holds in + /// both modes: the composer sorts RawContainers by NodeId and the planner sorts its added set by + /// the same key, so a parent's RawPath still precedes each child's. /// - /// The composition carrying the Raw subtree. + /// The FULL composition — the context for lookups even when a subset is passed. + /// Materialise only these containers; null ⇒ the whole composition's. + /// Materialise only these tags; null ⇒ the whole composition's. /// The count of swallowed sink failures in this pass. - public int MaterialiseRawSubtree(AddressSpaceComposition composition) + public int MaterialiseRawSubtree( + AddressSpaceComposition composition, + IReadOnlyList? onlyContainers = null, + IReadOnlyList? onlyTags = null) { ArgumentNullException.ThrowIfNull(composition); - if (composition.RawContainers.Count == 0 && composition.RawTags.Count == 0) return 0; + var containers = onlyContainers ?? composition.RawContainers; + var tags = onlyTags ?? composition.RawTags; + if (containers.Count == 0 && tags.Count == 0) return 0; var failed = 0; // Containers first — the composer sorts them by NodeId (ordinal) so a parent's RawPath precedes each // child's; EnsureFolder is idempotent, so a re-apply is cheap. - foreach (var c in composition.RawContainers) + foreach (var c in containers) { if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++; } @@ -761,7 +785,7 @@ public sealed class AddressSpaceApplier // when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing). IReadOnlyDictionary? equipIdByFolderPath = null; - foreach (var t in composition.RawTags) + foreach (var t in tags) { if (t.Alarm is not null) { @@ -817,8 +841,8 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, failed={Failed})", - composition.RawContainers.Count, composition.RawTags.Count, failed); + "AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, subset={Subset}, failed={Failed})", + containers.Count, tags.Count, onlyContainers is not null || onlyTags is not null, failed); return failed; } @@ -839,16 +863,25 @@ public sealed class AddressSpaceApplier /// shape edit produce the coincident UnsReferenceDelta that drives this node's repair /// (remove-previous in Apply, recreate here). A back-lookup would re-introduce the split the /// 2026-08-15 review found: a record that says one thing and a materialised node built from another. + /// Subset overload (2026-08-15 surgical-deploys). narrows WHAT is + /// materialised to a plan's delta (its added references PLUS the current side of every changed + /// one — Apply removed the previous node, so omitting the currents would leave a re-pointed or + /// renamed reference removed and never recreated). stays the FULL + /// composition for context; a null subset means "the whole composition" (the rebuild path). /// - /// The composition carrying the UNS reference variables. + /// The FULL composition — the context even when a subset is passed. + /// Materialise only these references; null ⇒ the whole composition's. /// The count of swallowed sink failures in this pass. - public int MaterialiseUnsReferences(AddressSpaceComposition composition) + public int MaterialiseUnsReferences( + AddressSpaceComposition composition, + IReadOnlyList? only = null) { ArgumentNullException.ThrowIfNull(composition); - if (composition.UnsReferenceVariables.Count == 0) return 0; + var references = only ?? composition.UnsReferenceVariables; + if (references.Count == 0) return 0; var failed = 0; - foreach (var v in composition.UnsReferenceVariables) + foreach (var v in references) { var isArray = v.BackingIsArray; uint? arrayLength = v.BackingArrayLength; @@ -872,8 +905,8 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, failed={Failed})", - composition.UnsReferenceVariables.Count, failed); + "AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, subset={Subset}, failed={Failed})", + references.Count, only is not null, failed); return failed; } @@ -890,14 +923,21 @@ public sealed class AddressSpaceApplier /// variables (and any per-tag sub-folder); owns the /// equipment folders and this pass never re-creates them. The sink's EnsureVariable /// takes a plain string dataType (not a DriverAttributeInfo). + /// Subset overload (2026-08-15 surgical-deploys). narrows WHAT is + /// materialised to a plan's added tags; stays the FULL composition for + /// context. A null subset means "the whole composition" (the rebuild path). /// - /// The composition result containing the equipment tags to materialise. + /// The FULL composition — the context even when a subset is passed. + /// Materialise only these tags; null ⇒ the whole composition's. /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor /// sums it into the degraded-apply surface (archreview 01/S-1). - public int MaterialiseEquipmentTags(AddressSpaceComposition composition) + public int MaterialiseEquipmentTags( + AddressSpaceComposition composition, + IReadOnlyList? only = null) { ArgumentNullException.ThrowIfNull(composition); - if (composition.EquipmentTags.Count == 0) return 0; + var equipmentTags = only ?? composition.EquipmentTags; + if (equipmentTags.Count == 0) return 0; var failed = 0; // Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder @@ -905,7 +945,7 @@ public sealed class AddressSpaceApplier // equipment folder, which MaterialiseHierarchy already created (never re-create // the equipment folder here). var foldersCreated = new HashSet(StringComparer.Ordinal); - foreach (var tag in composition.EquipmentTags) + foreach (var tag in equipmentTags) { if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath); @@ -919,7 +959,7 @@ public sealed class AddressSpaceApplier // signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to // route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly. // Per-variable idempotency relies on the sink's own EnsureVariable. - foreach (var tag in composition.EquipmentTags) + foreach (var tag in equipmentTags) { var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.EquipmentId @@ -948,9 +988,10 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, failed={Failed})", - composition.EquipmentTags.Count, - composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(), + "AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})", + equipmentTags.Count, + equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(), + only is not null, failed); return failed; } @@ -967,21 +1008,28 @@ public sealed class AddressSpaceApplier /// different equipments never collide in the sink (which keys on NodeId). Variables start /// BadWaitingForInitialData; VirtualTagActor fills live values in a later milestone. /// Idempotent (per-variable idempotency relies on the sink's own EnsureVariable). + /// Subset overload (2026-08-15 surgical-deploys). narrows WHAT is + /// materialised to a plan's added VirtualTags; stays the FULL + /// composition for context. A null subset means "the whole composition" (the rebuild path). /// - /// The composition result containing the equipment VirtualTags to materialise. + /// The FULL composition — the context even when a subset is passed. + /// Materialise only these VirtualTags; null ⇒ the whole composition's. /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor /// sums it into the degraded-apply surface (archreview 01/S-1). - public int MaterialiseEquipmentVirtualTags(AddressSpaceComposition composition) + public int MaterialiseEquipmentVirtualTags( + AddressSpaceComposition composition, + IReadOnlyList? only = null) { ArgumentNullException.ThrowIfNull(composition); - if (composition.EquipmentVirtualTags.Count == 0) return 0; + var virtualTags = only ?? composition.EquipmentVirtualTags; + if (virtualTags.Count == 0) return 0; var failed = 0; // Sub-folders first — a VirtualTag's FolderPath becomes one folder UNDER its equipment folder // (deduped per distinct equipment+path). VirtualTags with no FolderPath hang directly under the // equipment folder, which MaterialiseHierarchy already created (never re-create it here). var foldersCreated = new HashSet(StringComparer.Ordinal); - foreach (var v in composition.EquipmentVirtualTags) + foreach (var v in virtualTags) { if (string.IsNullOrWhiteSpace(v.FolderPath)) continue; var folderNodeId = UnsSubFolder(v.EquipmentId, v.FolderPath); @@ -995,7 +1043,7 @@ public sealed class AddressSpaceApplier // forwards historized results to IHistoryWriter. It is intentionally NOT materialised as an SDK // Historizing/HistoryRead variable here (no server-side OPC UA HistoryRead for vtags), so these // stay plain read-only computed-output nodes. - foreach (var v in composition.EquipmentVirtualTags) + foreach (var v in virtualTags) { var parent = string.IsNullOrWhiteSpace(v.FolderPath) ? v.EquipmentId @@ -1006,9 +1054,10 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment}, failed={Failed})", - composition.EquipmentVirtualTags.Count, - composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count(), + "AddressSpaceApplier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment}, subset={Subset}, failed={Failed})", + virtualTags.Count, + virtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count(), + only is not null, failed); return failed; } @@ -1021,18 +1070,25 @@ public sealed class AddressSpaceApplier /// Disabled alarms are skipped — they expose no node. Must run AFTER /// so the equipment folders exist. Idempotent (the sink's /// MaterialiseAlarmCondition re-creates cleanly on re-apply). + /// Subset overload (2026-08-15 surgical-deploys). narrows WHAT is + /// materialised to a plan's added alarms; stays the FULL composition for + /// context. A null subset means "the whole composition" (the rebuild path). /// - /// The composition result containing the scripted alarms to materialise. + /// The FULL composition — the context even when a subset is passed. + /// Materialise only these alarms; null ⇒ the whole composition's. /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor /// sums it into the degraded-apply surface (archreview 01/S-1). - public int MaterialiseScriptedAlarms(AddressSpaceComposition composition) + public int MaterialiseScriptedAlarms( + AddressSpaceComposition composition, + IReadOnlyList? only = null) { ArgumentNullException.ThrowIfNull(composition); - if (composition.EquipmentScriptedAlarms.Count == 0) return 0; + var alarms = only ?? composition.EquipmentScriptedAlarms; + if (alarms.Count == 0) return 0; var materialised = 0; var failed = 0; - foreach (var alarm in composition.EquipmentScriptedAlarms) + foreach (var alarm in alarms) { if (!alarm.Enabled) continue; if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false, realm: AddressSpaceRealm.Uns)) failed++; @@ -1040,10 +1096,11 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment}, failed={Failed})", + "AddressSpaceApplier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment}, subset={Subset}, failed={Failed})", materialised, - composition.EquipmentScriptedAlarms.Where(a => a.Enabled) + alarms.Where(a => a.Enabled) .Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count(), + only is not null, failed); return failed; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index dd15cac4..6546a158 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -503,31 +503,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node // Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter. var failedNodes = outcome.FailedNodes; + // The UNS Area/Line/Equipment folder hierarchy is materialised in FULL on every apply, rebuild or + // not: it is O(areas + lines + equipment) — trivial next to the tag counts the delta passes below + // exist to bound — and every node the delta passes add needs its ancestor folders to already + // exist, so narrowing it would mean re-deriving each add's ancestor closure (strictly more work). failedNodes += _applier.MaterialiseHierarchy(composition); - // v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then - // tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw - // edge finds its raw target. The driver binds live values to these raw NodeIds. - failedNodes += _applier.MaterialiseRawSubtree(composition); - // v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment - // folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw - // node. Runs AFTER both the equipment folders AND the raw subtree exist. - failedNodes += _applier.MaterialiseUnsReferences(composition); - // T14 — scripted alarms get their own pass right after the hierarchy so the equipment - // folders they parent under already exist. Materialises real Part 9 AlarmConditionState - // nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled - // alarms are skipped. - failedNodes += _applier.MaterialiseScriptedAlarms(composition); - // Equipment-namespace tags get their own pass: ensures each signal's Variable (and any - // FolderPath sub-folder) exists under its already-materialised equipment folder so - // clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after - // each subscription cycle; variables show BadWaitingForInitialData only until the first - // publish interval fires. - failedNodes += _applier.MaterialiseEquipmentTags(composition); - // Equipment-namespace VirtualTags get their own pass right after the equipment tags: - // ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its - // equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live - // values once the first dependency update arrives; until then variables show BadWaitingForInitialData. - failedNodes += _applier.MaterialiseEquipmentVirtualTags(composition); + failedNodes += MaterialiseNodes(plan, composition, fullComposition: outcome.RebuildCalled); // R2-07 T4b — on a NON-rebuild apply (PureAdd / AttributeOnly), the Materialise passes above // just created exactly the added nodes WITHOUT tearing anything down, so announce them with a @@ -537,6 +518,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // them, so the nodes exist when clients re-browse. After a full rebuild the announcement is moot // (subscriptions are dead anyway) — the guard skips it. AnnounceAddedNodes is Safe-wrapped // internally, so a faulting announce can never break the deploy. + // + // KNOWN, PRE-EXISTING GAP (unchanged by the delta-materialise work above): ComputeAddAnnouncements + // only walks the equipment-namespace add sets, so added RAW containers/tags and added UNS + // reference variables are materialised but never announced. Model-aware clients pick them up on + // their next unprompted browse. if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild")); @@ -561,6 +547,145 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } + /// + /// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms, + /// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over + /// just this plan's delta (surgical-deploys Task 6). MaterialiseHierarchy is NOT one of them: + /// the caller always runs it in full, because parents must exist before any add. + /// + /// + /// Why the delta path exists. The passes are idempotent, but idempotent is not free: + /// every EnsureVariable / EnsureFolder / MaterialiseAlarmCondition is a sink call + /// that takes the node manager's lock and re-derives the node's identity before discovering there is + /// nothing to do. Running them over the whole composition made the cost of ANY apply proportional to + /// the size of the served address space rather than to the size of the change: adding one tag to a + /// 50k-node server was ~50k sink calls, on the actor's pinned publish thread, with every client's + /// publish blocked behind it. + /// Why is the branch key rather than + /// the classifier's kind: a surgical apply that hits a false/throw from the surgical sink falls + /// back to a full RebuildAddressSpace() INSIDE Apply, which wipes every node. That plan + /// is still classified non-Rebuild, so branching on the kind would re-materialise only the delta onto + /// a wiped address space. The outcome flag reports what actually happened. + /// Why changed UNS references are in the subset. Apply repairs a + /// UnsReferenceDelta by REMOVING the previous node and leaving the recreate to this pass, so + /// the subset must carry the current side of every changed reference as well as the added ones + /// — otherwise a re-pointed, renamed, or backing-edited UNS node is removed and never comes back. + /// Accepted narrowing. The plan diffs the THIN ScriptedAlarmPlans projection, so + /// the added-alarm subset is the rich EquipmentScriptedAlarms rows whose id the plan reports as + /// added. An alarm already present in both compositions whose predicate SCRIPT row only now arrived + /// (making it materialisable for the first time) is not in that set — but neither is it in any diff + /// set at all, so on its own it already produced an empty plan and materialised nothing. The delta + /// path extends that pre-existing blindness to the case where it rides along with another change. + /// + /// The plan just applied — the source of the delta subsets. + /// The full composition, always passed as the passes' lookup context. + /// When true, materialise the whole composition (post-rebuild). + /// The summed count of swallowed per-node sink failures across the passes that ran. + private int MaterialiseNodes(AddressSpacePlan plan, AddressSpaceComposition composition, bool fullComposition) + { + var applier = _applier!; + var failedNodes = 0; + + // v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then + // tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw + // edge finds its raw target. The driver binds live values to these raw NodeIds. + if (fullComposition) + { + failedNodes += applier.MaterialiseRawSubtree(composition); + } + else if (plan.AddedRawContainers.Count > 0 || plan.AddedRawTags.Count > 0) + { + failedNodes += applier.MaterialiseRawSubtree(composition, plan.AddedRawContainers, plan.AddedRawTags); + } + + // v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment + // folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw + // node. Runs AFTER both the equipment folders AND the raw subtree exist. + if (fullComposition) + { + failedNodes += applier.MaterialiseUnsReferences(composition); + } + else + { + var unsReferences = DeltaUnsReferences(plan); + if (unsReferences.Count > 0) failedNodes += applier.MaterialiseUnsReferences(composition, unsReferences); + } + + // T14 — scripted alarms get their own pass right after the hierarchy so the equipment + // folders they parent under already exist. Materialises real Part 9 AlarmConditionState + // nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled + // alarms are skipped. + if (fullComposition) + { + failedNodes += applier.MaterialiseScriptedAlarms(composition); + } + else + { + var addedAlarms = DeltaScriptedAlarms(plan, composition); + if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms); + } + + // Equipment-namespace tags get their own pass: ensures each signal's Variable (and any + // FolderPath sub-folder) exists under its already-materialised equipment folder so + // clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after + // each subscription cycle; variables show BadWaitingForInitialData only until the first + // publish interval fires. + if (fullComposition) + { + failedNodes += applier.MaterialiseEquipmentTags(composition); + } + else if (plan.AddedEquipmentTags.Count > 0) + { + failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags); + } + + // Equipment-namespace VirtualTags get their own pass right after the equipment tags: + // ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its + // equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live + // values once the first dependency update arrives; until then variables show BadWaitingForInitialData. + if (fullComposition) + { + failedNodes += applier.MaterialiseEquipmentVirtualTags(composition); + } + else if (plan.AddedEquipmentVirtualTags.Count > 0) + { + failedNodes += applier.MaterialiseEquipmentVirtualTags(composition, plan.AddedEquipmentVirtualTags); + } + + return failedNodes; + } + + /// The UNS reference variables a non-rebuild apply must materialise: the plan's ADDED references + /// plus the CURRENT side of every CHANGED one (whose previous node the applier already removed in place). + /// + /// The plan just applied. + /// The references to materialise (possibly empty). + private static IReadOnlyList DeltaUnsReferences(AddressSpacePlan plan) + { + if (plan.ChangedUnsReferenceVariables.Count == 0) return plan.AddedUnsReferenceVariables; + var list = new List( + plan.AddedUnsReferenceVariables.Count + plan.ChangedUnsReferenceVariables.Count); + list.AddRange(plan.AddedUnsReferenceVariables); + foreach (var d in plan.ChangedUnsReferenceVariables) list.Add(d.Current); + return list; + } + + /// The scripted-alarm host plans a non-rebuild apply must materialise: the rich + /// EquipmentScriptedAlarms rows whose ScriptedAlarmId the plan reports as ADDED (the plan + /// diffs only the thin projection — see the narrowing note on ). + /// The plan just applied. + /// The full composition carrying the rich alarm plans. + /// The alarms to materialise (possibly empty). + private static IReadOnlyList DeltaScriptedAlarms( + AddressSpacePlan plan, AddressSpaceComposition composition) + { + if (plan.AddedAlarms.Count == 0 || composition.EquipmentScriptedAlarms.Count == 0) + return Array.Empty(); + + var addedIds = new HashSet(plan.AddedAlarms.Select(a => a.ScriptedAlarmId), StringComparer.Ordinal); + return composition.EquipmentScriptedAlarms.Where(a => addedIds.Contains(a.ScriptedAlarmId)).ToList(); + } + /// /// Decode the artifact's data-plane authorization inputs (API keys + this cluster's ACL grants) /// and install them for OPC UA session activation (#520 stage 4). No-op when nothing consumes diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs index 150e864c..c8f2244f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs @@ -345,6 +345,100 @@ public sealed class AddressSpaceApplierRawUnsTests sink.Variables.ShouldHaveSingleItem().HistorianTagname.ShouldBe(expected); } + /// + /// 2026-08-15 surgical-deploys (Task 6) — the subset overload materialises ONLY the tags/containers it + /// is handed, while still resolving native-alarm notifier targets from the FULL composition's equipment + /// map. Both halves matter: narrowing the iteration is the whole point (a one-tag deploy used to re-walk + /// every node on the server), and the map has to stay full because the equipment a raw alarm fans to is + /// not part of any raw-tag delta — building it from the subset would silently drop the fan-out. + /// + [Fact] + public void MaterialiseRawSubtree_with_a_subset_ensures_only_the_subset_but_resolves_notifiers_from_the_full_composition() + { + var sink = new RealmRecordingSink(); + var untouched = new RawTagPlan("t0", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float", + Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty()); + var added = new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean", + Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700), + ReferencingEquipmentPaths: new[] { "filling/line1/station1" }); + var composition = new AddressSpaceComposition( + new[] { new UnsAreaProjection("a1", "filling") }, + new[] { new UnsLineProjection("l1", "a1", "line1") }, + new[] { new EquipmentNode("EQ-1", "station1", "l1") }, + Array.Empty(), + Array.Empty()) + { + RawContainers = new[] + { + new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder), + new RawContainerNode("Plant/A", "Plant", "A", RawNodeKind.Driver), + new RawContainerNode("Plant/A/dev", "Plant/A", "dev", RawNodeKind.Device), + }, + RawTags = new[] { untouched, added }, + }; + + // The delta of a one-tag add: no new containers, exactly one new tag. + var failed = NewApplier(sink).MaterialiseRawSubtree( + composition, + onlyContainers: Array.Empty(), + onlyTags: new[] { added }); + + failed.ShouldBe(0); + sink.Folders.ShouldBeEmpty(); // the three existing containers were NOT re-ensured + sink.Variables.ShouldBeEmpty(); // the untouched value tag was NOT re-ensured + sink.Conditions.ShouldHaveSingleItem().NodeId.ShouldBe("Plant/A/dev/OverTemp"); + // …and the notifier still resolved through the FULL composition's Area/Line/Equipment map. + sink.NotifierWirings.ShouldHaveSingleItem().FolderNodeIds.ShouldBe(new[] { "EQ-1" }); + } + + /// A null subset (the rebuild path) still means "the whole composition" — the overload is additive + /// and every pre-existing call site keeps its behaviour. + [Fact] + public void MaterialiseRawSubtree_with_no_subset_still_materialises_the_whole_composition() + { + var sink = new RealmRecordingSink(); + var composition = new AddressSpaceComposition( + Array.Empty(), Array.Empty(), Array.Empty()) + { + RawContainers = new[] { new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder) }, + RawTags = new[] + { + new RawTagPlan("t0", "Plant/a", "Plant", "drv", "a", "Float", Writable: true, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()), + new RawTagPlan("t1", "Plant/b", "Plant", "drv", "b", "Float", Writable: true, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()), + }, + }; + + NewApplier(sink).MaterialiseRawSubtree(composition); + + sink.Folders.Select(f => f.NodeId).ShouldBe(new[] { "Plant" }); + sink.Variables.Select(v => v.NodeId).ShouldBe(new[] { "Plant/a", "Plant/b" }); + } + + /// 2026-08-15 surgical-deploys (Task 6) — the UNS-reference subset overload materialises only the + /// references it is handed (with their Organizes edges), leaving every other reference in the composition + /// untouched. + [Fact] + public void MaterialiseUnsReferences_with_a_subset_ensures_only_the_subset() + { + var sink = new RealmRecordingSink(); + var untouched = new UnsReferenceVariable("r0", "EQ-1", "filling/line1/station1/speed", "speed", + "Plant/A/dev/speed", "Float", Writable: true); + var added = new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/temp", "temp", + "Plant/A/dev/temp", "Float", Writable: false); + var composition = new AddressSpaceComposition( + Array.Empty(), Array.Empty(), Array.Empty()) + { + UnsReferenceVariables = new[] { untouched, added }, + }; + + NewApplier(sink).MaterialiseUnsReferences(composition, only: new[] { added }); + + sink.Variables.ShouldHaveSingleItem().NodeId.ShouldBe("filling/line1/station1/temp"); + sink.References.ShouldHaveSingleItem().Target.ShouldBe("Plant/A/dev/temp"); + } + /// A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges. private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 227b683b..0adad84c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -607,10 +607,291 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase return id; } + // --------------------------------------------------------------------------------------------------- + // 2026-08-15 surgical-deploys (Task 6) — a NON-rebuild apply materialises the plan's DELTA, not the whole + // composition. The passes are idempotent, but idempotent is not free: every EnsureVariable / EnsureFolder + // is a sink call that takes the node manager's lock, so re-walking the composition made the cost of ANY + // apply proportional to the size of the served address space (a one-tag add on a 50k-node server was + // ~50k sink calls on the publish thread) instead of to the size of the change. + // --------------------------------------------------------------------------------------------------- + + /// THE headline test. Deploy three raw tags, then deploy the same three PLUS one more: the + /// second apply must touch ONLY the added tag. Everything already standing — the three container folders + /// and the three existing variables — must not be re-ensured. + [Fact] + public void Pure_add_materialises_only_the_added_nodes() + { + var db = NewInMemoryDbFactory(); + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var dep1 = SeedRawTagDeployment(db, new[] { "A", "B", "C" }); + + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/C"), duration: PresenceBudget); + var afterFirst = sink.Calls.Count; + + // One new tag on an otherwise identical configuration. + var dep2 = SeedRawTagDeployment(db, new[] { "A", "B", "C", "D" }); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/D"), duration: PresenceBudget); + Thread.Sleep(200); // let any (unwanted) extra pass finish before counting + + var second = sink.Calls.Skip(afterFirst).ToList(); + sink.RebuildCalls.ShouldBe(0); + // Exactly one variable ensured — the added tag. The three that were already standing are untouched. + second.Where(c => c.StartsWith("EV:")).ShouldBe(new[] { "EV:Plant/Modbus/dev1/D" }); + // …and no container folder was re-ensured either (the raw containers did not change). + second.ShouldNotContain(c => c.StartsWith("EF:")); + } + + /// The falsification control for the headline test: when the plan DOES force a full rebuild, the + /// address space has just been wiped, so every node in the composition must be re-materialised — the delta + /// subsets would leave the server serving almost nothing. + [Fact] + public void Rebuild_still_materialises_the_full_composition() + { + var db = NewInMemoryDbFactory(); + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); + + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); + var afterFirst = sink.Calls.Count; + + // Renaming the equipment is a ChangedEquipment delta ⇒ the classifier's default-closed Rebuild. + var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); + AwaitAssert( + () => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/b"), + duration: PresenceBudget); + + var second = sink.Calls.Skip(afterFirst).ToList(); + // The FULL raw subtree came back: both containers and both tags, not just a delta. + second.ShouldContain("EF:Plant"); + second.ShouldContain("EF:Plant/Modbus/dev1"); + second.ShouldContain("EV:Plant/Modbus/dev1/a"); + second.ShouldContain("EV:Plant/Modbus/dev1/b"); + // …and so did the UNS reference variable (whose own row never changed). + second.ShouldContain("EV:filling/line1/station1-renamed/a"); + } + + /// + /// The delta subset MUST carry the CURRENT side of every changed UNS reference, not just the added + /// ones. AddressSpaceApplier.Apply repairs a UnsReferenceDelta by REMOVING the previous + /// node in place and leaving the recreate to the materialise pass; a subset built from + /// AddedUnsReferenceVariables alone would remove a re-pointed reference and never bring it back. + /// Here the reference is re-pointed from raw tag a to raw tag b (which also moves the + /// effective name, and so the UNS NodeId). + /// + [Fact] + public void Changed_uns_reference_is_recreated_after_surgical_remove() + { + var db = NewInMemoryDbFactory(); + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); + + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget); + var afterFirst = sink.Calls.Count; + + // Re-point the SAME reference row at raw tag "b". + var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "b"); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/b"), duration: PresenceBudget); + + var second = sink.Calls.Skip(afterFirst).ToList(); + sink.RebuildCalls.ShouldBe(0); // repaired in place, not by wiping the server + second.ShouldContain("RV:filling/line1/station1/a"); // the previous node was removed… + second.ShouldContain("EV:filling/line1/station1/b"); // …and the current one recreated + // …with its Organizes edge pointing at the NEW backing raw path. + sink.ReferenceEdges.ShouldContain(("filling/line1/station1/b", "Plant/Modbus/dev1/b")); + } + + /// + /// The same repair for the shape the 2026-08-15 review found (commit 875ec9fe): the reference row is + /// untouched, but its BACKING raw tag becomes historized. The coincident UnsReferenceDelta must + /// still reach the materialise pass, or the UNS mirror keeps serving no history while the raw node + /// serves it — the exact split the Backing* fields exist to close. + /// + [Fact] + public void Changed_uns_reference_is_recreated_when_the_backing_tag_flips_historian() + { + var db = NewInMemoryDbFactory(); + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); + + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget); + var afterFirst = sink.Calls.Count; + + var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a", + historianTagnameForA: "WW.A"); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + + AwaitAssert( + () => sink.VariableDetails.ShouldContain( + v => v.NodeId == "filling/line1/station1/a" && v.HistorianTagname == "WW.A"), + duration: PresenceBudget); + + var second = sink.Calls.Skip(afterFirst).ToList(); + sink.RebuildCalls.ShouldBe(0); + second.ShouldContain("RV:filling/line1/station1/a"); // removed… + second.ShouldContain("EV:filling/line1/station1/a"); // …and re-ensured, now carrying the tagname + } + + /// An AttributeOnly apply (here a raw tag gaining a historian override, with no UNS reference to + /// repair) is handled entirely by the surgical in-place update: no materialise pass has anything to do, so + /// not a single node is re-ensured. + [Fact] + public void Attribute_only_apply_skips_all_materialise_passes_except_hierarchy() + { + var db = NewInMemoryDbFactory(); + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var dep1 = SeedRawTagDeployment(db, new[] { "A", "B" }); + + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget); + var afterFirst = sink.Calls.Count; + + var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" }, historizedTag: "A"); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + AwaitAssert(() => sink.Calls.ShouldContain("UT:Plant/Modbus/dev1/A"), duration: PresenceBudget); + Thread.Sleep(200); // let any (unwanted) extra pass finish before counting + + var second = sink.Calls.Skip(afterFirst).ToList(); + sink.RebuildCalls.ShouldBe(0); + second.Where(c => c.StartsWith("EV:")).ShouldBeEmpty(); // nothing re-ensured + second.Where(c => c.StartsWith("EF:")).ShouldBeEmpty(); + second.ShouldContain("UT:Plant/Modbus/dev1/A"); // the in-place attribute update DID happen + } + + /// Seal a deployment whose raw tree is Plant/Modbus/dev1 carrying one tag per name in + /// (RawPath Plant/Modbus/dev1/<name>). There is no UNS topology, so + /// the composition is raw-only. names one tag that carries an + /// isHistorized TagConfig, which is an in-place-updatable (surgical) attribute change. + /// Deliberately NOT a params method, and deliberately not overloaded. The first draft + /// was (dbFactory, string? historizedTag = null, params string[] tagNames) alongside a convenience + /// (dbFactory, params string[] tagNames); Seed(db, "A", "B") then bound to the FIRST overload + /// in expanded form — historizing a tag called "A" and seeding only "B" — which compiled clean and made a + /// delta test silently assert against a one-tag composition. + private static Guid SeedRawTagDeployment( + IDbContextFactory dbFactory, + string[] tagNames, + string? historizedTag = null) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = Array.Empty(), + UnsLines = Array.Empty(), + Equipment = Array.Empty(), + RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = tagNames.Select(n => new + { + TagId = $"t-{n}", + DeviceId = "dev-1", + TagGroupId = (string?)null, + Name = n, + DataType = "Float", + AccessLevel = 0, + TagConfig = string.Equals(n, historizedTag, StringComparison.Ordinal) + ? "{\"isHistorized\":true}" + : "{}", + }).ToArray(), + UnsTagReferences = Array.Empty(), + ScriptedAlarms = Array.Empty(), + }); + + return SealArtifact(dbFactory, artifact); + } + + /// Seal a deployment carrying BOTH a raw tree (Plant/Modbus/dev1 with tags a + + /// b) and a UNS topology (filling / line1 / ) whose + /// single UnsTagReference row (id ref-1, stable across deploys) points at + /// . Re-pointing that row across two deploys yields a + /// UnsReferenceDelta; renaming the equipment yields a rebuild-forcing ChangedEquipment. + /// historizes raw tag a with that override, which makes the + /// reference's Backing* mirror fields differ (the 875ec9fe repair path). + private static Guid SeedUnsRawDeployment( + IDbContextFactory dbFactory, + string equipmentName, + string referencedTag, + string? historianTagnameForA = null) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" } }, + UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line1" } }, + Equipment = new[] { new { EquipmentId = "eq-1", UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" } }, + RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = new[] + { + new + { + TagId = "t-a", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "a", + DataType = "Float", AccessLevel = 0, + TagConfig = historianTagnameForA is null + ? "{}" + : $"{{\"isHistorized\":true,\"historianTagname\":\"{historianTagnameForA}\"}}", + }, + new + { + TagId = "t-b", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "b", + DataType = "Float", AccessLevel = 0, TagConfig = "{}", + }, + }, + UnsTagReferences = new[] + { + new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = $"t-{referencedTag}" }, + }, + ScriptedAlarms = Array.Empty(), + }); + + return SealArtifact(dbFactory, artifact); + } + + /// Seal as a new deployment and return its id. + private static Guid SealArtifact(IDbContextFactory dbFactory, byte[] artifact) + { + var id = Guid.NewGuid(); + using var ctx = dbFactory.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id, + RevisionHash = new string('e', 64), + Status = DeploymentStatus.Sealed, + CreatedBy = "test", + SealedAtUtc = DateTime.UtcNow, + ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + return id; + } + private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink { /// Gets the list of recorded sink calls. public ConcurrentQueue Calls { get; } = new(); + /// Gets every EnsureVariable call with the attributes the applier resolved for it (the + /// ordered queue only carries node ids). + public ConcurrentQueue<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> VariableDetails { get; } = new(); + /// Gets every AddReference (Organizes) edge the applier wired. + public ConcurrentQueue<(string Source, string Target)> ReferenceEdges { get; } = new(); /// Gets or sets the count of rebuild address space calls. public int RebuildCalls; /// Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001). @@ -653,14 +934,18 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => Calls.Enqueue($"EV:{variableNodeId}"); + { + Calls.Enqueue($"EV:{variableNodeId}"); + VariableDetails.Enqueue((variableNodeId, parentFolderNodeId, writable, historianTagname, realm)); + } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}"); public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } - public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") + => ReferenceEdges.Enqueue((sourceNodeId, targetNodeId)); /// Records a surgical in-place tag-attribute update (always succeeds in this recording sink). public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) {