From 00300d0f606717f1bbc67d7e13c75fd23043d709 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:41:09 -0400 Subject: [PATCH 01/13] fix(opcua): no write ever creates a node; cross-kind id guard (B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 L1/L4/H2. WriteValue/WriteValues are non-creating (an unknown id is skipped, counted and Debug-logged, as WriteStatuses always was), so a driver publish racing a surgical remove can no longer resurrect the id as a flat BaseDataType phantom under the shared root — nor hand a later re-add of the same tag the phantom's parent/DataType/writability through EnsureVariable's early return. WriteAlarmCondition for an unmaterialised id is a logged no-op instead of minting the legacy bool[2] placeholder (AddPredefinedNode is AddOrUpdate, so it replaced the SDK lookup entry of any folder sharing the id). The applier no longer writes a condition state against every removed equipment id (a folder, never a condition). EnsureFolder/EnsureVariable/MaterialiseAlarmCondition refuse an id already held by another node kind in the same realm. CreateVariable is deleted. Tests: NodeManagerPhantomNodeTests (publish racing a remove leaves no node; the re-add gets the authored shape and is live; unknown alarm id is a no-op; the guard in all three directions, realm-qualified); the sink / batch-write / write-statuses tests now declare their variables. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 10 +- .../OtOpcUaNodeManager.cs | 158 ++++++------ .../AddressSpaceApplierFailureSurfaceTests.cs | 30 ++- .../NodeManagerBatchWriteTests.cs | 16 +- .../NodeManagerPhantomNodeTests.cs | 231 ++++++++++++++++++ .../OtOpcUaNodeManagerWriteStatusesTests.cs | 9 +- .../SdkAddressSpaceSinkTests.cs | 41 +++- 7 files changed, 396 insertions(+), 99 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index bd7e8ba0..ce85a7ff 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -93,15 +93,13 @@ public sealed class AddressSpaceApplier } var ts = DateTime.UtcNow; - var removedCount = 0; + // A removed equipment is a FOLDER: it gets no terminal condition write (review 01 L4 — the write the + // applier used to make against the equipment id never found a condition and minted a placeholder + // variable that shadowed the folder's SDK lookup entry). Its subtree teardown is in ApplyPureRemove. + var removedCount = plan.RemovedEquipment.Count; // Swallowed removal-pass condition-write failures are tallied here (not lost to a per-node Warning) // so a degraded removal is operator-visible via AddressSpaceApplyOutcome.FailedNodes (archreview 01/S-1). var failedNodes = 0; - foreach (var eq in plan.RemovedEquipment) - { - if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; - removedCount++; - } foreach (var alarm in plan.RemovedAlarms) { if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index f8355b90..a49edfa9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -18,18 +18,19 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// /// Custom OPC UA that owns the writable address space for -/// the OtOpcUa server. Variable nodes are created lazily on first -/// under the manager's namespace; subsequent writes update the existing node's Value + -/// StatusCode + SourceTimestamp and notify subscribed clients via the standard -/// ClearChangeMasks path. +/// the OtOpcUa server. Variable nodes are declared by (the applier's +/// materialise passes); a updates an existing node's Value + StatusCode + +/// SourceTimestamp and notifies subscribed clients via the standard ClearChangeMasks path. +/// No write ever creates a node (review 01 L1): a publish naming an id a surgical remove has just +/// dropped is skipped, so it can neither resurrect the node as a flat phantom under the shared root nor +/// hand a later re-add of the same id the phantom's shape. /// /// This is the F10b production wiring behind the v2 /// seam — once a is bound, OpcUaPublishActor's writes /// materialise as real OPC UA Variable updates that clients can browse + subscribe to. /// /// Node-id encoding uses the manager's default namespace + the caller-supplied string id -/// as the identifier portion (e.g. "ns=2;s=eq-1/temp"). Beyond lazily-created flat -/// variables, the manager also materialises the full UNS Area/Line/Equipment folder hierarchy +/// as the identifier portion (e.g. "ns=2;s=eq-1/temp"). The manager materialises the full UNS Area/Line/Equipment folder hierarchy /// (), typed equipment-tag variables with the correct built-in /// DataType / array shape / access levels (), historized /// nodes (the HistoryRead access bit + HistoryRead overrides), and real Part 9 @@ -705,9 +706,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - /// Apply a value write from . Creates the - /// variable node on first call; subsequent calls update Value + StatusCode + - /// SourceTimestamp and call ClearChangeMasks so subscribed clients see the change. + /// Apply a value write from : update the materialised + /// variable's Value + StatusCode + SourceTimestamp and call ClearChangeMasks so subscribed clients + /// see the change. Never creates a node — an id nothing declared via + /// (a publish racing a surgical remove) is skipped and Debug-logged, exactly as + /// always did (review 01 L1). /// /// The node identifier of the variable. /// The new value to write. @@ -720,10 +723,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ArgumentException.ThrowIfNullOrEmpty(nodeId); EnsureAddressSpaceCreated(); - var ns = NamespaceIndexForRealm(realm); var key = MapKey(realm, nodeId); + bool applied; lock (Lock) - WriteValueLocked(nodeId, ns, key, value, statusCode, sourceTimestampUtc); + applied = WriteValueLocked(key, value, statusCode, sourceTimestampUtc); + + if (!applied) + { + _logger.LogDebug( + "OtOpcUaNodeManager: WriteValue skipped {NodeId} (realm {Realm}) — node not materialised.", + nodeId, realm); + } } /// The most items applies under a SINGLE Lock acquisition before @@ -741,7 +751,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Batched : one Lock acquisition per chunk of /// items instead of one per value — the publish path's per-value /// lock take was the steady-state throughput ceiling. Per-item semantics are identical to - /// (create-on-first-write verbatim status codes, ClearChangeMasks per node). + /// (never creates; verbatim status codes; ClearChangeMasks per node) — an item naming a + /// node that is not materialised is skipped and counted, not failed (the caller's per-driver ref index can + /// legitimately name a node a surgical remove has just dropped). /// Items are isolated. A bad item (empty NodeId, or a throw out of the SDK) does not abandon /// the rest of the batch: it is recorded and the loop continues, and the aggregate surfaces afterwards as /// a . Dropping the tail would be silent and durable — the poll @@ -785,6 +797,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } var succeeded = 0; + var skipped = 0; for (var start = 0; start < values.Count; start += MaxWritesPerLockAcquisition) { var end = Math.Min(start + MaxWritesPerLockAcquisition, values.Count); @@ -796,9 +809,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var v = values[i]; try { - WriteValueLocked(resolved[i].NodeId, resolved[i].NamespaceIndex, resolved[i].Key, - v.Value, v.StatusCode, v.SourceTimestampUtc); - succeeded++; + if (WriteValueLocked(resolved[i].Key, v.Value, v.StatusCode, v.SourceTimestampUtc)) succeeded++; + else skipped++; } catch (Exception ex) { @@ -810,6 +822,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + if (skipped > 0) + { + // Debug, not Warning: the caller's index racing a surgical remove is expected, and a driver + // whose nodes were all just removed would otherwise log once per ref. + _logger.LogDebug( + "OtOpcUaNodeManager: WriteValues skipped {Skipped} of {Total} item(s) — node not materialised.", + skipped, + values.Count); + } + if (failed > 0) { throw new ValueWriteBatchException( @@ -925,24 +947,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// False when the item failed the pre-pass and must be skipped. private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, RealmKey Key, bool IsValid); - /// The body of a single value write. Caller MUST hold Lock — this is factored out - /// solely so can apply a whole batch under one acquisition; it is byte-identical - /// to what has always done inside its own lock. - /// The bare s= identifier of the variable. - /// The namespace index resolved from the write's realm. - /// The realm-qualified map key for . + /// The body of a single value write. Caller MUST hold Lock — factored out so + /// can apply a whole batch under one acquisition. + /// Non-creating (review 01 L1). The former create-on-first-write branch minted a flat, read-only, + /// BaseDataType variable under the shared root whenever a publish raced a surgical remove; it stayed + /// until the next full rebuild, and because early-returns on an existing id, a + /// later re-add of the same tag silently kept the phantom's parent, DataType and writability. + /// The realm-qualified map key of the variable. /// The new value to write. /// The full OPC UA status code, sub-code included. /// The timestamp of the value in UTC. - private void WriteValueLocked(string nodeId, ushort ns, RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc) + /// when the node existed and was updated; when no + /// such node is materialised (skipped — deliberately not a create). + private bool WriteValueLocked(RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc) { - // CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode), - // so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too. - if (!_variables.TryGetValue(key, out var variable)) - { - variable = CreateVariable(nodeId, ns); - _variables[key] = variable; - } + if (!_variables.TryGetValue(key, out var variable)) return false; variable.Value = value; // The caller's status code reaches the SDK verbatim, sub-code included. This used to @@ -952,6 +971,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 variable.Timestamp = sourceTimestampUtc; // Stays INSIDE the lock, per node: this is how the SDK notifies monitored items of the change. variable.ClearChangeMasks(SystemContext, includeChildren: false); + return true; } /// @@ -959,10 +979,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// been materialised for (via ), /// this projects the whole snapshot /// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition - /// node and recomputes Retain (T15 — richer state; still no event firing, that lands in T16). - /// Otherwise it falls back to the legacy two-element [Active, Acknowledged] - /// placeholder so callers whose alarm node hasn't been - /// materialised (and the existing unit tests) keep working. + /// node, recomputes Retain and fires one condition event when the state actually moved. + /// An id with no materialised condition is a logged no-op (review 01 L4) — it used to mint a legacy + /// bool[2] placeholder variable whose AddPredefinedNode (an AddOrUpdate) REPLACED the SDK + /// lookup entry of any folder sharing the id, which the applier's per-removed-equipment write did on + /// every removal. /// /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. @@ -974,13 +995,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ArgumentNullException.ThrowIfNull(state); EnsureAddressSpaceCreated(); - var ns = NamespaceIndexForRealm(realm); var key = MapKey(realm, alarmNodeId); // Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear // _alarmConditions / detach the condition node between the lookup and the Set* calls. lock (Lock) { - if (_alarmConditions.TryGetValue(key, out var condition)) + if (!_alarmConditions.TryGetValue(key, out var condition)) + { + // Unknown / unmaterialised condition ⇒ safe no-op (a mid-rebuild race, or a caller naming a + // node that is not a condition), mirroring WriteAlarmQuality. + _logger.LogDebug( + "OtOpcUaNodeManager: WriteAlarmCondition skipped {NodeId} (realm {Realm}) — condition not materialised.", + alarmNodeId, realm); + return; + } + { // T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting // the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming @@ -1053,22 +1082,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { ReportConditionEvent(condition, sourceTimestampUtc); } - return; } - - // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so - // un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates - // the SDK address space, so it MUST run under Lock (see WriteValue). - if (!_variables.TryGetValue(key, out var variable)) - { - variable = CreateVariable(alarmNodeId, ns); - _variables[key] = variable; - } - - variable.Value = new[] { state.Active, state.Acknowledged }; - variable.StatusCode = StatusCodes.Good; - variable.Timestamp = sourceTimestampUtc; - variable.ClearChangeMasks(SystemContext, includeChildren: false); } } @@ -1364,6 +1378,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag. if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative) return; + // Review 01 H2 — a variable or folder already holding this id in this realm wins; never shadow it. + if (!_alarmConditions.ContainsKey(conditionKey) && IsHeldByAnotherKind(conditionKey, "alarm condition")) + 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. @@ -2886,6 +2903,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_folders.ContainsKey(key)) return; + if (IsHeldByAnotherKind(key, "folder")) return; var parent = ResolveParentFolder(parentNodeId, realm); var folder = new FolderState(parent) @@ -2977,6 +2995,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_variables.ContainsKey(key)) return; + if (IsHeldByAnotherKind(key, "variable")) return; var parent = ResolveParentFolder(parentFolderNodeId, realm); // Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead @@ -6961,28 +6980,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - private BaseDataVariableState CreateVariable(string nodeId, ushort ns) + /// + /// Review 01 H2 — the cross-map guard every node-minting entry point (, + /// , ) runs before creating a node: + /// true (and a Warning) when is already held by a node of ANOTHER kind. + /// The three maps are separate, so nothing else stops one (realm, id) being both a folder and a + /// variable — and the SDK's AddPredefinedNode is an AddOrUpdate, so the second mint silently + /// replaced the first's lookup entry. MUST be called under Lock. + /// + /// The realm-qualified id about to be minted. + /// The kind the caller is minting (for the log line). + /// when the id is taken by another kind and the caller must not mint. + private bool IsHeldByAnotherKind(RealmKey key, string requestedKind) { - var v = new BaseDataVariableState(_root) - { - NodeId = new NodeId(nodeId, ns), - BrowseName = new QualifiedName(nodeId, ns), - DisplayName = nodeId, - TypeDefinitionId = VariableTypeIds.BaseDataVariableType, - ReferenceTypeId = ReferenceTypeIds.Organizes, - DataType = DataTypeIds.BaseDataType, - // Lazy-created nodes (a WriteValue for a node never declared via EnsureVariable) carry no - // array intent, so they stay scalar with no ArrayDimensions — mirrors EnsureVariable's - // scalar branch. Array nodes are always pre-declared via EnsureVariable(isArray:true). - ValueRank = ValueRanks.Scalar, - ArrayDimensions = null, - AccessLevel = AccessLevels.CurrentRead, - UserAccessLevel = AccessLevels.CurrentRead, - Historizing = false, - }; - _root?.AddChild(v); - AddPredefinedNode(SystemContext, v); - return v; + if (ResolveNodeState(key) is not { } existing) return false; + _logger.LogWarning( + "OtOpcUaNodeManager: refusing to materialise {Kind} {NodeId} (realm {Realm}) — the id is already held by a {ExistingKind} node", + requestedKind, key.Id, key.Realm, existing.GetType().Name); + return true; } - } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index 08076fb1..a94b824b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -45,17 +45,32 @@ public sealed class AddressSpaceApplierFailureSurfaceTests // ---------------- T2: FailedNodes (removal-pass tally) ---------------- - /// A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today). + /// A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today). + /// The terminal condition write is made for a removed SCRIPTED ALARM — a removed equipment gets none (review + /// 01 L4: an equipment id is a folder, never a condition, so writing one only minted a phantom). [Fact] public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes() { var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var outcome = applier.Apply(AlarmRemovalPlan("alm-1")); + + outcome.RemovedNodes.ShouldBe(1); + outcome.FailedNodes.ShouldBe(1); + } + + /// Review 01 L4 — removing an equipment writes NO condition state against the equipment id. + [Fact] + public void Apply_RemovedEquipment_WritesNoConditionAgainstTheEquipmentId() + { + var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true }; + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var outcome = applier.Apply(EquipmentRemovalPlan("eq-1")); outcome.RemovedNodes.ShouldBe(1); - outcome.FailedNodes.ShouldBe(1); + outcome.FailedNodes.ShouldBe(0, "no condition write happens for an equipment folder, so a throwing alarm write cannot be reached"); } // ---------------- T3: Materialise* passes return failed-node counts ---------------- @@ -135,6 +150,17 @@ public sealed class AddressSpaceApplierFailureSurfaceTests RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); + private static AddressSpacePlan AlarmRemovalPlan(params string[] ids) => new( + AddedEquipment: Array.Empty(), + RemovedEquipment: Array.Empty(), + ChangedEquipment: Array.Empty(), + AddedDrivers: Array.Empty(), + RemovedDrivers: Array.Empty(), + ChangedDrivers: Array.Empty(), + AddedAlarms: Array.Empty(), + RemovedAlarms: ids.Select(id => new ScriptedAlarmPlan(id, "eq-1", "pred", "msg")).ToArray(), + ChangedAlarms: Array.Empty()); + private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new( AddedEquipment: Array.Empty(), RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs index bb0c8725..92233d32 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs @@ -12,8 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// per-value take was the steady-state throughput ceiling on the publish path; the chunk cap keeps the /// first poll cycle's force-raise-everything batch from stalling every SDK service thread). /// These tests pin the observable contract rather than the locking: every item lands with -/// its own Value / StatusCode / Timestamp, an item for an undeclared NodeId is lazily created exactly -/// as does, and an empty batch does nothing at all. +/// its own Value / StatusCode / Timestamp, the realm travels with every item (the same s= id in the +/// two namespaces is two nodes), and an empty batch does nothing at all. An item for an undeclared NodeId +/// is skipped, never created (review 01 L1 — ). /// A non-Good status is included so the #519 verbatim-sub-code passthrough is proven on the batched /// path too — a batch that re-projected status through a 3-state enum would collapse it to a bare /// Bad. @@ -90,16 +91,16 @@ public sealed class NodeManagerBatchWriteTests : IDisposable /// assertion is therefore two nodes with DISTINCT namespace indices, not merely "both reads returned /// something" (which a realm-blind map satisfies, since the shared key resolves for either lookup). [Fact] - public async Task WriteValues_creates_missing_variables() + public async Task WriteValues_is_realm_qualified() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; - const string SharedId = "Shared/lazy/id"; - nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns).ShouldBeNull(); - nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw).ShouldBeNull(); + const string SharedId = "Shared/both/id"; + nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw); var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc); nm.WriteValues(new List @@ -143,6 +144,8 @@ public sealed class NodeManagerBatchWriteTests : IDisposable { var nm = server.NodeManager!; var ts = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc); + nm.EnsureVariable("eq-iso/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-iso/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns); var ex = Should.Throw(() => nm.WriteValues(new List { @@ -187,6 +190,7 @@ public sealed class NodeManagerBatchWriteTests : IDisposable var items = new List(Count); for (var i = 0; i < Count; i++) { + nm.EnsureVariable($"eq-chunk/{i}", null, $"{i}", "Int32", writable: false, realm: AddressSpaceRealm.Uns); items.Add(new ValueWrite($"eq-chunk/{i}", i, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns)); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs new file mode 100644 index 00000000..821f0dab --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs @@ -0,0 +1,231 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 L1 / L4 / H2 — the node manager never materialises a node from a VALUE or ALARM write, and +/// never lets one (realm, id) be held by two different node kinds. +/// +/// The lazy create-on-first-write path predates and had +/// no production producer left — but it had a production consumer by accident: a driver publish +/// (or the UNS fan-out) racing a surgical remove re-created the removed id as a flat, read-only, +/// BaseDataType variable under the shared root, and because EnsureVariable early-returns +/// on an existing id, a later re-add of the same tag silently kept the phantom's shape. +/// +/// +public sealed class NodeManagerPhantomNodeTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-phantom-{Guid.NewGuid():N}"); + + /// A value write to an id nothing declared creates nothing. + [Fact] + public async Task WriteValue_for_an_undeclared_id_creates_no_node() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var before = nm.VariableCount; + + nm.WriteValue("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + nm.VariableCount.ShouldBe(before); + nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull(); + nm.GetManagerHandle(new NodeId("Plant/Modbus/dev1/Ghost", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))) + .ShouldBeNull("the SDK must not resolve a node that was never declared"); + } + finally { await host.DisposeAsync(); } + } + + /// + /// A batch naming a removed id skips it (no node, no exception) while the declared ids in the same batch + /// land — the positive control that proves the skip is a skip and not a dropped batch. + /// + [Fact] + public async Task WriteValues_skips_undeclared_ids_and_still_applies_the_declared_ones() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("Plant/Modbus/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + var ts = DateTime.UtcNow; + + nm.WriteValues( + [ + new ValueWrite("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw), + new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw), + ]); + + nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull(); + nm.TryGetVariable("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw)!.Value.ShouldBe(42); + } + finally { await host.DisposeAsync(); } + } + + /// + /// The L1 scenario end to end: a publish racing a surgical remove leaves NO node, and the re-add that + /// follows gets the AUTHORED DataType, parent and writability rather than inheriting a phantom's. + /// + [Fact] + public async Task A_publish_racing_a_surgical_remove_leaves_no_node_and_the_readd_gets_the_authored_shape() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + const string id = "Plant/Modbus/dev1/Speed"; + nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw); + nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Float", writable: false, realm: AddressSpaceRealm.Raw); + nm.RemoveVariableNode(id, AddressSpaceRealm.Raw).ShouldBeTrue(); + + // The race: the driver's fan-out publishes the id the surgical remove just dropped. + nm.WriteValue(id, 1.5f, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + nm.TryGetVariable(id, AddressSpaceRealm.Raw).ShouldBeNull("the racing publish must not resurrect the node"); + + // The re-add (a later deploy re-authors the tag with a different shape). + nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Int32", writable: true, realm: AddressSpaceRealm.Raw); + + var v = nm.TryGetVariable(id, AddressSpaceRealm.Raw); + v.ShouldNotBeNull(); + v!.DataType.ShouldBe(DataTypeIds.Int32); + v.Parent.ShouldBeSameAs(nm.TryGetFolder("Plant/Modbus/dev1", AddressSpaceRealm.Raw)); + ((int)v.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe(0, "the re-added node is writable as authored"); + v.OnWriteValue.ShouldNotBeNull("a writable node carries the inbound-write handler"); + + // Positive control: the re-added node is live — a publish lands on it. + nm.WriteValue(id, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + v.Value.ShouldBe(7); + } + finally { await host.DisposeAsync(); } + } + + /// A condition write for an id that was never materialised is a logged no-op (L4): no placeholder variable. + [Fact] + public async Task WriteAlarmCondition_for_an_unknown_id_creates_nothing() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); + var before = nm.VariableCount; + + var snapshot = new AlarmConditionSnapshot( + Active: false, Acknowledged: true, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: 0, Message: string.Empty); + // The applier used to write this against the EQUIPMENT id on every removal — a folder, never a condition. + nm.WriteAlarmCondition("eq-1", snapshot, DateTime.UtcNow, AddressSpaceRealm.Uns); + + nm.VariableCount.ShouldBe(before); + nm.TryGetVariable("eq-1").ShouldBeNull(); + // The folder's SDK lookup entry survives (the phantom used to REPLACE it — AddPredefinedNode is AddOrUpdate). + var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))); + handle.ShouldNotBeNull(); + handle!.Node.ShouldBeOfType(); + } + finally { await host.DisposeAsync(); } + } + + /// H2 — a variable may not take an id a folder already holds in the same realm. + [Fact] + public async Task EnsureVariable_refuses_an_id_held_by_a_folder() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); + + nm.EnsureVariable("eq-1", null, "eq-1", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + + nm.TryGetVariable("eq-1").ShouldBeNull(); + nm.TryGetFolder("eq-1").ShouldNotBeNull(); + var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))); + handle!.Node.ShouldBeSameAs(nm.TryGetFolder("eq-1")); + } + finally { await host.DisposeAsync(); } + } + + /// H2 — a folder may not take an id a variable already holds in the same realm. + [Fact] + public async Task EnsureFolder_refuses_an_id_held_by_a_variable() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("Plant/X", null, "X", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + + nm.EnsureFolder("Plant/X", null, "X", AddressSpaceRealm.Raw); + + nm.TryGetFolder("Plant/X", AddressSpaceRealm.Raw).ShouldBeNull(); + var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("Plant/X", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))); + handle!.Node.ShouldBeSameAs(nm.TryGetVariable("Plant/X", AddressSpaceRealm.Raw)); + } + finally { await host.DisposeAsync(); } + } + + /// H2 — a condition may not take an id a variable already holds in the same realm. + [Fact] + public async Task MaterialiseAlarmCondition_refuses_an_id_held_by_a_variable() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw); + nm.EnsureVariable("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw); + + nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true); + + nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldBeNull(); + nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull(); + } + finally { await host.DisposeAsync(); } + } + + /// The same bare id in the OTHER realm is a different node and is still allowed (the guard is realm-qualified). + [Fact] + public async Task The_guard_is_realm_qualified() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("shared", null, "shared", AddressSpaceRealm.Uns); + + nm.EnsureVariable("shared", null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + + nm.TryGetVariable("shared", AddressSpaceRealm.Raw).ShouldNotBeNull(); + nm.TryGetFolder("shared", AddressSpaceRealm.Uns).ShouldNotBeNull(); + } + finally { await host.DisposeAsync(); } + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.PhantomTest", + ApplicationUri = $"urn:OtOpcUa.PhantomTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs index 6de7a0c2..8cf7211e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs @@ -19,8 +19,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Never creates. A status carries nothing to create a node from, and the caller's /// per-driver index can legitimately name a node a surgical remove has just dropped. A missing id -/// is skipped, not materialised — the opposite of , -/// which lazily creates. +/// is skipped, not materialised — the same contract +/// carries since review 01 L1 (it used to lazily create). /// /// Boots a real , like /// , so the assertions read the SDK node state a client @@ -81,6 +81,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); const string sharedId = "Shared/both/realms"; + nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns); nm.WriteValue(sharedId, 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); nm.WriteValue(sharedId, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns); @@ -110,6 +112,7 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable { var nm = server.NodeManager!; var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); + nm.EnsureVariable("Plant/known", null, "known", "Int32", writable: false, realm: AddressSpaceRealm.Raw); nm.WriteValue("Plant/known", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); var before = nm.VariableCount; @@ -143,6 +146,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable { var nm = server.NodeManager!; var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); + nm.EnsureVariable("Plant/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + nm.EnsureVariable("Plant/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Raw); nm.WriteValue("Plant/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); nm.WriteValue("Plant/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 932e1f95..0611c42f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -20,18 +20,25 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable Path.GetTempPath(), $"otopcua-sink-{Guid.NewGuid():N}"); - /// Verifies that WriteValue creates and updates variables in the OPC UA node manager. + /// Verifies that WriteValue updates declared variables in the OPC UA node manager (and never + /// creates one — review 01 L1, pinned by ). [Fact] - public async Task WriteValue_creates_and_updates_variable_in_node_manager() + public async Task WriteValue_updates_declared_variables_in_node_manager() { var (host, server) = await BootAsync(); - var sink = new SdkAddressSpaceSink(server.NodeManager!); + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns); + sink.EnsureVariable("eq-1/pressure", null, "pressure", "Int32", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); sink.WriteValue("eq-1/temp", 23.1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); sink.WriteValue("eq-1/pressure", 100, OpcUaStatus.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue("eq-1/undeclared", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - server.NodeManager!.VariableCount.ShouldBe(2); + nm.VariableCount.ShouldBe(2); + nm.TryGetVariable("eq-1/temp")!.Value.ShouldBe(23.1); + nm.TryGetVariable("eq-1/pressure")!.StatusCode.ShouldBe((StatusCode)StatusCodes.Uncertain); await host.DisposeAsync(); } @@ -50,6 +57,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // (a concrete-typed call would simply stop compiling, which is not the regression being pinned). IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm); var ts = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc); + sink.EnsureVariable("Plant/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + sink.EnsureVariable("eq-1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteValues(new List { @@ -91,6 +100,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm); var ts = new DateTime(2026, 8, 15, 9, 30, 0, DateTimeKind.Utc); + sink.EnsureVariable("eq-fwd/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + sink.EnsureVariable("eq-fwd/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns); var ex = Should.Throw(() => sink.WriteValues(new List { @@ -113,18 +124,22 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable } } - /// Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node - /// distinct from value writes. + /// A WriteAlarmCondition for an id no condition was materialised under is a no-op (review 01 L4): + /// it creates no placeholder node, and the value write beside it is unaffected. [Fact] - public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes() + public async Task WriteAlarmCondition_for_an_unmaterialised_id_creates_no_node() { var (host, server) = await BootAsync(); - var sink = new SdkAddressSpaceSink(server.NodeManager!); + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns); sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - server.NodeManager!.VariableCount.ShouldBe(2); + nm.VariableCount.ShouldBe(1); + nm.TryGetVariable("alarm-7").ShouldBeNull(); + nm.TryGetAlarmCondition("alarm-7").ShouldBeNull(); await host.DisposeAsync(); } @@ -242,17 +257,21 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var (host, server) = await BootAsync(); var sink = new SdkAddressSpaceSink(server.NodeManager!); + sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + sink.EnsureVariable("b", null, "b", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + sink.EnsureVariable("c", null, "c", "Int32", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); sink.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns); server.NodeManager!.VariableCount.ShouldBe(3); sink.RebuildAddressSpace(); server.NodeManager.VariableCount.ShouldBe(0); - // After rebuild, subsequent writes start fresh. + // After rebuild, subsequent materialisation starts fresh. + sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteValue("a", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); server.NodeManager.VariableCount.ShouldBe(1); + server.NodeManager.TryGetVariable("a")!.Value.ShouldBe(99); await host.DisposeAsync(); } From eaac65286df1bdf57ae28de358c09e4f7900d1fc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:41:09 -0400 Subject: [PATCH 02/13] fix(opcua): tombstone MonitoredNode2 on every node removal (B2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 L2. The SDK keys its fan-out lists by NodeId and its component cache hands back the CACHED NodeState for a NodeId it already holds, so a rebuild or a surgical remove+re-add left a NEW item on the same NodeId — from any session — bound to the DEAD node: created Good, fed its stale initial value, never again, until every session had deleted its items. Every removal path (RebuildAddressSpace, RemoveVariableNode, RemoveAlarmConditionNode incl. the condition's instance-scoped children, RemoveEquipmentSubtree) now queues BadNodeIdUnknown to the node's data items, drops the MonitoredNodes entry, releases each item's component cache reference and forgets the _liveItems/_eventItems entries. Items the ACL pass stood down (on no fan-out list) are swept by NodeId. A later client delete of an orphaned item is completed by the DeleteMonitoredItem override / the SubscribeToEvents unsubscribe arm instead of answering BadMonitoredItemIdInvalid or NRE-ing in MonitoredNode2.Remove. The revalidation restore re-resolves the node by NodeId and no longer re-attaches to Handle.Node blindly. Timestamps now come from the injected Clock (UtcNow). Tests: StaleMonitoredNodeWireTests (real SDK, three legs: rebuild, surgical remove+re-add, second session; each keeps the old item alive, proves the new item receives a value written after its creation, the old one gets BadNodeIdUnknown, and its delete succeeds — all three fail with the tombstone neutered), NodeManagerStaleMonitoredNodeTests (stood-down item swept on surgical remove and rebuild; sibling removal leaves it restorable). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 250 ++++++++++++++++- .../StaleMonitoredNodeWireTests.cs | 252 ++++++++++++++++++ .../NodeManagerStaleMonitoredNodeTests.cs | 197 ++++++++++++++ 3 files changed, 694 insertions(+), 5 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index a49edfa9..774fc3f6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -290,6 +290,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private volatile TimeProvider _clock = TimeProvider.System; + /// The manager's "now" (UTC) — read off so a test clock drives every timestamp this + /// manager stamps (condition / shelve / revert / revalidation times), not only the HistoryUpdate bound. + private DateTime UtcNow => _clock.GetUtcNow().UtcDateTime; + private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance; /// @@ -3491,6 +3495,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { lock (Lock) { + // Review 01 L2 — every monitored item on a node this rebuild discards is told and forgotten BEFORE + // the nodes go, so a later create on the same NodeId binds to the re-created node (see + // TombstoneMonitoredNode). The shared root survives the rebuild and keeps its items. + TombstoneAllMonitoredNodes(); + foreach (var v in _variables.Values) { v.Parent?.RemoveChild(v); @@ -3602,6 +3611,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. if (!_variables.TryRemove(key, out var variable)) return false; + TombstoneMonitoredNode(variable.NodeId, UtcNow); // 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds // BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a // condition removal. AddReference wires the edge bidirectionally: forward on the UNS reference @@ -3668,6 +3678,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole // equipment goes). if (!_alarmConditions.TryRemove(key, out var condition)) return false; + TombstoneMonitoredSubtree(condition, UtcNow); // unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE // the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without // this they would keep a dangling inverse-notifier entry to the removed condition. @@ -3704,11 +3715,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 bool InScope(RealmKey key) => key.Realm == realm && (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal)); + var now = UtcNow; // Value variables (+ their historized-tagname registrations). foreach (var id in _variables.Keys.Where(InScope).ToList()) { if (_variables.TryRemove(id, out var v)) { + TombstoneMonitoredNode(v.NodeId, now); // Sever the inverse half of each cross-tree Organizes edge // first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a // RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge. @@ -3725,6 +3738,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (_alarmConditions.TryRemove(id, out var c)) { + TombstoneMonitoredSubtree(c, now); c.Parent?.RemoveChild(c); ForgetPredefinedSubtree(c); } @@ -3765,6 +3779,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (_folders.TryRemove(id, out var f)) { + TombstoneMonitoredNode(f.NodeId, now); f.Parent?.RemoveChild(f); PredefinedNodes?.Remove(f.NodeId); } @@ -4080,8 +4095,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (unsubscribe) { - // An item RevalidateMonitoredItems stood down is ALREADY off its notifier's fan-out. - // Handing its delete to the SDK would make it remove an item that is not there — at best a + // An item RevalidateMonitoredItems stood down is ALREADY off its notifier's fan-out, and so is + // one whose notifier a rebuild / surgical remove tombstoned (review 01 L2, TombstoneMonitoredNode). + // Handing either delete to the SDK would make it remove an item that is not there — at best a // spurious BadNodeIdUnknown on the client's delete, at worst a NullReferenceException inside // MonitoredNode2.Remove when the node's event list has gone null. Checked and dropped under the // same Lock the revalidation pass re-attaches under, so the two cannot interleave into @@ -4089,7 +4105,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_eventItems.TryRemove(monitoredItem, out var stoodDown) && stoodDown.Suppressed) + { + Interlocked.Decrement(ref _suppressedItems); return ServiceResult.Good; + } + + if (IsHandleInNamespace(sourceId) is { } h + && (!MonitoredNodes.TryGetValue(h.NodeId, out var current) + || current.EventMonitoredItems?.Contains(monitoredItem) != true)) + { + return ServiceResult.Good; + } } return base.SubscribeToEvents(context, sourceId, subscriptionId, monitoredItem, unsubscribe); @@ -4175,10 +4201,186 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 protected override void OnMonitoredItemDeleted( ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem) { - _liveItems.TryRemove(monitoredItem, out _); + if (_liveItems.TryRemove(monitoredItem, out var tracked) && tracked.Suppressed) Interlocked.Decrement(ref _suppressedItems); base.OnMonitoredItemDeleted(context, handle, monitoredItem); } + // Review 01 L2 — a client deleting an item whose node this manager removed from under it (a rebuild or + // a surgical remove tombstoned the node's MonitoredNode2, see TombstoneMonitoredNode) must succeed. The + // SDK's own delete looks the node up by NodeId: with the entry gone it answers BadMonitoredItemIdInvalid, + // and with a FRESH entry (the same NodeId re-created and re-subscribed) it would try to remove the item + // from a fan-out list it is not on — a NullReferenceException when that list has gone null. An item that + // is not on the current fan-out for its NodeId has nothing left to detach, so its delete is completed here. + /// + protected override ServiceResult DeleteMonitoredItem( + ServerSystemContext context, IMonitoredItem monitoredItem, NodeHandle handle) + { + lock (Lock) + { + if (monitoredItem is ISampledDataChangeMonitoredItem dataItem + && (!MonitoredNodes.TryGetValue(handle.NodeId, out var current) + || current.DataChangeMonitoredItems?.Contains(dataItem) != true)) + { + OnMonitoredItemDeleted(context, handle, dataItem); + return ServiceResult.Good; + } + } + + return base.DeleteMonitoredItem(context, monitoredItem, handle); + } + + /// How many items the revalidation pass currently holds detached (stood down). Lets the + /// tombstone sweep for stood-down items — which are not on any fan-out list — run only when there are any. + private int _suppressedItems; + + /// + /// Review 01 L2 — forget the of a node this manager is removing, telling + /// every data-change item on it BadNodeIdUnknown first. MUST be called under Lock, BEFORE + /// the node is detached. + /// + /// + /// + /// The SDK keys its fan-out lists by NodeId and never clears them on the node manager's behalf + /// (DeleteMonitoredItem drops an entry only when its item count reaches zero), and its + /// component cache (AddNodeToComponentCache) hands back the CACHED + /// for a NodeId it already holds. So after a rebuild or a surgical remove+re-add, a NEW item on the + /// same NodeId — from any session — was bound to the DEAD node: created Good, never fed. Only + /// once every session had deleted its items did a create bind to the live node. + /// + /// + /// The fix removes the entry and releases each item's component-cache reference (the refcount the + /// SDK took at create), so the next create binds to the re-created node. The items themselves stay in + /// their client's subscription — a node manager cannot remove them — told BadNodeIdUnknown + /// (data items; an event item has no value slot) and dropped from the revalidation maps; their later + /// client delete is completed by / the SubscribeToEvents + /// unsubscribe arm. Queueing under Lock follows the publish path's own order (Lock, then + /// the item lock) — the inversion the revalidation pass avoids is the other way round. + /// + /// + /// An item the revalidation pass has STOOD DOWN is on no fan-out list, so it is swept from the + /// tracking map by NodeId instead — it still holds a component-cache reference that would otherwise + /// keep the dead node cached. + /// + /// + /// The NodeId being removed. + /// The timestamp the terminal BadNodeIdUnknown carries. + private void TombstoneMonitoredNode(NodeId nodeId, DateTime now) + { + if (MonitoredNodes.TryGetValue(nodeId, out var monitored)) + { + MonitoredNodes.Remove(nodeId); + if (monitored.DataChangeMonitoredItems is { Count: > 0 } dataItems) + { + foreach (var item in dataItems.ToArray()) + { + TellNodeRemoved(item, now); + if (item is ISampledDataChangeMonitoredItem sampled) _liveItems.TryRemove(sampled, out _); + ReleaseComponentCacheReference(item); + } + } + + if (monitored.EventMonitoredItems is { Count: > 0 } eventItems) + { + foreach (var item in eventItems.ToArray()) + { + _eventItems.TryRemove(item, out _); + ReleaseComponentCacheReference(item); + } + } + } + + if (Volatile.Read(ref _suppressedItems) > 0) SweepStoodDownItems(nodeId, now); + } + + /// Tombstones and every descendant (a condition's instance-scoped + /// children are monitorable in their own right). MUST be called under Lock. + private void TombstoneMonitoredSubtree(NodeState node, DateTime now) + { + TombstoneMonitoredNode(node.NodeId, now); + var children = new List(); + node.GetChildren(SystemContext, children); + foreach (var child in children) TombstoneMonitoredSubtree(child, now); + } + + /// The rebuild form: every monitored node this manager serves except the shared root, which the + /// rebuild keeps. MUST be called under Lock. + private void TombstoneAllMonitoredNodes() + { + var now = UtcNow; + var rootId = _root?.NodeId; + foreach (var nodeId in MonitoredNodes.Keys.ToList()) + { + if (rootId is not null && nodeId == rootId) continue; + TombstoneMonitoredNode(nodeId, now); + } + + if (Volatile.Read(ref _suppressedItems) > 0) SweepStoodDownItems(nodeId: null, now); + } + + /// Drops the stood-down items of (every stood-down item when null — + /// the rebuild case) from the revalidation maps, telling the data items BadNodeIdUnknown. + private void SweepStoodDownItems(NodeId? nodeId, DateTime now) + { + foreach (var (item, tracked) in _liveItems) + { + if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; + if (!_liveItems.TryRemove(item, out _)) continue; + Interlocked.Decrement(ref _suppressedItems); + TellNodeRemoved(item, now); + ReleaseComponentCacheReference(item); + } + + foreach (var (item, tracked) in _eventItems) + { + if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; + if (!_eventItems.TryRemove(item, out _)) continue; + Interlocked.Decrement(ref _suppressedItems); + ReleaseComponentCacheReference(item); + } + } + + /// Queues the terminal BadNodeIdUnknown on a data-change item whose node is going away. + private void TellNodeRemoved(IDataChangeMonitoredItem2 item, DateTime now) + { + try + { + item.QueueValue( + new DataValue(StatusCodes.BadNodeIdUnknown) { SourceTimestamp = now, ServerTimestamp = now }, + new ServiceResult(StatusCodes.BadNodeIdUnknown), + ignoreFilters: true); + } + catch (Exception ex) + { + // A disabled item refuses the value; nothing else here is worth failing a removal for. + _logger.LogDebug(ex, "OtOpcUaNodeManager: could not queue the removal status to a monitored item"); + } + } + + /// Gives back the component-cache reference the SDK took for at create, so + /// the cache stops handing out the removed node for that NodeId. + private void ReleaseComponentCacheReference(IMonitoredItem item) + { + if (item.ManagerHandle is NodeHandle handle) RemoveNodeFromComponentCache(SystemContext, handle); + } + + /// Test observability: how many NodeIds currently carry a fan-out. + internal int MonitoredNodeCount + { + get { lock (Lock) return MonitoredNodes.Count; } + } + + /// Test observability: whether currently carries a fan-out. + internal bool IsNodeMonitored(NodeId nodeId) + { + lock (Lock) return MonitoredNodes.ContainsKey(nodeId); + } + + /// Test observability: how many data-change items the revalidation map tracks. + internal int LiveItemCount => _liveItems.Count; + + /// Test observability: how many direct event items the revalidation map tracks. + internal int LiveEventItemCount => _eventItems.Count; + /// /// Live data-change monitored items on nodes this manager owns, so a grant revoked by a deployment /// can reach the subscriptions it was authorizing. @@ -4817,7 +5019,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!tracked.Suppressed) continue; - lock (Lock) tracked.Handle.MonitoredNode?.Add(item); + // Re-resolve the node by NodeId rather than trusting the handle (review 01 L2): the fan-out + // the item came off belongs to the NodeState that existed when it was stood down, and a + // rebuild or surgical remove in between has tombstoned that node — then the item was already + // dropped from this map, so a mismatch here is a race with one; tell the client and let go. + bool reattached; + lock (Lock) + { + reattached = ResolveNodeState(MapKey(nodeId)) is { } liveNode + && ReferenceEquals(liveNode, tracked.Handle.Node); + if (reattached) tracked.Handle.MonitoredNode?.Add(item); + else _liveItems.TryRemove(item, out _); + } + + Interlocked.Decrement(ref _suppressedItems); + if (!reattached) + { + TellNodeRemoved(item, now); + continue; + } + tracked.Suppressed = false; restored++; continue; @@ -4832,8 +5053,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 new ServiceResult(StatusCodes.BadUserAccessDenied), ignoreFilters: true); - lock (Lock) tracked.Handle.MonitoredNode?.Remove(item); + lock (Lock) + { + // The fan-out list may have gone null if every other item on the node was deleted meanwhile. + if (tracked.Handle.MonitoredNode is { DataChangeMonitoredItems: not null } fanOut) fanOut.Remove(item); + } tracked.Suppressed = true; + Interlocked.Increment(ref _suppressedItems); stoodDown++; } @@ -4905,8 +5131,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!_eventItems.TryGetValue(item, out var current) || !ReferenceEquals(current, tracked)) continue; + // Re-resolve the notifier by NodeId (review 01 L2): re-attaching to the handle's NodeState + // after a rebuild would bind the item to a dead node. A notifier that is gone, or has been + // re-created, cannot be the one this item was stood down from — drop the item instead. + if (permitted + && (ResolveNodeState(MapKey(tracked.Handle.NodeId)) is not { } live || !ReferenceEquals(live, tracked.Handle.Node))) + { + _eventItems.TryRemove(item, out _); + Interlocked.Decrement(ref _suppressedItems); + ReleaseComponentCacheReference(item); + continue; + } + SubscribeToEvents(SystemContext, tracked.Handle.Node, item, unsubscribe: !permitted); tracked.Suppressed = !permitted; + if (permitted) Interlocked.Decrement(ref _suppressedItems); + else Interlocked.Increment(ref _suppressedItems); } if (permitted) restored++; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs new file mode 100644 index 00000000..6cacc60e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs @@ -0,0 +1,252 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Review 01 L2, over the wire against the real SDK: a rebuild (or a surgical remove + re-add) that +/// recreates a NodeId must not leave the SDK's per-NodeId MonitoredNode2 — and its component-cache +/// entry — pointing at the DEAD NodeState. Before the fix, ANY new item on that NodeId, from any +/// session, bound to the dead node while an older item on it still existed: created Good, fed its +/// stale initial value, then never again. +/// +/// Each test keeps the OLD item alive while creating the NEW one — that is exactly the trap — and +/// proves the positive path (the new item receives a value written AFTER its creation) before asserting +/// the old item's BadNodeIdUnknown. Deleting the old item afterwards must succeed too. +/// +/// +public sealed class StaleMonitoredNodeWireTests +{ + private const string ServerUri = "urn:OtOpcUa.StaleMonitoredNode"; + private const string Device = "Plant/Modbus/dev1"; + private const string Tag = "Plant/Modbus/dev1/Speed"; + + /// Rebuild + re-create the same NodeId: the old item is told, a new item on the same NodeId receives values. + [Fact] + public async Task A_new_item_after_a_rebuild_binds_to_the_recreated_node() + { + await RunAsync("rebuild", nm => + { + nm.RebuildAddressSpace(); + nm.CompleteAddressSpaceApply(); + Materialise(nm); + }); + } + + /// Surgical remove + re-add of the same NodeId: same contract as the rebuild. + [Fact] + public async Task A_new_item_after_a_surgical_remove_and_readd_binds_to_the_recreated_node() + { + await RunAsync("surgical", nm => + { + nm.RemoveVariableNode(Tag, AddressSpaceRealm.Raw).ShouldBeTrue(); + nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + }); + } + + /// + /// The same trap from a SECOND session: a UaExpert operator connecting after a deploy while ScadaBridge's + /// original item is still alive. + /// + [Fact] + public async Task A_second_session_binds_to_the_recreated_node_while_the_first_sessions_item_lives() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-2s-{Guid.NewGuid():N}"); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host, port) = await BootServerAsync(pkiRoot, ServerUri + ".TwoSessions", ct); + await using var _ = host; + var nm = server.NodeManager!; + Materialise(nm); + nm.WriteValue(Tag, 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + using var sessionA = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var nodeId = new NodeId(Tag, (ushort)sessionA.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); + var (subA, oldItem, oldValues, gate) = await SubscribeAsync(sessionA, nodeId, ct); + await WaitUntilAsync(() => { lock (gate) return oldValues.Count >= 1; }, TimeSpan.FromSeconds(5)); + + nm.RebuildAddressSpace(); + nm.CompleteAddressSpaceApply(); + Materialise(nm); + nm.WriteValue(Tag, 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + // Session B arrives AFTER the rebuild, while A's item is still in A's subscription. + using var sessionB = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var (subB, newItem, newValues, gateB) = await SubscribeAsync(sessionB, nodeId, ct); + await WaitUntilAsync(() => { lock (gateB) return newValues.Count >= 1; }, TimeSpan.FromSeconds(5)); + + nm.WriteValue(Tag, 3, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + await WaitUntilAsync(() => { lock (gateB) return newValues.Any(v => Equals(v.Value, 3)); }, TimeSpan.FromSeconds(5), + "the second session's item must receive a value written after its creation"); + + lock (gate) oldValues.ShouldContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown, "the first session's item was told its node went away"); + lock (gateB) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); + + subA.RemoveItem(oldItem); + await subA.ApplyChangesAsync(ct); + subB.RemoveItem(newItem); + await subB.ApplyChangesAsync(ct); + } + finally + { + SafeDelete(pkiRoot); + } + } + + private static async Task RunAsync(string name, Action recreate) + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{name}-{Guid.NewGuid():N}"); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host, port) = await BootServerAsync(pkiRoot, $"{ServerUri}.{name}", ct); + await using var _ = host; + var nm = server.NodeManager!; + Materialise(nm); + nm.WriteValue(Tag, 41, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var nodeId = new NodeId(Tag, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); + var (subscription, oldItem, oldValues, gate) = await SubscribeAsync(session, nodeId, ct); + await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => Equals(v.Value, 41)); }, TimeSpan.FromSeconds(5)); + nm.IsNodeMonitored(nodeId).ShouldBeTrue("non-vacuity: the SDK really holds a fan-out for the node"); + + // The node goes away and comes back under the same NodeId; a fresh value lands on the NEW node. + recreate(nm); + nm.WriteValue(Tag, 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + // A NEW item on the same NodeId, in the same subscription, with the old one still present. + var newValues = new List(); + var newItem = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10, + }; + newItem.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) newValues.Add(v); }; + subscription.AddItem(newItem); + await subscription.ApplyChangesAsync(ct); + newItem.Status.Error.ShouldBeNull(); + + // Positive control: the initial value is the NEW node's, and a value written afterwards arrives. + await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5), + "the new item's initial read must come from the re-created node"); + nm.WriteValue(Tag, 43, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 43)); }, TimeSpan.FromSeconds(5), + "the new item must receive a value written after its creation"); + lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); + + // The old item was told, and the dead node's fan-out is gone. + await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); }, TimeSpan.FromSeconds(5), + "the item on the removed node must be told BadNodeIdUnknown"); + nm.IsNodeMonitored(nodeId).ShouldBeTrue("the NEW item's fan-out exists for the re-created node"); + + // The old item's delete completes — its node is gone, not its right to be deleted. + subscription.RemoveItem(oldItem); + await subscription.ApplyChangesAsync(ct); + (oldItem.Status.Error is null || ServiceResult.IsGood(oldItem.Status.Error)).ShouldBeTrue( + $"deleting the orphaned item must succeed, got {oldItem.Status.Error}"); + + // And the new item is unaffected by that delete. + nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5)); + } + finally + { + SafeDelete(pkiRoot); + } + } + + private static void Materialise(OtOpcUaNodeManager nm) + { + nm.EnsureFolder(Device, null, "dev1", AddressSpaceRealm.Raw); + nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + } + + private static async Task<(Subscription Subscription, MonitoredItem Item, List Values, object Gate)> SubscribeAsync( + ISession session, NodeId nodeId, CancellationToken ct) + { + var values = new List(); + var gate = new object(); + var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; + session.AddSubscription(subscription); + await subscription.CreateAsync(ct); + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10, + }; + item.Notification += (i, _) => { foreach (var v in i.DequeueValues()) lock (gate) values.Add(v); }; + subscription.AddItem(item); + await subscription.ApplyChangesAsync(ct); + return (subscription, item, values, gate); + } + + private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host, int Port)> BootServerAsync( + string pkiRoot, string appUri, CancellationToken ct) + { + var boundPort = 0; + var started = await OpcUaHostStart.StartAsync( + port => + { + boundPort = port; + return new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = appUri, + ApplicationUri = appUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = pkiRoot, + EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, + AutoAcceptUntrustedClientCertificates = true, + }, + NullLogger.Instance); + }, + () => new OtOpcUaSdkServer(), ct); + return (started.Server, started.Host, boundPort); + } + + private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.StaleMonitoredNodeClient", + ApplicationUri = $"urn:OtOpcUa.StaleMonitoredNodeClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, + sessionName: "StaleMonitoredNodeWireTests", sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout, string? because = null) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(50); + } + condition().ShouldBeTrue(because ?? "condition not met within timeout"); + } + + private static void SafeDelete(string dir) + { + if (!Directory.Exists(dir)) return; + try { Directory.Delete(dir, recursive: true); } + catch { /* best-effort */ } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs new file mode 100644 index 00000000..fcd966e5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs @@ -0,0 +1,197 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 L2, the half the wire tests (StaleMonitoredNodeWireTests) cannot reach: an item the +/// ACL revalidation pass has STOOD DOWN is on no SDK fan-out list, so the tombstone a removal runs must find +/// it through the revalidation map. Otherwise it keeps a component-cache reference to the dead node — and +/// the cache hands that node to the next item created on the NodeId. +/// +public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable +{ + private const string Cluster = "SITE-A"; + private const string Principal = "svc"; + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{Guid.NewGuid():N}"); + + /// A surgical remove tells a stood-down item and drops it from the revalidation map. + [Fact] + public async Task A_surgical_remove_tells_and_forgets_a_stood_down_item() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var (item, nodeId) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed"); + + nm.RemoveVariableNode("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw).ShouldBeTrue(); + + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown); + nm.LiveItemCount.ShouldBe(0, "the item's node is gone; the map must not keep re-authorizing it"); + nm.IsNodeMonitored(nodeId).ShouldBeFalse(); + } + finally { await host.DisposeAsync(); } + } + + /// A rebuild sweeps every stood-down item. + [Fact] + public async Task A_rebuild_tells_and_forgets_every_stood_down_item() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed"); + + nm.RebuildAddressSpace(); + nm.CompleteAddressSpaceApply(); + + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown); + nm.LiveItemCount.ShouldBe(0); + } + finally { await host.DisposeAsync(); } + } + + /// + /// Positive control for the sweep: a stood-down item on a SURVIVING node is left alone by a surgical + /// remove of a sibling, and the next permitted pass restores it. + /// + [Fact] + public async Task A_stood_down_item_on_a_surviving_node_is_untouched_and_still_restorable() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("Plant/Modbus/dev1/Other", null, "Other", "Float", writable: false, realm: AddressSpaceRealm.Raw); + var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed"); + + nm.RemoveVariableNode("Plant/Modbus/dev1/Other", AddressSpaceRealm.Raw).ShouldBeTrue(); + + item.Statuses.ShouldNotContain((StatusCode)StatusCodes.BadNodeIdUnknown); + nm.LiveItemCount.ShouldBe(1); + + // Grant restored ⇒ the pass re-attaches it (a second stand-down proves the restore happened). + nm.Authorizer = Authorizer(permits: true); + nm.RevalidateMonitoredItems(); + nm.Authorizer = Authorizer(permits: false); + nm.RevalidateMonitoredItems(); + item.Statuses.Count(s => s == StatusCodes.BadUserAccessDenied).ShouldBe(2); + } + finally { await host.DisposeAsync(); } + } + + /// Registers a stub item on and stands it down through a denying pass. + private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath) + { + nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw); + var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId; + var handle = (NodeHandle)nm.GetManagerHandle(nodeId); + var item = new StubItem(handle, Identity()); + nm.TrackLiveMonitoredItem(handle, item); + + nm.Authorizer = Authorizer(permits: false); + nm.RevalidateMonitoredItems(); + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: the item really was stood down"); + await Task.CompletedTask; + return (item, nodeId); + } + + private static DataPlaneAuthorizer Authorizer(bool permits) + { + var state = new DataPlaneAuthorityState(); + var grants = permits + ? new[] + { + new NodeAcl + { + NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal, + ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Subscribe, + }, + } + : []; + state.Apply(Cluster, "rev-1", [], grants, new Dictionary>()); + return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); + } + + private static RoleCarryingUserIdentity Identity() => + new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null); + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.StaleTest", + ApplicationUri = $"urn:OtOpcUa.StaleTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + /// The minimum of a sampled item the removal path touches: its handle, identity and value queue. + private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem + { + public List Statuses { get; } = []; + public IUserIdentity EffectiveIdentity => identity; + public object ManagerHandle => handle; + public NodeId NodeId => handle.NodeId; + public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode); + public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false); + public void Dispose() { } + + public INodeManager NodeManager => throw new NotSupportedException(); + public ISession Session => throw new NotSupportedException(); + public uint Id => throw new NotSupportedException(); + public uint SubscriptionId => throw new NotSupportedException(); + public bool IsDurable => throw new NotSupportedException(); + public uint ClientHandle => throw new NotSupportedException(); + public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public int MonitoredItemType => throw new NotSupportedException(); + public bool IsReadyToPublish => throw new NotSupportedException(); + public bool IsReadyToTrigger { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public bool IsResendData => throw new NotSupportedException(); + public MonitoringMode MonitoringMode => throw new NotSupportedException(); + public double SamplingInterval => throw new NotSupportedException(); + public DataChangeFilter DataChangeFilter => throw new NotSupportedException(); + public uint AttributeId => throw new NotSupportedException(); + public NumericRange IndexRange => throw new NotSupportedException(); + public QualifiedName DataEncoding => throw new NotSupportedException(); + public bool AlwaysReportUpdates { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public DiagnosticsMasks DiagnosticsMasks => throw new NotSupportedException(); + public uint QueueSize => throw new NotSupportedException(); + public double MinimumSamplingInterval => throw new NotSupportedException(); + public int TimeToNextSample => throw new NotSupportedException(); + public void SetupResendDataTrigger() => throw new NotSupportedException(); + public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException(); + public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException(); + public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException(); + public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException(); + public bool Publish(OperationContext context, Queue notifications, Queue diagnostics, uint maxNotificationsPerPublish, ILogger logger) => throw new NotSupportedException(); + public void SetSemanticsChanged() => throw new NotSupportedException(); + public void SetStructureChanged() => throw new NotSupportedException(); + public bool SamplingIntervalExpired() => throw new NotSupportedException(); + public ReadValueId GetReadValueId() => throw new NotSupportedException(); + public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException(); + public void SetSamplingInterval(double samplingInterval) => throw new NotSupportedException(); + } +} From dca822dc59b37ca2f784546d41ae7546108f5587 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:17:49 -0400 Subject: [PATCH 03/13] perf(opcua): de-quadratic the lock-time teardown paths (B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 P1/P2/P3/P5 — work done under the manager-wide SDK Lock on the pinned publish thread, where every Read/Browse/Publish waits. - P1 RebuildAddressSpace detaches the shared root's top-level children once instead of every leaf from its parent (NodeState.RemoveChild is a linear scan + RemoveAt, ~k²/2 compares per k-child folder); root notifiers are cleared as a list instead of one O(F) RemoveRootNotifier each; ForgetPredefinedSubtree / TombstoneMonitoredSubtree are iterative with one child buffer. - P2 RemoveEquipmentSubtree walks a parent→children index maintained by EnsureFolder/EnsureVariable/MaterialiseAlarmCondition (keyed by the parent id the caller named) instead of prefix-scanning six maps per removed equipment; a surviving condition's wiring into a removed folder is found through the folder's own notifier list; descendants are not detached from a folder that is itself being discarded. - P3 LowerAllEventsMonitoring(condition) replaces the full O(C) reconcile on RemoveAlarmConditionNode / RemoveEquipmentSubtree / the kind-swap. - P5 AlarmNotifierWiring.Folders is a reference-identity HashSet and an already-wired pair skips the SDK's linear AddNotifier dedup. Benchmark (NodeManagerLockTimeBenchmarkTests, M-series Mac under load, three runs each): rebuild of a 10k-tag folder 75–150 ms → 5–6 ms; removing 200 equipment (250 signals each) from a 50k-node space 471–681 ms → 58–64 ms; all-events bookkeeping visits for 10 condition removals over 50 conditions 900 → 10. The two timing legs assert < 1 s; the P3 leg asserts the visit count (machine-independent). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 331 +++++++++++------- .../NodeManagerLockTimeBenchmarkTests.cs | 190 ++++++++++ 2 files changed, 397 insertions(+), 124 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 774fc3f6..9a2fe15f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -111,6 +111,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// against it (a miss ⇒ BadHistoryOperationUnsupported). Cleared on /// . private readonly ConcurrentDictionary _eventNotifierSources = new(); + /// Review 01 P2 — parent key → the keys of the folders / variables / conditions materialised + /// under it (by the parent id the CALLER named, so a node whose parent was unknown at creation and hangs + /// under the shared root is still found under its intended parent). + /// walks this instead of prefix-scanning every map per removed equipment. Guarded by Lock. + private readonly Dictionary> _childIndex = new(); + /// The reverse of : child key → parent key. Guarded by Lock. + private readonly Dictionary _parentIndex = new(); private FolderState? _root; private readonly ILogger _logger; @@ -1400,8 +1407,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ForgetPredefinedSubtree(existing); // The materialise tail raises only the new instance's unit (no full reconcile), so give the // dropped instance's back here rather than leave it tracked until the next full reconcile. - if (_allEventsMonitoredConditions.Remove(existing)) - existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true); + LowerAllEventsMonitoring(existing); } // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new @@ -1579,6 +1585,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 AddPredefinedNode(SystemContext, alarm); _alarmConditions[conditionKey] = alarm; + if (!string.IsNullOrEmpty(equipmentNodeId)) IndexChild(MapKey(realm, equipmentNodeId), conditionKey); // Only the new condition can be missing its unit here, so raise just that one — a full reconcile // walks every condition, which made a deploy of N alarms O(N²) under Lock whenever any Server-object // item was live (always, in production). Removal, rebuild and (un)subscribe keep the full reconcile. @@ -1690,11 +1697,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// One materialised native-alarm condition together with the equipment folders wired as extra - /// event-notifier roots for it. The list is mutated in place under Lock as - /// notifiers are wired / torn down; is the concrete - /// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when - /// the instance changes). - private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List Folders); + /// event-notifier roots for it. The set (reference identity — review 01 P5: the + /// membership probe per wired folder was a linear ReferenceEquals walk) is mutated in place under + /// Lock as notifiers are wired / torn down; is the concrete + /// instance the notifiers were wired against (a rebuild recreates the + /// instance, so the entry is reset when the instance changes). + private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, HashSet Folders); // This is a RECONCILE, not additive-only: it wires the supplied set AND unwires any // previously-tracked folder whose id is no longer in notifierFolderNodeIds @@ -1735,7 +1743,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // the entry (the old folder list referenced the discarded instance) when the instance differs. if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm)) { - wiring = new AlarmNotifierWiring(alarm, new List()); + wiring = new AlarmNotifierWiring( + alarm, new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance)); _alarmNotifierWiring[alarmKey] = wiring; } @@ -1772,13 +1781,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder // folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link // EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent) - // AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply) - // updates rather than duplicates. + // A pair this wiring already holds is skipped outright (review 01 P5): the SDK's AddNotifier + // dedups by a linear ReferenceEquals walk of the condition's notifier list, so re-wiring K + // folders on an idempotent re-apply cost O(K²) for nothing. + if (!wiring.Folders.Add(folder)) continue; alarm.AddNotifier(SystemContext, null, isInverse: true, folder); folder.AddNotifier(SystemContext, null, isInverse: false, alarm); EnsureFolderIsEventNotifier(folder); - - if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder); } } } @@ -2826,10 +2835,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node whose subtree is forgotten. private void ForgetPredefinedSubtree(NodeState node) { - PredefinedNodes?.Remove(node.NodeId); + // Iterative with one reused child buffer — the recursive form allocated a list per node (review 01 P1). + var pending = new Stack(); + pending.Push(node); var children = new List(); - node.GetChildren(SystemContext, children); - foreach (var child in children) ForgetPredefinedSubtree(child); + while (pending.Count > 0) + { + var current = pending.Pop(); + PredefinedNodes?.Remove(current.NodeId); + children.Clear(); + current.GetChildren(SystemContext, children); + foreach (var child in children) pending.Push(child); + } } /// Promote to and @@ -2922,9 +2939,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 parent.AddChild(folder); AddPredefinedNode(SystemContext, folder); _folders[key] = folder; + if (!string.IsNullOrEmpty(parentNodeId)) IndexChild(MapKey(realm, parentNodeId), key); } } + /// Records under in the P2 subtree index. MUST be + /// called under Lock. Idempotent for the same pair; a child re-indexed under another parent moves. + private void IndexChild(RealmKey parent, RealmKey child) + { + if (_parentIndex.TryGetValue(child, out var previous)) + { + if (previous == parent) return; + UnindexChild(child); + } + + if (!_childIndex.TryGetValue(parent, out var children)) _childIndex[parent] = children = new HashSet(); + children.Add(child); + _parentIndex[child] = parent; + } + + /// Drops from the P2 subtree index. MUST be called under Lock. + private void UnindexChild(RealmKey child) + { + if (!_parentIndex.Remove(child, out var parent)) return; + if (_childIndex.TryGetValue(parent, out var children) && children.Remove(child) && children.Count == 0) + _childIndex.Remove(parent); + } + /// /// Update an EXISTING folder node's in place and notify /// subscribers, WITHOUT a rebuild — the in-place counterpart of for a @@ -3036,6 +3077,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 parent.AddChild(variable); AddPredefinedNode(SystemContext, variable); _variables[key] = variable; + if (!string.IsNullOrEmpty(parentFolderNodeId)) IndexChild(MapKey(realm, parentFolderNodeId), key); // Phase C: register the resolved historian tagname so the HistoryRead override can map this // NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME // tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname. @@ -3500,16 +3542,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // TombstoneMonitoredNode). The shared root survives the rebuild and keeps its items. TombstoneAllMonitoredNodes(); - foreach (var v in _variables.Values) - { - v.Parent?.RemoveChild(v); - PredefinedNodes?.Remove(v.NodeId); - } - _variables.Clear(); - // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. - _historizedTagnames.Clear(); - _arrayOnScalarRefused.Clear(); - // Capture every NATIVE condition's live state before the conditions are discarded, so the // re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted // conditions are not captured — their engine re-asserts them after the reload. @@ -3520,38 +3552,56 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm); } - foreach (var alarm in _alarmConditions.Values) - { - alarm.Parent?.RemoveChild(alarm); - ForgetPredefinedSubtree(alarm); - } // tear down every native-alarm→equipment-folder notifier pair bidirectionally // BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the - // rebuild. The condition + folder NodeState objects still exist here (the loops above/below only - // detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the - // tracking map so the re-materialise + re-wire on the next apply starts from a clean slate. + // rebuild. The condition + folder NodeState objects still exist here, so RemoveNotifier is valid; + // then clear the tracking map so the re-materialise + re-wire on the next apply starts clean. foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList()) UnwireAlarmNotifiers(alarmKey); + // Review 01 P1 — detach whole subtrees, not leaves. NodeState.RemoveChild is a linear scan + + // RemoveAt, so detaching each of a folder's k children from it cost ~k²/2 compares under Lock + // (a 10k-tag device folder ≈ 50M). Everything this manager minted hangs under the shared root, + // which survives the rebuild, so detaching the root's own children once discards every subtree + // beneath them; the SDK lookup dictionary is still cleared per id (O(1) each) because + // PredefinedNodes indexes every node, condition children included. + if (_root is { } root) + { + var topLevel = new List(); + root.GetChildren(SystemContext, topLevel); + // Last-first: RemoveChild still scans for the reference, but RemoveAt on the tail shifts nothing. + for (var i = topLevel.Count - 1; i >= 0; i--) root.RemoveChild(topLevel[i]); + } + + foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId); + _variables.Clear(); + // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. + _historizedTagnames.Clear(); + _arrayOnScalarRefused.Clear(); + + foreach (var alarm in _alarmConditions.Values) ForgetPredefinedSubtree(alarm); _alarmConditions.Clear(); // H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a // re-materialise on the next apply (possibly as the other kind) starts from a clean slate. _nativeAlarmNodeIds.Clear(); - foreach (var f in _folders.Values) - { - f.Parent?.RemoveChild(f); - PredefinedNodes?.Remove(f.NodeId); - } + foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId); _folders.Clear(); + _childIndex.Clear(); + _parentIndex.Clear(); // Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the // guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server - // object. RemoveRootNotifier just severs that link, so its order relative to the folder - // teardown above doesn't matter — but it must run under this same Lock. - foreach (var folder in _notifierFolders.Values) + // object. Done the way RemoveRootNotifier does it, but over the whole list at once: the SDK's + // per-folder removal scans RootNotifiers each time (O(F²) for F promoted folders). + if (RootNotifiers is { Count: > 0 } rootNotifiers) { - RemoveRootNotifier(folder); + foreach (var notifier in rootNotifiers) + { + notifier.OnReportEvent = null; + notifier.RemoveReference(ReferenceTypeIds.HasNotifier, true, ObjectIds.Server); + } + rootNotifiers.Clear(); } // Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt) @@ -3611,6 +3661,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. if (!_variables.TryRemove(key, out var variable)) return false; + UnindexChild(key); TombstoneMonitoredNode(variable.NodeId, UtcNow); // 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds // BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a @@ -3678,6 +3729,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole // equipment goes). if (!_alarmConditions.TryRemove(key, out var condition)) return false; + UnindexChild(key); TombstoneMonitoredSubtree(condition, UtcNow); // unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE // the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without @@ -3686,7 +3738,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.Parent?.RemoveChild(condition); ForgetPredefinedSubtree(condition); _nativeAlarmNodeIds.Remove(key); - ReconcileAllEventsMonitoring(); + LowerAllEventsMonitoring(condition); e = BuildNodesRemovedModelChange(alarmNodeId, realm); } ReportModelChangeOutsideLock(e, alarmNodeId); @@ -3706,84 +3758,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds). if (!_folders.ContainsKey(equipKey)) return false; - // Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly - // or begins with "/" (sub-folders, variables, condition nodes); the explicit realm - // compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s=" - // — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot - // each map's in-scope keys first (ToList) so we don't mutate while enumerating. - var prefix = equipmentNodeId + "/"; - bool InScope(RealmKey key) => key.Realm == realm - && (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal)); - - var now = UtcNow; - // Value variables (+ their historized-tagname registrations). - foreach (var id in _variables.Keys.Where(InScope).ToList()) - { - if (_variables.TryRemove(id, out var v)) - { - TombstoneMonitoredNode(v.NodeId, now); - // Sever the inverse half of each cross-tree Organizes edge - // first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a - // RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge. - SeverOutgoingReferences(v); - v.Parent?.RemoveChild(v); - PredefinedNodes?.Remove(v.NodeId); - } - _historizedTagnames.TryRemove(id, out _); - _arrayOnScalarRefused.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)) - { - TombstoneMonitoredSubtree(c, now); - c.Parent?.RemoveChild(c); - ForgetPredefinedSubtree(c); - } - _nativeAlarmNodeIds.Remove(id); - } - ReconcileAllEventsMonitoring(); - - // a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this - // UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra - // event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop, - // else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The - // condition's own scoped removal — if its raw tag is also removed this apply — runs later via - // RemoveAlarmConditionNode; this only reaches surviving conditions.) - foreach (var wiring in _alarmNotifierWiring.Values) - { - foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList()) - { - wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true); - wiring.Folders.Remove(folder); - } - } - - // 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 InScope on its realm-qualified map key. - foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).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)) - { - TombstoneMonitoredNode(f.NodeId, now); - f.Parent?.RemoveChild(f); - PredefinedNodes?.Remove(f.NodeId); - } - } + // Review 01 P2 — walk the parent→children index the materialise calls maintain instead of + // prefix-scanning every map (variables, conditions, folders, notifier folders, event sources and + // every alarm's wiring) per removed equipment, which made a PureRemove of M equipment O(M·N) + // under Lock. A node is detached from its SDK parent only when that parent SURVIVES (the + // equipment folder from its line; a node hung under the shared root because its parent was + // unknown at creation) — detaching k children from a folder that is itself being discarded cost + // k²/2 compares for nothing (NodeState.RemoveChild is a linear scan). + var removedParents = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance); + RemoveSubtree(equipKey, removedParents, UtcNow); e = BuildNodesRemovedModelChange(equipmentNodeId, realm); } @@ -3791,6 +3774,84 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return true; } + /// Removes the node at and, when it is a folder, everything indexed beneath + /// it (children first). MUST be called under Lock. + /// The node to remove. + /// The folders already discarded by this removal — a node under one of them is + /// not detached from it. + /// The timestamp the removal statuses carry. + private void RemoveSubtree(RealmKey key, HashSet removedParents, DateTime now) + { + if (_folders.TryGetValue(key, out var folder)) + { + removedParents.Add(folder); + if (_childIndex.TryGetValue(key, out var children)) + { + foreach (var child in children.ToArray()) RemoveSubtree(child, removedParents, now); + _childIndex.Remove(key); + } + + _folders.TryRemove(key, out _); + UnindexChild(key); + TombstoneMonitoredNode(folder.NodeId, now); + + // A native alarm condition lives at its raw tag (Raw realm) and SURVIVES this UNS-scoped + // subtree removal, but it may hold this folder as an extra event-notifier root. Unwire those + // condition↔folder pairs bidirectionally BEFORE the folder drops, else the surviving condition + // keeps a dangling inverse-notifier entry to a removed folder. The folder's own notifier list + // names exactly the conditions wired into it (the forward half of each pair). + var notifiers = new List(); + folder.GetNotifiers(SystemContext, notifiers); + foreach (var entry in notifiers) + { + if (entry.IsInverse || entry.Node is not AlarmConditionState alarm) continue; + if (!_alarmNotifierWiring.TryGetValue(MapKey(alarm.NodeId), out var wiring) || !ReferenceEquals(wiring.Alarm, alarm)) continue; + alarm.RemoveNotifier(SystemContext, folder, bidirectional: true); + wiring.Folders.Remove(folder); + } + + // Notifier demotion BEFORE dropping the folder: sever the Server↔folder HasNotifier ref for a + // promoted folder (an equipment folder, or a sub-folder that hosted an alarm), else the removal + // leaks an orphaned root-notifier reference on the Server object. + if (_notifierFolders.Remove(folder.NodeId, out var promoted)) RemoveRootNotifier(promoted); + _eventNotifierSources.TryRemove(key, out _); + + if (folder.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(folder); + PredefinedNodes?.Remove(folder.NodeId); + return; + } + + if (_variables.TryRemove(key, out var v)) + { + UnindexChild(key); + TombstoneMonitoredNode(v.NodeId, now); + // Sever the inverse half of each cross-tree Organizes edge first, exactly as RemoveVariableNode + // does — a UNS reference variable in scope points at a RAW node that survives this UNS-scoped + // removal and would otherwise keep a dangling edge. + SeverOutgoingReferences(v); + if (v.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(v); + PredefinedNodes?.Remove(v.NodeId); + _historizedTagnames.TryRemove(key, out _); + _arrayOnScalarRefused.TryRemove(key, out _); + return; + } + + if (_alarmConditions.TryRemove(key, out var c)) + { + UnindexChild(key); + TombstoneMonitoredSubtree(c, now); + UnwireAlarmNotifiers(key); + if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c); + ForgetPredefinedSubtree(c); + _nativeAlarmNodeIds.Remove(key); + LowerAllEventsMonitoring(c); + return; + } + + // Indexed but already gone (a surgical remove dropped it first) — nothing to do. + UnindexChild(key); + } + private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm) { EnsureAddressSpaceCreated(); @@ -4296,10 +4357,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// children are monitorable in their own right). MUST be called under Lock. private void TombstoneMonitoredSubtree(NodeState node, DateTime now) { - TombstoneMonitoredNode(node.NodeId, now); + if (MonitoredNodes.Count == 0 && Volatile.Read(ref _suppressedItems) == 0) return; + var pending = new Stack(); + pending.Push(node); var children = new List(); - node.GetChildren(SystemContext, children); - foreach (var child in children) TombstoneMonitoredSubtree(child, now); + while (pending.Count > 0) + { + var current = pending.Pop(); + TombstoneMonitoredNode(current.NodeId, now); + children.Clear(); + current.GetChildren(SystemContext, children); + foreach (var child in children) pending.Push(child); + } } /// The rebuild form: every monitored node this manager serves except the shared root, which the @@ -4560,6 +4629,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true); } + /// + /// The removal-side mirror of (review 01 P3): gives back the one + /// condition's unit, touching no other condition. Call under Lock, for a condition being removed + /// or replaced. The surgical removal sites used to run the full reconcile — O(C) per removed condition + /// while any Server-object item was live, i.e. always in production. + /// + /// The condition leaving _alarmConditions. + private void LowerAllEventsMonitoring(AlarmConditionState condition) + { + AllEventsMonitoringConditionVisits++; + if (_allEventsMonitoredConditions.Remove(condition)) + condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true); + } + /// /// How many conditions the Server-object monitored bookkeeping has visited since start — every /// condition a full walks, plus one per incremental diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs new file mode 100644 index 00000000..848529f3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs @@ -0,0 +1,190 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 P1 / P2 / P3 — the lock-time budget of the three teardown paths that run under the +/// manager-wide SDK Lock on the pinned publish thread, where every Read / Browse / Publish waits. +/// +/// P1: a full rebuild of a 10k-tag device folder was O(k²) per folder because +/// NodeState.RemoveChild is a linear scan + RemoveAt per leaf (~50M compares). +/// P2: removing M equipment from an N-node space scanned every map per equipment (O(M·N)). +/// P3: every surgical condition removal re-walked every condition for the Server-object +/// monitoring bookkeeping (O(C) per removal) while any i=2253 item existed. +/// +/// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit +/// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine. +/// +[Trait("Category", "Benchmark")] +public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static readonly TimeSpan Budget = TimeSpan.FromSeconds(1); + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-bench-{Guid.NewGuid():N}"); + + /// P1 — a rebuild that discards one device folder holding 10k tags completes within the budget. + [Fact] + public async Task Rebuild_of_a_10k_tag_folder_stays_within_budget() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + const string device = "Plant/Modbus/dev1"; + nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw); + for (var i = 0; i < 10_000; i++) + nm.EnsureVariable($"{device}/T{i}", device, $"T{i}", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + nm.VariableCount.ShouldBe(10_000); + + var sw = Stopwatch.StartNew(); + nm.RebuildAddressSpace(); + sw.Stop(); + nm.CompleteAddressSpaceApply(); + + nm.VariableCount.ShouldBe(0); + nm.FolderCount.ShouldBe(0); + // Positive control: the space is really empty for the SDK too, and can be re-populated. + nm.GetManagerHandle(new NodeId($"{device}/T0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))).ShouldBeNull(); + nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw); + nm.EnsureVariable($"{device}/T0", device, "T0", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + nm.TryGetVariable($"{device}/T0", AddressSpaceRealm.Raw)!.Parent.ShouldBeSameAs(nm.TryGetFolder(device, AddressSpaceRealm.Raw)); + + output.WriteLine($"BENCH rebuild 10k-tag folder: {sw.ElapsedMilliseconds} ms"); + sw.Elapsed.ShouldBeLessThan(Budget, $"rebuild of a 10k-tag folder took {sw.ElapsedMilliseconds} ms"); + } + finally { await host.DisposeAsync(); } + } + + /// P2 — removing 200 equipment (250 signals each, 50k nodes) completes within the budget. + [Fact] + public async Task Removing_200_equipment_from_a_50k_node_space_stays_within_budget() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + const int equipmentCount = 200, perEquipment = 250; + for (var e = 0; e < equipmentCount; e++) + { + var equip = $"area/line/eq{e}"; + nm.EnsureFolder(equip, null, $"eq{e}", AddressSpaceRealm.Uns); + for (var i = 0; i < perEquipment; i++) + nm.EnsureVariable($"{equip}/S{i}", equip, $"S{i}", "Float", writable: false, realm: AddressSpaceRealm.Uns); + } + // A survivor the removals must not touch. + nm.EnsureFolder("area/line/keep", null, "keep", AddressSpaceRealm.Uns); + nm.EnsureVariable("area/line/keep/S0", "area/line/keep", "S0", "Float", writable: false, realm: AddressSpaceRealm.Uns); + nm.VariableCount.ShouldBe(equipmentCount * perEquipment + 1); + + var sw = Stopwatch.StartNew(); + for (var e = 0; e < equipmentCount; e++) + nm.RemoveEquipmentSubtree($"area/line/eq{e}", AddressSpaceRealm.Uns).ShouldBeTrue(); + sw.Stop(); + + nm.VariableCount.ShouldBe(1); + nm.FolderCount.ShouldBe(1); + nm.TryGetVariable("area/line/keep/S0").ShouldNotBeNull(); + nm.GetManagerHandle(new NodeId("area/line/eq0/S0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))).ShouldBeNull(); + + output.WriteLine($"BENCH remove 200 equipment / 50k nodes: {sw.ElapsedMilliseconds} ms"); + sw.Elapsed.ShouldBeLessThan(Budget, $"removing 200 equipment took {sw.ElapsedMilliseconds} ms"); + } + finally { await host.DisposeAsync(); } + } + + /// + /// P3 — with a Server-object item live, a surgical condition removal lowers only that condition's + /// monitoring unit instead of re-walking every condition: the bookkeeping visits at most one + /// condition per removal. (A full reconcile visits every condition twice — desired + held.) + /// + [Fact] + public async Task A_surgical_condition_removal_visits_only_its_own_condition() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); + const int conditions = 50; + for (var i = 0; i < conditions; i++) + nm.MaterialiseAlarmCondition($"alm-{i}", "eq-1", $"Alarm {i}", "OffNormalAlarm", 500, AddressSpaceRealm.Uns); + + // A Server-object (all-events) subscriber, so the per-condition monitoring unit is in play. + nm.SubscribeToAllEvents( + new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, identity: null), + 1, new StubEventItem(), unsubscribe: false); + nm.ConditionEventsMonitored("alm-0", AddressSpaceRealm.Uns).ShouldBeTrue("non-vacuity: the units really were raised"); + + var before = nm.AllEventsMonitoringConditionVisits; + for (var i = 0; i < 10; i++) + nm.RemoveAlarmConditionNode($"alm-{i}", AddressSpaceRealm.Uns).ShouldBeTrue(); + var visits = nm.AllEventsMonitoringConditionVisits - before; + output.WriteLine($"BENCH all-events visits for 10 condition removals over {conditions} conditions: {visits}"); + + visits.ShouldBeLessThanOrEqualTo(10, $"ten removals visited {visits} conditions; a full reconcile per removal would visit ~{10 * 2 * conditions}"); + // The survivors keep their unit. + nm.ConditionEventsMonitored("alm-49", AddressSpaceRealm.Uns).ShouldBeTrue(); + } + finally { await host.DisposeAsync(); } + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.BenchTest", + ApplicationUri = $"urn:OtOpcUa.BenchTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + /// The minimum of a Server-object event item the all-events bookkeeping touches: identity only. + private sealed class StubEventItem : IEventMonitoredItem + { + public bool MonitoringAllEvents => true; + public NodeId NodeId => ObjectIds.Server; + public IUserIdentity EffectiveIdentity => null!; + public object ManagerHandle => null!; + public void QueueEvent(IFilterTarget instance) { } + public void QueueEvent(IFilterTarget instance, bool bypassFilter) { } + public void Dispose() { } + + public INodeManager NodeManager => throw new NotSupportedException(); + public ISession Session => throw new NotSupportedException(); + public uint Id => 1; + public uint SubscriptionId => 1; + public bool IsDurable => false; + public uint ClientHandle => 1; + public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public int MonitoredItemType => MonitoredItemTypeMask.Events; + public bool IsReadyToPublish => false; + public bool IsReadyToTrigger { get => false; set { } } + public bool IsResendData => false; + public MonitoringMode MonitoringMode => MonitoringMode.Reporting; + public double SamplingInterval => 0; + public EventFilter EventFilter => null!; + public void SetupResendDataTrigger() { } + public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException(); + public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException(); + public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException(); + public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException(); + public bool Publish(OperationContext context, Queue notifications, uint maxNotificationsPerPublish) => throw new NotSupportedException(); + public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException(); + } +} From 1578efd4a91bae7b0261498bad6408e35f031670 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:22:29 -0400 Subject: [PATCH 04/13] fix(opcua): write-path robustness, injected clock, configurable quotas (B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 L3/H5/H4/P6/P4. - L3/H5 OnEquipmentTagWrite's dispatch is factored into DispatchDeviceWrite: a gateway that throws synchronously is refused inline with BadDeviceFailure (it used to escape the handler under the SDK Lock), and a faulted/cancelled dispatch task has its exception observed and logged and carries the real reason into the revert/audit event instead of surfacing as UnobservedTaskException at finalization. - H4 every timestamp the manager stamps (condition quality, timed-shelve expiry, method-transition events, carry-over, revert, revalidation) reads the injected Clock; RemainingShelveMs takes the clock reading. - P6 OpcUa:Quotas:* (OpcUaQuotaOptions on OpcUaApplicationHostOptions): Min/MaxRequestThreadCount, MaxQueuedRequestCount with the former hard-coded 5/100/200 as defaults, and the TransportQuotas members as nullable overrides over the SDK defaults. - P4 the SDK line exposes IHistoryReadAsyncNodeManager but CustomNodeManager2 offers no async virtual to override (adopting it means re-implementing the base dispatcher), so the lever is the admission cap: MaxConcurrentHistoryReadBatches defaults to clamp(2*ProcessorCount, 4, 16). The Host still overrides it from ServerHistorianOptions (default 16) — follow-up F3. Tests: NodeManagerWriteDispatchTests (sync throw refused; faulted task reverts; success leaves the value; clock-stamped revert), OpcUaApplicationHostQuotaTests. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OpcUaApplicationHost.cs | 89 +++++++++- .../OtOpcUaNodeManager.cs | 110 +++++++++--- .../NodeManagerWriteDispatchTests.cs | 160 ++++++++++++++++++ .../OpcUaApplicationHostQuotaTests.cs | 74 ++++++++ 4 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index b1e265fd..4b12e0d1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -105,6 +105,55 @@ public sealed class OpcUaApplicationHostOptions /// is always element 0. /// public IList PeerApplicationUris { get; set; } = new List(); + + /// + /// SDK request-processing and transport quotas (OpcUa:Quotas:*, review 01 P6). The defaults are + /// exactly the values that used to be hard-coded (5 / 100 / 200 request threads and queue) and the SDK's + /// own transport defaults, so an absent section changes nothing. + /// + public OpcUaQuotaOptions Quotas { get; set; } = new(); +} + +/// +/// The OpcUa:Quotas section: the SDK's request-thread / queue ceilings and its transport quotas. +/// Request quotas carry the former hard-coded defaults; a transport quota left null keeps the SDK +/// default ('s constructor values). +/// +public sealed class OpcUaQuotaOptions +{ + /// Minimum SDK request-processing threads (default 5). + public int MinRequestThreadCount { get; set; } = 5; + + /// Maximum SDK request-processing threads (default 100). + public int MaxRequestThreadCount { get; set; } = 100; + + /// Requests the SDK queues before refusing with BadTooManyOperations (default 200) — the + /// burst ceiling review 01 P6 flagged. + public int MaxQueuedRequestCount { get; set; } = 200; + + /// Per-operation timeout in milliseconds; null keeps the SDK default. + public int? OperationTimeoutMs { get; set; } + + /// Largest string the transport decodes; null keeps the SDK default. + public int? MaxStringLength { get; set; } + + /// Largest byte string the transport decodes; null keeps the SDK default. + public int? MaxByteStringLength { get; set; } + + /// Largest array the transport decodes; null keeps the SDK default. + public int? MaxArrayLength { get; set; } + + /// Largest message the transport accepts, in bytes; null keeps the SDK default. + public int? MaxMessageSize { get; set; } + + /// Transport buffer size in bytes; null keeps the SDK default. + public int? MaxBufferSize { get; set; } + + /// Secure-channel lifetime in milliseconds; null keeps the SDK default. + public int? ChannelLifetimeMs { get; set; } + + /// Security-token lifetime in milliseconds; null keeps the SDK default. + public int? SecurityTokenLifetimeMs { get; set; } } /// @@ -435,10 +484,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable var serverConfig = new ServerConfiguration { BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" }, - MinRequestThreadCount = 5, - MaxRequestThreadCount = 100, - MaxQueuedRequestCount = 200, }; + ApplyRequestQuotas(serverConfig, _options.Quotas); foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles)) { @@ -469,7 +516,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") }, AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates, }, - TransportQuotas = new TransportQuotas(), + TransportQuotas = BuildTransportQuotas(_options.Quotas), ClientConfiguration = new ClientConfiguration(), TraceConfiguration = new TraceConfiguration(), }; @@ -479,6 +526,40 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable return config; } + /// + /// Applies the request-processing quotas (OpcUa:Quotas:*, review 01 P6 — they were hard-coded at + /// 5 / 100 / 200) to the SDK server configuration. + /// + /// The configuration to set. + /// The configured quotas. + internal static void ApplyRequestQuotas(ServerConfiguration serverConfig, OpcUaQuotaOptions quotas) + { + serverConfig.MinRequestThreadCount = quotas.MinRequestThreadCount; + serverConfig.MaxRequestThreadCount = quotas.MaxRequestThreadCount; + serverConfig.MaxQueuedRequestCount = quotas.MaxQueuedRequestCount; + } + + /// + /// Builds the SDK transport quotas from OpcUa:Quotas:*: every member starts at the SDK default and + /// only a configured (non-null) value replaces it, so an absent section is byte-identical to the former + /// new TransportQuotas(). + /// + /// The configured quotas. + /// The transport quotas to install. + internal static TransportQuotas BuildTransportQuotas(OpcUaQuotaOptions quotas) + { + var t = new TransportQuotas(); + if (quotas.OperationTimeoutMs is { } operationTimeout) t.OperationTimeout = operationTimeout; + if (quotas.MaxStringLength is { } maxString) t.MaxStringLength = maxString; + if (quotas.MaxByteStringLength is { } maxByteString) t.MaxByteStringLength = maxByteString; + if (quotas.MaxArrayLength is { } maxArray) t.MaxArrayLength = maxArray; + if (quotas.MaxMessageSize is { } maxMessage) t.MaxMessageSize = maxMessage; + if (quotas.MaxBufferSize is { } maxBuffer) t.MaxBufferSize = maxBuffer; + if (quotas.ChannelLifetimeMs is { } channelLifetime) t.ChannelLifetime = channelLifetime; + if (quotas.SecurityTokenLifetimeMs is { } tokenLifetime) t.SecurityTokenLifetime = tokenLifetime; + return t; + } + /// /// Maps each configured to a SDK /// . Duplicate profiles are silently de-duped because diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 9a2fe15f..8f03cd75 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -581,9 +581,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Process-wide cap on concurrently-served HistoryRead batches (arch-review 03/S3). A batch that /// cannot acquire a slot within its (bounded) wait budget fast-fails every handle with /// BadTooManyOperations. Mirrors ServerHistorianOptions.MaxConcurrentHistoryReadBatches; - /// the Host sets it at StartAsync. The default (16) survives when the section is absent. + /// the Host sets it at StartAsync. The default () + /// survives when the section is absent. + /// + /// Review 01 P4: every HistoryRead / HistoryUpdate arm is sync-over-async on an SDK request thread — + /// the SDK line pins this manager to the synchronous CustomNodeManager2.HistoryRead dispatcher + /// (its IHistoryReadAsyncNodeManager is a separate interface, adapted for sync managers, that + /// offers no async virtual to override; adopting it means re-implementing the base's handle validation + /// and continuation-point plumbing). So the admission cap is the lever: with a slow historian a + /// batch holds its request thread for up to , and 16 of them on a + /// small node is a third of MaxRequestThreadCount. The default therefore scales with the + /// machine — clamp(2 × ProcessorCount, 4, 16) — instead of a flat 16. + /// /// - public int MaxConcurrentHistoryReadBatches { get; set; } = 16; + public int MaxConcurrentHistoryReadBatches { get; set; } = DefaultMaxConcurrentHistoryReadBatches; + + /// The CPU-scaled default of : clamp(2 × ProcessorCount, 4, 16). + public static int DefaultMaxConcurrentHistoryReadBatches { get; } = Math.Clamp(Environment.ProcessorCount * 2, 4, 16); /// /// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in @@ -1058,7 +1072,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 SystemContext, shelved: state.Shelving != AlarmShelvingKind.Unshelved, oneShot: state.Shelving == AlarmShelvingKind.OneShot, - shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc) : 0); + shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc, UtcNow) : 0); } condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity)); condition.Message.Value = new LocalizedText(state.Message); @@ -1105,11 +1119,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// at most int.MaxValue (the SDK timer's range). /// /// The engine's expiry instant, or null. + /// The manager's clock reading (), so a test clock drives it. /// The shelving time in milliseconds. - internal static double RemainingShelveMs(DateTime? unshelveAtUtc) + internal static double RemainingShelveMs(DateTime? unshelveAtUtc, DateTime nowUtc) { if (unshelveAtUtc is not { } at) return 0; - var remaining = (at - DateTime.UtcNow).TotalMilliseconds; + var remaining = (at - nowUtc).TotalMilliseconds; return Math.Clamp(Math.Ceiling(remaining), 1, int.MaxValue); } @@ -1476,7 +1491,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (alarm.Quality is not null) { alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good; - if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow; + if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = UtcNow; } // Initial state via the SDK setters (T14: basic state only, NO event firing). @@ -1538,7 +1553,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var (operation, unshelveAt) = !shelving ? ("Unshelve", (DateTime?)null) : oneShot ? ("OneShotShelve", null) - : ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime)); + : ("TimedShelve", UtcNow + TimeSpan.FromMilliseconds(shelvingTime)); return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt); }; // The SDK's auto-unshelve timer (armed by SetShelvingState for the real remaining time since the @@ -1635,9 +1650,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// /// The live native condition, about to be discarded. /// Its carried state. - private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new( + private NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new( ReadConditionDelta(condition), - condition.Time?.Value ?? DateTime.UtcNow, + condition.Time?.Value ?? UtcNow, condition.ActiveState?.TransitionTime?.Value, condition.Quality?.SourceTimestamp?.Value, condition.Comment?.Value, @@ -1953,7 +1968,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } if (alwaysReport || ReadConditionDelta(alarm) != before) - ReportConditionEvent(alarm, DateTime.UtcNow); + ReportConditionEvent(alarm, UtcNow); return result; } @@ -2223,20 +2238,60 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart. var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName; - // Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock - // continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never - // sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the - // revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed - // task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never - // re-enters lock (Lock) while CustomNodeManager2.Write still holds it. - _ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None) - .ContinueWith( - t => + return DispatchDeviceWrite(gateway, nodeKey, nodeBareId, nodeRealm, optimisticValue, priorValue, priorStatus, clientUserId); + } + + /// + /// The dispatch half of : start the device write and arm the off-Lock + /// revert. Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert + /// (off-Lock continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic + /// value never sticks when the route never resolved a real outcome; its exception is OBSERVED and logged + /// here (review 01 L3 — it used to surface only as an UnobservedTaskException at finalization, with + /// the constant reason "write dispatch faulted" in the audit event). A gateway that throws SYNCHRONOUSLY + /// (review 01 H5) is refused inline with BadDeviceFailure before the SDK applies anything, rather + /// than escaping the handler under Lock. RunContinuationsAsynchronously guarantees the revert + /// never runs inline on the SDK write thread (the gateway can return a synchronously-completed task — e.g. + /// its boot-window "no DriverHostActor yet" branch), so never + /// re-enters Lock while CustomNodeManager2.Write still holds it. + /// + /// once the write is in flight (the SDK then applies the value + /// optimistically); a Bad result when the dispatch itself threw. + internal ServiceResult DispatchDeviceWrite( + IOpcUaNodeWriteGateway gateway, string nodeKey, string nodeBareId, AddressSpaceRealm nodeRealm, + object? optimisticValue, object? priorValue, StatusCode priorStatus, string? clientUserId) + { + Task dispatch; + try + { + dispatch = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OtOpcUaNodeManager: write dispatch for {NodeId} threw synchronously; the write is refused", nodeKey); + return new ServiceResult(StatusCodes.BadDeviceFailure, $"write dispatch threw: {ex.Message}"); + } + + _ = dispatch.ContinueWith( + t => + { + NodeWriteOutcome outcome; + if (t.IsCompletedSuccessfully) { - var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted"); - RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm); - }, - CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); + outcome = t.Result; + } + else + { + // Reading t.Exception observes it, so the failure never reaches UnobservedTaskException. + var fault = t.Exception?.GetBaseException(); + var reason = fault is not null ? $"write dispatch faulted: {fault.Message}" + : t.IsCanceled ? "write dispatch cancelled" : "write dispatch faulted"; + _logger.LogWarning(fault, "OtOpcUaNodeManager: write dispatch for {NodeId} did not complete — {Reason}", nodeKey, reason); + outcome = new NodeWriteOutcome(false, reason); + } + + RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm); + }, + CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); return ServiceResult.Good; } @@ -2647,13 +2702,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore // the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's // node-update shape). See the method remarks for the queue-coalescing caveat. + var now = UtcNow; variable.StatusCode = StatusCodes.BadDeviceFailure; - variable.Timestamp = DateTime.UtcNow; + variable.Timestamp = now; variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip variable.Value = priorValue; variable.StatusCode = priorStatus; - variable.Timestamp = DateTime.UtcNow; + variable.Timestamp = now; variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior // Item C: build the audit event-state while we hold the node reference + Lock, but DON'T report yet. @@ -2710,7 +2766,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 severity: EventSeverity.Medium, message: new LocalizedText($"Inbound write rejected by device: {reason}"), status: false, - actionTimestamp: DateTime.UtcNow); + actionTimestamp: UtcNow); // Write-specific fields (AuditWriteUpdateEventType). AttributeId = Value; IndexRange empty (full write); // OldValue = the real pre-write value; NewValue = the value the client attempted. The AuditWriteUpdate @@ -5079,7 +5135,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return; - var now = DateTime.UtcNow; + var now = UtcNow; var stoodDown = 0; var restored = 0; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs new file mode 100644 index 00000000..404a8550 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs @@ -0,0 +1,160 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 L3 / H5 / H4 — the device-write dispatch behind +/// and the manager's clock: a gateway that throws synchronously is refused inline (the handler runs under +/// the SDK Lock, and the throw used to escape it), a faulted dispatch task is observed and still +/// reverts the optimistic value, and the timestamps the manager stamps come from the injected +/// . +/// +public sealed class NodeManagerWriteDispatchTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dispatch-{Guid.NewGuid():N}"); + + /// H5 — a synchronous throw out of the gateway is a Bad result, never an escaped exception. + [Fact] + public async Task A_gateway_that_throws_synchronously_is_refused_inline() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + + var result = nm.DispatchDeviceWrite( + new ThrowingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns, + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + + ServiceResult.IsBad(result).ShouldBeTrue(); + result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadDeviceFailure); + result.ToString().ShouldContain("boom"); + nm.TryGetVariable("eq-1/sp")!.Value.ShouldBe(7, "nothing was applied, so nothing needs reverting"); + } + finally { await host.DisposeAsync(); } + } + + /// L3 — a faulted dispatch task is observed and the optimistic value is reverted with its reason. + [Fact] + public async Task A_faulted_dispatch_is_observed_and_reverts_the_optimistic_value() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var node = nm.TryGetVariable("eq-1/sp")!; + + var result = nm.DispatchDeviceWrite( + new FaultingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns, + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + ServiceResult.IsGood(result).ShouldBeTrue("the dispatch itself started; the SDK applies the value optimistically"); + node.Value = 42; // what the SDK does on Good + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline && !Equals(node.Value, 7)) await Task.Delay(20, Ct); + node.Value.ShouldBe(7, "the faulted dispatch must revert the optimistic value"); + node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); + } + finally { await host.DisposeAsync(); } + } + + /// Positive control for the two above: a gateway that succeeds leaves the optimistic value standing. + [Fact] + public async Task A_successful_dispatch_leaves_the_optimistic_value() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var node = nm.TryGetVariable("eq-1/sp")!; + + nm.DispatchDeviceWrite( + new SucceedingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns, + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + node.Value = 42; + + await Task.Delay(200, Ct); + node.Value.ShouldBe(42); + } + finally { await host.DisposeAsync(); } + } + + /// H4 — the revert stamps the manager's clock, not the wall clock. + [Fact] + public async Task The_revert_timestamps_come_from_the_injected_clock() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var fixedNow = new DateTime(2030, 1, 2, 3, 4, 5, DateTimeKind.Utc); + nm.Clock = new FixedClock(fixedNow); + nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), AddressSpaceRealm.Uns); + var node = nm.TryGetVariable("eq-1/sp")!; + node.Value = 42; + + nm.RevertOptimisticWriteIfNeeded( + "eq-1/sp", new NodeWriteOutcome(false, "rejected"), optimisticValue: 42, priorValue: 7, + priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns); + + node.Timestamp.ShouldBe(fixedNow); + OtOpcUaNodeManager.RemainingShelveMs(fixedNow.AddSeconds(30), fixedNow).ShouldBe(30_000); + } + finally { await host.DisposeAsync(); } + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.DispatchTest", + ApplicationUri = $"urn:OtOpcUa.DispatchTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + private sealed class ThrowingGateway : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => + throw new InvalidOperationException("boom"); + } + + private sealed class FaultingGateway : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => + Task.FromException(new TimeoutException("gateway ask timed out")); + } + + private sealed class SucceedingGateway : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => + Task.FromResult(new NodeWriteOutcome(true, null)); + } + + private sealed class FixedClock(DateTime utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs new file mode 100644 index 00000000..0809e916 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs @@ -0,0 +1,74 @@ +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// Review 01 P6 — the SDK request and transport quotas are configurable (OpcUa:Quotas:*) and +/// default to exactly what used to be hard-coded. +public sealed class OpcUaApplicationHostQuotaTests +{ + [Fact] + public void Default_request_quotas_are_the_former_hard_coded_values() + { + var config = new ServerConfiguration(); + OpcUaApplicationHost.ApplyRequestQuotas(config, new OpcUaQuotaOptions()); + + config.MinRequestThreadCount.ShouldBe(5); + config.MaxRequestThreadCount.ShouldBe(100); + config.MaxQueuedRequestCount.ShouldBe(200); + } + + [Fact] + public void Configured_request_quotas_are_applied() + { + var config = new ServerConfiguration(); + OpcUaApplicationHost.ApplyRequestQuotas(config, new OpcUaQuotaOptions + { + MinRequestThreadCount = 8, MaxRequestThreadCount = 250, MaxQueuedRequestCount = 1000, + }); + + config.MinRequestThreadCount.ShouldBe(8); + config.MaxRequestThreadCount.ShouldBe(250); + config.MaxQueuedRequestCount.ShouldBe(1000); + } + + [Fact] + public void Unset_transport_quotas_keep_the_sdk_defaults() + { + var sdkDefault = new TransportQuotas(); + var built = OpcUaApplicationHost.BuildTransportQuotas(new OpcUaQuotaOptions()); + + built.OperationTimeout.ShouldBe(sdkDefault.OperationTimeout); + built.MaxStringLength.ShouldBe(sdkDefault.MaxStringLength); + built.MaxByteStringLength.ShouldBe(sdkDefault.MaxByteStringLength); + built.MaxArrayLength.ShouldBe(sdkDefault.MaxArrayLength); + built.MaxMessageSize.ShouldBe(sdkDefault.MaxMessageSize); + built.MaxBufferSize.ShouldBe(sdkDefault.MaxBufferSize); + built.ChannelLifetime.ShouldBe(sdkDefault.ChannelLifetime); + built.SecurityTokenLifetime.ShouldBe(sdkDefault.SecurityTokenLifetime); + } + + [Fact] + public void Configured_transport_quotas_replace_only_what_is_set() + { + var sdkDefault = new TransportQuotas(); + var built = OpcUaApplicationHost.BuildTransportQuotas(new OpcUaQuotaOptions + { + MaxMessageSize = 8 * 1024 * 1024, OperationTimeoutMs = 45_000, MaxArrayLength = 1_000_000, + }); + + built.MaxMessageSize.ShouldBe(8 * 1024 * 1024); + built.OperationTimeout.ShouldBe(45_000); + built.MaxArrayLength.ShouldBe(1_000_000); + built.MaxStringLength.ShouldBe(sdkDefault.MaxStringLength); + built.ChannelLifetime.ShouldBe(sdkDefault.ChannelLifetime); + } + + [Fact] + public void History_read_batch_cap_default_scales_with_the_machine() + { + OtOpcUaNodeManager.DefaultMaxConcurrentHistoryReadBatches + .ShouldBe(Math.Clamp(Environment.ProcessorCount * 2, 4, 16)); + } +} From 118933d5d515b356aa79cd39f141c6412b9e6831 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:29:54 -0400 Subject: [PATCH 05/13] refactor(opcua): delete the dead v3 equipment-tag materialisation (B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 U2. The composer never emits EquipmentTags in v3 (device values live in the Raw subtree and reach equipment as UnsReferenceVariables), so the equipment-tag plan diff sets (Added/Removed/ChangedEquipmentTags, EquipmentTagDelta), the planner's diff over them, the classifier's TagDeltaIsSurgicalEligible and its Rebuild clause, the applier's MaterialiseEquipmentTags pass, surgical ChangedEquipmentTags loop, removed-tag teardown loop and add-announcement term, and the publish actor's equipment-tag pass were unreachable, maintained dead weight. IsEmpty keeps its semantics (an otherwise-empty plan is still Empty). Kept, deliberately: AddressSpaceComposition.EquipmentTags and the EquipmentTagPlan record — Runtime's DeploymentArtifact (outside this lane) still assigns the empty set and the artifact parity tests assert it; follow-up F2 removes both together. Tests: the equipment-tag vehicle tests for PureRemove / AddRemoveMix / the rebuild ratchet are re-expressed over raw tags (same applier paths); the equipment-tag surgical and classifier tests are deleted (their raw / UNS-reference equivalents already exist); SubscriptionSurvivalTests now materialises its tags through MaterialiseRawSubtree. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 154 +--- .../AddressSpaceChangeClassifier.cs | 42 +- .../AddressSpacePlan.cs | 37 +- .../OpcUa/OpcUaPublishActor.cs | 20 +- .../SubscriptionSurvivalTests.cs | 87 +- .../AddressSpaceApplierFailureSurfaceTests.cs | 26 +- .../AddressSpaceApplierHierarchyTests.cs | 15 +- .../AddressSpaceApplierTests.cs | 781 ++---------------- .../AddressSpaceChangeClassifierTests.cs | 75 +- .../AddressSpacePlannerTests.cs | 61 -- 10 files changed, 159 insertions(+), 1139 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index ce85a7ff..9d42e409 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -105,23 +105,21 @@ public sealed class AddressSpaceApplier if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; removedCount++; } - // Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write - // before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes - // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4: - // removed raw containers/tags + UNS reference variables are likewise real removals. - removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + + // Removed VirtualTags are plain variable nodes (no Part 9 condition to write before tear-down), but + // they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes is accurate on a + // removed-vtag-only deploy. v3 Batch 4: removed raw containers/tags + UNS reference variables are + // likewise real removals. + removedCount += plan.RemovedEquipmentVirtualTags.Count + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count; var changedCount = plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + - plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentVirtualTags.Count + plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count + // A UNS Area/Line rename is an in-place change to an existing folder node. plan.RenamedFolders.Count; var addedCount = plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + - plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count; @@ -143,7 +141,7 @@ public sealed class AddressSpaceApplier // order even when an id is reused across the remove + add sets (the recreated node is a fresh // NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback. // • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment / - // ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags) + // ChangedAlarms / node-relevant ChangedEquipmentVirtualTags / non-surgical raw-tag deltas) // — 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 @@ -154,7 +152,6 @@ public sealed class AddressSpaceApplier // afterward by the publish actor's Materialise passes + announce). var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix; - var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList(); // UNS Area / Line renames are surgically applicable (in-place DisplayName swap) // when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change), // MaterialiseHierarchy re-creates every folder with the new display names, so the renames are @@ -174,7 +171,7 @@ public sealed class AddressSpaceApplier rebuildFailed = !SafeRebuild(); rebuilt = true; } - else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass || + else if (renamedFolders.Count > 0 || hasRemovePass || surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0) { if (_sink is ISurgicalAddressSpaceSink surgical) @@ -192,25 +189,6 @@ public sealed class AddressSpaceApplier } if (!ok) { allApplied = false; break; } } - foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty()) - { - // Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags - // would so the in-place update matches what a rebuild would have produced. Array tags are - // forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays). - var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name); - var writable = d.Current.Writable && !d.Current.IsArray; - var historian = d.Current.IsHistorized - ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname) - : null; - bool ok; - try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); } - catch (Exception ex) - { - _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId); - ok = false; - } - if (!ok) { allApplied = false; break; } - } // v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE // UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths, // and before this path existed that tore down every node on the server for an edit that creates @@ -318,8 +296,8 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})", - kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, + "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})", + kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt); @@ -340,10 +318,9 @@ public sealed class AddressSpaceApplier /// 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 + /// subtree removal and skipped individually. For surviving-equipment removals: a removed VirtualTag /// variable gets a terminal Bad WriteValue then ; - /// a removed alarm-bearing tag (previously it got no terminal condition write) and a - /// removed scripted alarm get a terminal write then + /// a removed scripted alarm gets 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 @@ -361,25 +338,6 @@ public sealed class AddressSpaceApplier 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 = UnsEquipmentVar(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, AddressSpaceRealm.Uns)) failedNodes++; - if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false; - } - else - { - // Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal. - SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns); - if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false; - } - } - // Removed VirtualTags (always plain value variables) for surviving equipment. foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId))) { @@ -548,7 +506,6 @@ public sealed class AddressSpaceApplier ArgumentNullException.ThrowIfNull(plan); var ids = new HashSet(StringComparer.Ordinal); - foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath)); foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath)); 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 @@ -1072,96 +1029,9 @@ public sealed class AddressSpaceApplier return failed; } - /// - /// Materialise Equipment-namespace tags from a composition snapshot. - /// For each , - /// ensure its optional FolderPath sub-folder under the existing equipment folder, then - /// ensure a Variable (NodeId = FullName, the driver-side ref) inside it. Variables - /// start BadWaitingForInitialData; the driver fills live values in a later milestone. - /// Idempotent. This is a sink-based pass, NOT a reuse of EquipmentNodeWalker: no - /// sink-backed IAddressSpaceBuilder adapter exists, and the walker re-creates the - /// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's - /// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY - /// 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 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, - IReadOnlyList? only = null) - { - ArgumentNullException.ThrowIfNull(composition); - 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 - // (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the - // equipment folder, which MaterialiseHierarchy already created (never re-create - // the equipment folder here). - var foldersCreated = new HashSet(StringComparer.Ordinal); - foreach (var tag in equipmentTags) - { - if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; - var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath); - if (!foldersCreated.Add(folderNodeId)) continue; - if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++; - } - - // Variables: NodeId is FOLDER-SCOPED ("/"), NOT the raw FullName — a driver - // ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId - // would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's - // 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 equipmentTags) - { - var parent = string.IsNullOrWhiteSpace(tag.FolderPath) - ? tag.EquipmentId - : UnsSubFolder(tag.EquipmentId, tag.FolderPath); - var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name); - if (tag.Alarm is not null) - { - // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), - // NOT a value variable. Parent is the sub-folder when set, else the equipment folder. - if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++; - } - else - { - // Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective - // historian tagname HERE (default-vs-override): a null/blank override falls back to the - // driver-side FullName; null means the tag is not historized at all. - string? historianTagname = tag.IsHistorized - ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) - : null; - // Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only - // here. The live array-write capability is carried on RawTagPlan / - // UnsReferenceVariable only — see RawNodeWritable. - var writable = tag.Writable && !tag.IsArray; - if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++; - } - } - - _logger.LogInformation( - "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; - } - - /// /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag - /// analogue of . For each , + /// computed-signal pass. For each , /// ensure its optional FolderPath sub-folder under the existing equipment folder (in /// practice FolderPath is empty for VirtualTags, so this is usually a no-op), then ensure /// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index a64f3055..8d3b97d0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -8,7 +8,7 @@ public enum AddressSpaceChangeKind /// — nothing to do. Empty, - /// Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas, + /// Only surgical-eligible changed raw tags, surgical UNS-reference deltas, /// node-irrelevant vtag changes, folder renames, and/or node-inert driver deltas — the existing F10b /// in-place path. No adds, no removes. AttributeOnly, @@ -27,7 +27,7 @@ public enum AddressSpaceChangeKind AddRemoveMix, /// Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, ChangedRawContainers, - /// non-surgical ChangedEquipmentTags, alarm-bearing/identity-changing ChangedRawTags, node-relevant + /// alarm-bearing/identity-changing ChangedRawTags, node-relevant /// ChangedEquipmentVirtualTags) — the unclassifiable-safe default. Full rebuild. Rebuild, } @@ -53,8 +53,7 @@ public static class AddressSpaceChangeClassifier if (plan.IsEmpty) return AddressSpaceChangeKind.Empty; // 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment / - // ChangedAlarms / ChangedRawContainers are always node-affecting; a changed equipment tag is - // node-affecting UNLESS it is surgical-eligible, a changed raw tag UNLESS it is + // ChangedAlarms / ChangedRawContainers are always node-affecting; a changed raw tag UNLESS it is // RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the // add/remove split so a non-surgical change alongside pure adds still rebuilds. // @@ -94,7 +93,6 @@ public static class AddressSpaceChangeClassifier plan.ChangedAlarms.Count > 0 || plan.ChangedRawContainers.Count > 0 || plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d)) || - plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) || plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d))) { return AddressSpaceChangeKind.Rebuild; @@ -105,12 +103,12 @@ public static class AddressSpaceChangeClassifier // add/remove split below and leave a driver-only plan classified AttributeOnly. var hasAdds = plan.AddedEquipment.Count + plan.AddedAlarms.Count + - plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count + + plan.AddedEquipmentVirtualTags.Count + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count > 0; var hasRemoves = plan.RemovedEquipment.Count + plan.RemovedAlarms.Count + - plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + + plan.RemovedEquipmentVirtualTags.Count + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count > 0; @@ -141,36 +139,6 @@ public static class AddressSpaceChangeClassifier Historize = d.Current.Historize, }).Equals(d.Current); - // F10b: 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, - // avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray / - // ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions - // in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm - // differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different - // driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The - // override-unequal default also covers any future field. - // REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable - // top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array - // edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…) - // TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/ - // array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the - // correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway). - /// Whether a changed equipment tag can be updated in place on its existing node instead of rebuilding. - /// The equipment-tag delta to classify. - /// when the tag has no alarm and differs only in the in-place-updatable attributes; otherwise . - internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) => - d.Previous.Alarm is null && d.Current.Alarm is null && - (d.Previous with - { - Writable = d.Current.Writable, - IsHistorized = d.Current.IsHistorized, - HistorianTagname = d.Current.HistorianTagname, - DataType = d.Current.DataType, - IsArray = d.Current.IsArray, - ArrayLength = d.Current.ArrayLength, - }).Equals(d.Current); - // v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable // attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable) // and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs index 8b6cdd26..466a4a37 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs @@ -23,28 +23,15 @@ public sealed record AddressSpacePlan( IReadOnlyList RemovedAlarms, IReadOnlyList ChangedAlarms) { - /// - /// Equipment-namespace tag diff sets, keyed by . Added as - /// init-only members (defaulting empty) rather than positional parameters so existing - /// AddressSpacePlan construction sites compile unchanged — consistent with how - /// was added. Without these, an - /// incremental deploy that changes ONLY equipment tags produced an empty plan and - /// OpcUaPublishActor.HandleRebuild short-circuited before materialising them. - /// - public IReadOnlyList AddedEquipmentTags { get; init; } = Array.Empty(); - /// - public IReadOnlyList RemovedEquipmentTags { get; init; } = Array.Empty(); - /// - public IReadOnlyList ChangedEquipmentTags { get; init; } = Array.Empty(); - /// /// Equipment-namespace VirtualTag diff sets, keyed by . - /// The value-side analogue of : a VirtualTag carries an + /// A VirtualTag carries an /// Expression evaluated over DependencyRefs, so a deploy that changes ONLY /// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a /// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and - /// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same - /// compile-compatibility reason as . + /// such a deploy silently no-op'd. Added as init-only members (defaulting empty) so existing + /// construction sites compile unchanged. (The retired v3 equipment-tag diff sets — review 01 U2 — are gone: + /// device values live in the Raw subtree and reach equipment as s.) /// public IReadOnlyList AddedEquipmentVirtualTags { get; init; } = Array.Empty(); /// @@ -65,7 +52,7 @@ public sealed record AddressSpacePlan( /// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a /// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so /// every existing AddressSpacePlan construction site compiles unchanged — consistent with - /// the EquipmentTag / EquipmentVirtualTag diff sets above. + /// the EquipmentVirtualTag diff sets above. /// public IReadOnlyList RenamedFolders { get; init; } = Array.Empty(); @@ -74,7 +61,7 @@ public sealed record AddressSpacePlan( /// keyed by (the RawPath). A rename of any container changes /// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId /// is immutable identity. Init-only, defaulting empty, so existing construction sites compile - /// unchanged (consistent with the EquipmentTag diff sets above). + /// unchanged. /// public IReadOnlyList AddedRawContainers { get; init; } = Array.Empty(); /// @@ -114,7 +101,6 @@ public sealed record AddressSpacePlan( AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 && AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 && AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 && - AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 && AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 && AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 && AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 && @@ -124,7 +110,6 @@ public sealed record AddressSpacePlan( public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current); public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current); public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current); - public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current); public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current); /// A Raw container node present in both snapshots (same RawPath) with ≥1 differing field. @@ -177,12 +162,7 @@ public static class AddressSpacePlanner a => a.ScriptedAlarmId, (a, b) => new AddressSpacePlan.AlarmDelta(a, b)); - var (addedEqTags, removedEqTags, changedEqTags) = DiffById( - previous.EquipmentTags, next.EquipmentTags, - t => t.TagId, - (a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b)); - - // VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan + // VirtualTags diff by VirtualTagId. EquipmentVirtualTagPlan // overrides record equality to compare ALL fields by value — scalars (Expression/DataType/ // Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh // list instances, identical contents) correctly diffs to empty; only a real content change is @@ -232,9 +212,6 @@ public static class AddressSpacePlanner addedDrv, removedDrv, changedDrv, addedAlarm, removedAlarm, changedAlarm) { - AddedEquipmentTags = addedEqTags, - RemovedEquipmentTags = removedEqTags, - ChangedEquipmentTags = changedEqTags, AddedEquipmentVirtualTags = addedVTags, RemovedEquipmentVirtualTags = removedVTags, ChangedEquipmentVirtualTags = changedVTags, 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 68c23d4d..207585c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -725,8 +725,8 @@ 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 + /// Run the four node-materialising applier passes — Raw subtree, UNS references, scripted alarms, + /// equipment VirtualTags — either over the FULL composition (after a rebuild) or over /// just this plan's delta (surgical deploys). MaterialiseHierarchy is NOT one of them: /// the caller always runs it in full, because parents must exist before any add. /// @@ -809,21 +809,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers 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: + // Equipment-namespace VirtualTags get their own pass right after the scripted alarms: // 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. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs index b8701f69..31b597ef 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs @@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// announce (T4b) land. /// Run note: this is a heavy in-process server+client integration test — run it in the /// serial integration pass (it is NOT part of the lightweight unit sweep). The -/// namespace URI is ; the client resolves the +/// namespace URI is ; the client resolves the /// folder-scoped variable NodeIds against it. /// public sealed class SubscriptionSurvivalTests @@ -68,15 +68,15 @@ public sealed class SubscriptionSurvivalTests // --- Materialise tag A (equipment eq-1, tag "A") and seed it a Good value. --- var withA = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null)); + RawTag("tag-a", "A")); applier.MaterialiseHierarchy(withA); - applier.MaterialiseEquipmentTags(withA); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withA); + var nodeIdAString = "Plant/dev1/A"; + sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); // --- Open a client session + subscription + monitored item on A. --- using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); ns.ShouldBeGreaterThan((ushort)0); var nodeIdA = new NodeId(nodeIdAString, ns); @@ -106,22 +106,22 @@ public sealed class SubscriptionSurvivalTests // --- Drive a PURE-ADD apply adding tag B (previous={A}, next={A,B}). --- var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); var plan = AddressSpacePlanner.Compute(withA, withAB); - plan.AddedEquipmentTags.Count.ShouldBe(1); + plan.AddedRawTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown // Materialise passes (as the publish actor runs them) then announce — over the FULL composition. applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); + applier.MaterialiseRawSubtree(withAB); applier.AnnounceAddedNodes(plan); // --- A's monitored item must still be alive: a new write to A is delivered. --- lock (gate) received.Clear(); - sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5)); lock (gate) @@ -131,8 +131,8 @@ public sealed class SubscriptionSurvivalTests } // --- B is browsable + readable (the add landed). --- - var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns); - sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var nodeIdB = new NodeId("Plant/dev1/B", ns); + sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var bValue = await session.ReadValueAsync(nodeIdB, ct); bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown); bValue.Value.ShouldBe(7); @@ -180,17 +180,17 @@ public sealed class SubscriptionSurvivalTests // Materialise A + B and seed both Good. var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - var nodeIdBString = V3NodeIds.Uns("eq-1", "B"); - sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withAB); + var nodeIdAString = "Plant/dev1/A"; + var nodeIdBString = "Plant/dev1/B"; + sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var nodeIdA = new NodeId(nodeIdAString, ns); var nodeIdB = new NodeId(nodeIdBString, ns); @@ -211,9 +211,9 @@ public sealed class SubscriptionSurvivalTests // Drive a PURE-REMOVE apply removing B (previous={A,B}, next={A}). var withA = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null)); + RawTag("tag-a", "A")); var plan = AddressSpacePlanner.Compute(withAB, withA); - plan.RemovedEquipmentTags.Count.ShouldBe(1); + plan.RemovedRawTags.Count.ShouldBe(1); lock (gate) { receivedA.Clear(); receivedB.Clear(); } var outcome = applier.Apply(plan); @@ -223,7 +223,7 @@ public sealed class SubscriptionSurvivalTests await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5)); // A stays alive: a fresh write to A is delivered. - sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); // B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path @@ -273,15 +273,15 @@ public sealed class SubscriptionSurvivalTests var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withAB); + var nodeIdAString = "Plant/dev1/A"; + sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var nodeIdA = new NodeId(nodeIdAString, ns); var receivedA = new List(); @@ -297,32 +297,32 @@ public sealed class SubscriptionSurvivalTests // MIXED apply: remove B, add C (previous={A,B}, next={A,C}). var withAC = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-c", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Int32", FullName: "3", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-c", "C")); var plan = AddressSpacePlanner.Compute(withAB, withAC); - plan.RemovedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.Count.ShouldBe(1); + plan.RemovedRawTags.Count.ShouldBe(1); + plan.AddedRawTags.Count.ShouldBe(1); lock (gate) receivedA.Clear(); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); // AddRemoveMix — removes in place, no rebuild // Publish-actor sequence: removes ran inside Apply; now materialise the adds + announce. - applier.MaterialiseEquipmentTags(withAC); + applier.MaterialiseRawSubtree(withAC); applier.AnnounceAddedNodes(plan); // A stays alive. - sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); // B is gone. (1.5.378 node-cache read throws for an unknown node — see above.) - var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns); + var nodeIdB = new NodeId("Plant/dev1/B", ns); var bReadEx = await Should.ThrowAsync( async () => await session.ReadValueAsync(nodeIdB, ct)); bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown); // C is browsable + readable. - var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns); - sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var nodeIdC = new NodeId("Plant/dev1/C", ns); + sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var cRead = await session.ReadValueAsync(nodeIdC, ct); cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown); cRead.Value.ShouldBe(9); @@ -333,7 +333,11 @@ public sealed class SubscriptionSurvivalTests } } - private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) => + private static RawTagPlan RawTag(string tagId, string name) => + new(tagId, $"Plant/dev1/{name}", ParentNodeId: "Plant/dev1", "drv", Name: name, DataType: "Int32", Writable: false, + Alarm: null, ReferencingEquipmentPaths: Array.Empty()); + + private static AddressSpaceComposition CompositionWith(params RawTagPlan[] tags) => new( UnsAreas: Array.Empty(), UnsLines: Array.Empty(), @@ -341,7 +345,8 @@ public sealed class SubscriptionSurvivalTests DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { - EquipmentTags = tags, + RawContainers = new[] { new RawContainerNode("Plant/dev1", null, "dev1", RawNodeKind.Device) }, + RawTags = tags, }; private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index a94b824b..1282a731 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -75,30 +75,26 @@ public sealed class AddressSpaceApplierFailureSurfaceTests // ---------------- T3: Materialise* passes return failed-node counts ---------------- - /// Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return. + /// Every EnsureVariable throwing in MaterialiseRawSubtree is tallied into the pass's int return. [Fact] - public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount() + public void MaterialiseRawSubtree_WhenEnsureVariableThrows_ReturnsFailedCount() { var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var composition = CompositionWithTags( - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Float", FullName: "40003", Writable: false, Alarm: null)); + var composition = CompositionWithRawTags(RawTag("t1", "A"), RawTag("t2", "B"), RawTag("t3", "C")); - applier.MaterialiseEquipmentTags(composition).ShouldBe(3); + applier.MaterialiseRawSubtree(composition).ShouldBe(3); } - /// All five Materialise* passes return 0 failures on a clean sink — the happy-path regression. + /// All four Materialise* passes return 0 failures on a clean sink — the happy-path regression. [Fact] public void Materialise_HappyPath_AllPassesReturnZero() { var sink = new ConfigurableThrowingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var tags = CompositionWithTags( - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); + var tags = CompositionWithRawTags(RawTag("t1", "A")); var vtags = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("v1", "eq-1", FolderPath: "", Name: "VA", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty(), Historize: false)); var alarms = CompositionWithScriptedAlarms( @@ -106,17 +102,21 @@ public sealed class AddressSpaceApplierFailureSurfaceTests Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true)); applier.MaterialiseHierarchy(tags).ShouldBe(0); - applier.MaterialiseEquipmentTags(tags).ShouldBe(0); + applier.MaterialiseRawSubtree(tags).ShouldBe(0); applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0); applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0); } // ---------------- fixtures ---------------- - private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) => + private static RawTagPlan RawTag(string id, string name) => + new(id, $"Plant/dev1/{name}", ParentNodeId: null, "drv", Name: name, DataType: "Float", Writable: false, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()); + + private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) => new(Array.Empty(), Array.Empty(), Array.Empty()) { - EquipmentTags = tags, + RawTags = tags, }; private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs index 39d26056..4528812e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -110,13 +110,13 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable sdkServer.NodeManager!.FolderCount.ShouldBe(5); } - /// Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager: + /// Verifies MaterialiseRawSubtree is idempotent against a real SDK node manager: /// applying the same composition twice yields a single Variable node (no duplicates). This is the /// restart-safety guarantee — HandleRebuild runs on both the apply path and the /// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node /// restart re-runs this pass and must not double-materialise. [Fact] - public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent() + public async Task MaterialiseRawSubtree_against_real_SDK_node_manager_is_idempotent() { var applicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( @@ -145,16 +145,17 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { - EquipmentTags = new[] + RawTags = new[] { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), + new RawTagPlan("tag-1", "Plant/dev1/Speed", ParentNodeId: null, "drv", Name: "Speed", DataType: "Float", + Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty()), }, }; - // Equipment folder first (the variable's parent), then the tag pass — applied twice. + // The raw pass — applied twice. applier.MaterialiseHierarchy(composition); - applier.MaterialiseEquipmentTags(composition); - applier.MaterialiseEquipmentTags(composition); + applier.MaterialiseRawSubtree(composition); + applier.MaterialiseRawSubtree(composition); sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply } 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 f3e61370..2a60cf29 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -12,30 +12,6 @@ public sealed class AddressSpaceApplierTests - /// R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier - /// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions - /// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.) - [Fact] - public void Added_equipment_tags_are_pure_add_and_skip_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - outcome.AddedNodes.ShouldBe(1); - sink.RebuildCalls.ShouldBe(0); - } - /// R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier /// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒ /// rebuild" pin.) @@ -61,27 +37,22 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(0); } - /// R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on - /// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The - /// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is - /// surgical-eligible); the applier still runs the F10b surgical pass. + /// R2-07 T11 (raw-tag vehicle, review 01 U2 retired the equipment-tag one) — a remove-one-raw-tag + /// plan is a PureRemove: the applier writes a terminal Bad to the removed variable then removes it in place + /// via RemoveVariableNode in the RAW realm — NO rebuild. An alarm-bearing raw tag gets the terminal + /// RemovedConditionState and a condition-node removal instead. [Fact] - public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place() + public void Removed_raw_tags_write_terminal_state_and_remove_in_place_without_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var plan = EmptyPlan with { - AddedEquipmentTags = new[] + RemovedRawTags = new[] { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - ChangedEquipmentTags = new[] - { - new AddressSpacePlan.EquipmentTagDelta( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)), + RawTag("tag-1", "Plant/dev1/Speed"), + RawTag("tag-alm", "Plant/dev1/OverTemp", alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), }, }; @@ -89,125 +60,16 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.Writable.ShouldBeTrue(); - } - - /// R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node - /// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is - /// preserved through the classifier routing). - [Fact] - public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild() - { - var sink = new RecordingSink { SurgicalReturns = false }; - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - ChangedEquipmentTags = new[] - { - new AddressSpacePlan.EquipmentTagDelta( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); // fell back to a full rebuild - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first - } - - /// 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_tag_writes_terminal_bad_and_removes_variable_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - var nodeId = V3NodeIds.Uns("eq-1", "Speed"); - // Terminal Bad written to the removed variable BEFORE the removal. - sink.ValueWrites.ShouldContain((nodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns)); - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns)); - } - - /// 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 = V3NodeIds.Uns("eq-1", "OverTemp"); - // Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node. + sink.ValueWrites.ShouldContain(("Plant/dev1/Speed", OpcUaStatus.Bad, AddressSpaceRealm.Raw)); var write = sink.AlarmWrites.ShouldHaveSingleItem(); - write.NodeId.ShouldBe(nodeId); + write.NodeId.ShouldBe("Plant/dev1/OverTemp"); (!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, AddressSpaceRealm.Uns)); - 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 + sink.RemoveCalls.ShouldBe(new[] { - 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", AddressSpaceRealm.Uns)); + ("var", "Plant/dev1/Speed", AddressSpaceRealm.Raw), + ("alarm", "Plant/dev1/OverTemp", AddressSpaceRealm.Raw), + }); + outcome.RemovedNodes.ShouldBe(2); } /// R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier @@ -220,11 +82,7 @@ public sealed class AddressSpaceApplierTests 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), - }, + RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A"), RawTag("tag-2", "Plant/dev1/B") }, }; var outcome = applier.Apply(plan); @@ -243,13 +101,7 @@ public sealed class AddressSpaceApplierTests 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 plan = EmptyPlan with { RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A") } }; var outcome = applier.Apply(plan); @@ -259,7 +111,7 @@ public sealed class AddressSpaceApplierTests /// R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE /// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's - /// passes. (Supersedes the T2 phase pin.) + /// passes. [Fact] public void Add_and_remove_is_mixed_removes_in_place_without_rebuild() { @@ -268,14 +120,8 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, + AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") }, + RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") }, }; var outcome = applier.Apply(plan); @@ -283,40 +129,11 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); // The removed node is torn down in place; the add is left to the publish actor's materialise passes. - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old"), AddressSpaceRealm.Uns)); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", "Plant/dev1/Old", AddressSpaceRealm.Raw)); outcome.AddedNodes.ShouldBe(1); outcome.RemovedNodes.ShouldBe(1); } - /// R2-07 T13 — an id REUSED across the remove + add sets in one deploy (a tag removed at the - /// same folder-scoped NodeId another tag is added at): the applier removes the old node in place (the - /// recreate is the publish actor's job, a fresh NodeState). No rebuild. - [Fact] - public void Add_and_remove_reusing_same_node_id_removes_the_old_node_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var nodeId = V3NodeIds.Uns("eq-1", "Slot"); - var plan = EmptyPlan with - { - // Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot). - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Int32", FullName: "40002", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns)); - } - /// R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet. [Fact] public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild() @@ -326,14 +143,8 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, + AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") }, + RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") }, }; var outcome = applier.Apply(plan); @@ -342,38 +153,28 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(1); } - /// H1a — a deploy that ONLY changes an existing equipment tag in a NON-surgical way (here the - /// driver-side FullName re-routes to a different point) must rebuild the address space. The planner - /// diffs the tag into ChangedEquipmentTags with no Added/Removed of anything else; the applier must - /// still drive exactly one rebuild so the running server drops the stale node and re-materialises it. - /// (Surgically-applicable tag changes — Writable/Historizing/DataType/array-shape — take the in-place path - /// instead; those are covered by the F10b + FB-7 surgical tests.) + /// 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 Changed_equipment_tags_only_trigger_rebuild() + public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // Same tag id, but the driver-side FullName flipped — a non-surgical change, so the applier rebuilds. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: ONLY ChangedEquipmentTags is populated. - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.AddedEquipment.ShouldBeEmpty(); - plan.RemovedEquipment.ShouldBeEmpty(); + var plan = EmptyPlan with + { + RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") }, + 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.ShouldBeTrue(); - outcome.ChangedNodes.ShouldBe(1); - sink.RebuildCalls.ShouldBe(1); + outcome.RebuildCalled.ShouldBeFalse(); + // ONLY the subtree removal fires — no individual var removal for the subsumed child vtag. + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns)); } /// F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's Expression @@ -553,26 +354,18 @@ public sealed class AddressSpaceApplierTests var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) + new[] { new EquipmentNode("eq-1", "One", "line-1") }, Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }), }, }; - // Expression-only vtag edit (node-irrelevant) AND a non-surgical tag change (FullName re-route). + // Expression-only vtag edit (node-irrelevant) AND a node-affecting equipment change (rename). var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) + new[] { new EquipmentNode("eq-1", "One renamed", "line-1") }, Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", @@ -582,9 +375,9 @@ public sealed class AddressSpaceApplierTests var plan = AddressSpacePlanner.Compute(previous, next); - // Both a node-irrelevant vtag change AND a node-affecting tag change are present. + // Both a node-irrelevant vtag change AND a node-affecting equipment change are present. plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); - plan.ChangedEquipmentTags.Count.ShouldBe(1); + plan.ChangedEquipment.Count.ShouldBe(1); var outcome = applier.Apply(plan); @@ -670,7 +463,6 @@ public sealed class AddressSpaceApplierTests plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); var outcome = applier.Apply(plan); @@ -732,7 +524,6 @@ public sealed class AddressSpaceApplierTests plan.RemovedDrivers.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); plan.ChangedAlarms.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipmentVirtualTags.ShouldBeEmpty(); var outcome = applier.Apply(plan); @@ -748,7 +539,7 @@ public sealed class AddressSpaceApplierTests /// to removedCount — AddressSpaceApplyOutcome.RemovedNodes reported 0, a misleading audit /// entry. This pins both the rebuild and the accurate count. [Fact] - public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted() + public void Removed_virtual_tags_only_remove_in_place_and_are_counted() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); @@ -756,10 +547,6 @@ public sealed class AddressSpaceApplierTests var previous = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", @@ -771,440 +558,23 @@ public sealed class AddressSpaceApplierTests var plan = AddressSpacePlanner.Compute(previous, next); - // Guard the arrange: ONLY the two Removed sets are populated. - plan.RemovedEquipmentTags.Count.ShouldBe(1); + // Guard the arrange: ONLY the removed-vtag set is populated. plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.RemovedEquipment.ShouldBeEmpty(); plan.RemovedAlarms.ShouldBeEmpty(); var outcome = applier.Apply(plan); - // 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. + // R2-07 T11 — a removed-vtag plan is a PureRemove: the variable is torn down in place + // (RemoveVariableNode), NO full rebuild; the removal is 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", V3NodeIds.Uns("eq-1", "Speed"), AddressSpaceRealm.Uns)); - sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns)); + outcome.RemovedNodes.ShouldBe(1); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns)); } // ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) ----- - /// F10b — a deploy that ONLY flips an existing equipment tag's Writable bit (a plain, - /// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change - /// IN PLACE via ISurgicalAddressSpaceSink.UpdateTagAttributes, preserving every client's - /// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as - /// a change (ChangedNodes == 1). - [Fact] - public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // Same TagId/identity; only Writable flips false → true. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.Writable.ShouldBeTrue(); // the NEW Writable value - call.Historian.ShouldBeNull(); // not historized - outcome.ChangedNodes.ShouldBe(1); - } - - /// F10b — flipping IsHistorized false → true (no override) updates in place with the - /// historian tagname defaulting to FullName; flipping true → false updates in place with a null - /// historian tagname. Both skip the rebuild. - [Fact] - public void Changed_tag_is_historized_toggle_skips_rebuild_and_resolves_historian() - { - // false → true (no override) ⇒ historian defaults to FullName. - var sinkOn = new RecordingSink(); - var applierOn = new AddressSpaceApplier(sinkOn, NullLogger.Instance); - var planOn = AddressSpacePlanner.Compute( - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)), - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null))); - planOn.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcomeOn = applierOn.Apply(planOn); - - outcomeOn.RebuildCalled.ShouldBeFalse(); - sinkOn.RebuildCalls.ShouldBe(0); - sinkOn.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("T.A"); // default ⇒ FullName - - // true → false ⇒ historian null. - var sinkOff = new RecordingSink(); - var applierOff = new AddressSpaceApplier(sinkOff, NullLogger.Instance); - var planOff = AddressSpacePlanner.Compute( - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)), - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null))); - planOff.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcomeOff = applierOff.Apply(planOff); - - outcomeOff.RebuildCalled.ShouldBeFalse(); - sinkOff.RebuildCalls.ShouldBe(0); - sinkOff.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBeNull(); // not historized ⇒ null - } - - /// F10b — changing ONLY the HistorianTagname override on an already-historized tag - /// skips the rebuild and updates in place, passing the NEW override verbatim. - [Fact] - public void Changed_tag_historian_tagname_only_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Old")); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.New")); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - sink.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("WW.New"); // override verbatim - } - - /// FB-7 — a tag delta whose DataType changed is now surgical-eligible: the sink swaps the - /// node's DataType in place (and raises a GeneralModelChangeEvent), so the applier SKIPS the rebuild and - /// makes exactly one surgical call carrying the NEW DataType. Here Writable also flips, which the same - /// in-place update applies. Subscriptions are preserved. - [Fact] - public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // DataType flips Float → Int32 AND Writable flips false → true — both are now surgically applied. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.DataType.ShouldBe("Int32"); // the NEW DataType - call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call - call.IsArray.ShouldBeFalse(); - outcome.ChangedNodes.ShouldBe(1); - } - - /// FB-7 — a tag delta whose IsArray flag flips scalar → array is now surgical-eligible: - /// the sink swaps ValueRank + ArrayDimensions in place, so the applier skips the rebuild and the surgical - /// call carries the new array shape. An array tag is forced read-only (matching EnsureVariable), so the - /// surgical Writable is false even though the tag stays non-writable here. - [Fact] - public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.IsArray.ShouldBeTrue(); // the NEW array shape - call.ArrayLength.ShouldBe(16u); - call.DataType.ShouldBe("Int16"); // element type unchanged - call.Writable.ShouldBeFalse(); // array tag forced read-only - } - - /// F10b safe-default — a tag delta whose driver-side FullName changed is NOT - /// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds. - [Fact] - public void Changed_tag_full_name_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b safe-default — a tag delta whose Name changed is NOT surgical-eligible (the - /// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds. - [Fact] - public void Changed_tag_name_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "SpeedRpm", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b safe-default — a tag delta where an alarm appears (Alarm null → non-null) is NOT - /// surgical-eligible: the tag flips from a plain value variable to a Part 9 condition node, which only - /// a rebuild can materialise. No surgical call is made. - [Fact] - public void Changed_tag_alarm_presence_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, - Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700))); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b guard — a tag that ALREADY carries an alarm (Alarm is not null on BOTH previous - /// and current, identical alarm info) is NOT surgical-eligible even when only Writable changes. - /// The explicit Alarm is null guard in TagDeltaIsSurgicalEligible prevents a false - /// positive: without it the with { Writable = … } override (which does NOT touch Alarm) - /// would leave both sides with equal Alarm values and the delta would WRONGLY look surgical. - /// An alarm-bearing tag is a Part 9 condition node, not a plain value variable, so any change to it - /// must go through a full rebuild. - [Fact] - public void Changed_alarm_bearing_tag_writable_only_still_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Both previous and current carry an identical, non-null alarm — the tag is an alarm-bearing node. - var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700); - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", - FullName: "00001", Writable: false, Alarm: alarm)); - // Only Writable flips; alarm is unchanged (and still non-null on both sides). - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", - FullName: "00001", Writable: true, Alarm: alarm)); - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: the planner sees a changed tag (Writable differs) and nothing else. - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - // The alarm-bearing tag is NOT surgical-eligible — it must rebuild and make NO surgical call. - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); // NO surgical write on an alarm-bearing tag - } - - /// F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain - /// value variables, no alarm, both changing only Writable), the applier must apply BOTH in - /// place (two UpdateTagAttributes calls) without triggering any rebuild. Proves the surgical - /// path iterates all eligible deltas rather than stopping after the first. - [Fact] - public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible. - var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - }; - // Only Writable flips on both tags; everything else is identical. - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: true, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: exactly two changed tags, nothing else. - plan.ChangedEquipmentTags.Count.ShouldBe(2); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.AddedEquipment.ShouldBeEmpty(); - plan.RemovedEquipment.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - // No rebuild — both deltas are surgical-eligible. - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - - // Exactly two surgical calls — one per eligible delta. - sink.SurgicalCalls.Count.ShouldBe(2); - - // Both expected node ids must appear (order is not guaranteed). - var nodeId1 = V3NodeIds.Uns("eq-1", "Speed"); - var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure"); - sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable); - sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable); - - outcome.ChangedNodes.ShouldBe(2); - } - - /// R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd: - /// the added equipment is created by the Materialise passes and the surgical tag change is applied IN - /// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07 - /// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.) - [Fact] - public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - // Surgical-eligible Writable flip on the tag AND a brand-new equipment node. - var next = new AddressSpaceComposition( - new[] { new EquipmentNode("eq-new", "New", "line-1") }, Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipment.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue(); - } - - /// F10b fallback — a sink that does NOT implement cannot - /// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full - /// rebuild (safe default). - [Fact] - public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds() - { - var sink = new PlainRecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - } - - /// F10b fallback — when the surgical sink reports the node MISSING - /// (UpdateTagAttributes returns false), the applier falls back to a full rebuild. The surgical - /// call is still attempted (recorded once) before the fallback fires. - [Fact] - public void Surgical_sink_returning_false_node_missing_falls_back_to_rebuild() - { - var sink = new RecordingSink { SurgicalReturns = false }; - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild - sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first - } - // ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) ----- /// OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and @@ -1337,42 +707,6 @@ public sealed class AddressSpaceApplierTests // ----- R2-07 T3: pure-add NodeAdded announcements ----- - /// Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the - /// equipment id. - [Fact] - public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one() - { - var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), - }, - }; - - applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" }); - } - - /// A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment. - [Fact] - public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder() - { - var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - }, - }; - - applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") }); - } - /// An added scripted alarm announces its equipment folder (where its condition node parents). [Fact] public void ComputeAddAnnouncements_added_alarm_announces_equipment() @@ -1445,11 +779,11 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") }, - AddedEquipmentTags = new[] + AddedEquipmentVirtualTags = new[] { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), - new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null), + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "A", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty()), + new EquipmentVirtualTagPlan("vt-2", "eq-1", FolderPath: "", Name: "B", DataType: "Float", Expression: "2", DependencyRefs: Array.Empty()), + new EquipmentVirtualTagPlan("vt-3", "eq-1", FolderPath: "Diag", Name: "C", DataType: "Float", Expression: "3", DependencyRefs: Array.Empty()), }, }; @@ -1855,13 +1189,6 @@ public sealed class AddressSpaceApplierTests Array.Empty(), lines, Array.Empty(), Array.Empty(), Array.Empty()); - private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) => - new( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = tags, - }; - private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => new( Array.Empty(), Array.Empty(), Array.Empty()) @@ -1878,6 +1205,10 @@ public sealed class AddressSpaceApplierTests new( Array.Empty(), drivers, Array.Empty()); + private static RawTagPlan RawTag(string tagId, string rawPath, EquipmentTagAlarmInfo? alarm = null) => + new(tagId, rawPath, ParentNodeId: null, "drv", Name: rawPath[(rawPath.LastIndexOf('/') + 1)..], DataType: "Float", + Writable: false, Alarm: alarm, ReferencingEquipmentPaths: Array.Empty()); + private static AddressSpacePlan EmptyPlan => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs index 24507eb6..ad32ead8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs @@ -35,7 +35,7 @@ public sealed class AddressSpaceChangeClassifierTests } [Fact] - public void Added_equipment_tag_only_is_PureAdd() + public void Added_raw_tag_only_is_PureAdd() { var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); @@ -65,7 +65,7 @@ public sealed class AddressSpaceChangeClassifierTests } [Fact] - public void Removed_equipment_tag_only_is_PureRemove() + public void Removed_raw_tag_only_is_PureRemove() { var plan = Plan(removedTags: new[] { Tag("tag-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); @@ -116,19 +116,6 @@ public sealed class AddressSpaceChangeClassifierTests AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } - [Fact] - public void Non_surgical_changed_tag_is_Rebuild() - { - // FullName re-routes the node to a different driver point ⇒ NOT surgical-eligible ⇒ Rebuild. - var plan = Plan(changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", fullName: "40001"), - Tag("tag-1", "eq-1", fullName: "40002")), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); - } - [Fact] public void Node_relevant_changed_vtag_is_Rebuild() { @@ -144,19 +131,6 @@ public sealed class AddressSpaceChangeClassifierTests // ----- surgical-only / rename-only / driver-only ⇒ AttributeOnly ----- - [Fact] - public void Surgical_eligible_changed_tag_only_is_AttributeOnly() - { - // Only Writable flips ⇒ surgical-eligible ⇒ AttributeOnly (no adds, no removes). - var plan = Plan(changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", writable: false), - Tag("tag-1", "eq-1", writable: true)), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); - } - [Fact] public void Node_irrelevant_changed_vtag_only_is_AttributeOnly() { @@ -195,36 +169,6 @@ public sealed class AddressSpaceChangeClassifierTests // ----- mixed-with-surgical: adds still win over coincident surgical changes ----- - [Fact] - public void Adds_with_coincident_surgical_change_is_PureAdd() - { - var plan = Plan( - addedTags: new[] { Tag("tag-new", "eq-1") }, - changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", writable: false), - Tag("tag-1", "eq-1", writable: true)), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); - } - - [Fact] - public void Adds_with_coincident_non_surgical_change_is_Rebuild() - { - // Rule 2 (node-affecting change) is evaluated BEFORE the add/remove split, so a non-surgical - // change forces Rebuild even alongside pure adds. - var plan = Plan( - addedTags: new[] { Tag("tag-new", "eq-1") }, - changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", fullName: "40001"), - Tag("tag-1", "eq-1", fullName: "40002")), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); - } - [Fact] public void Adds_with_coincident_folder_rename_is_PureAdd() { @@ -240,8 +184,9 @@ public sealed class AddressSpaceChangeClassifierTests private static ScriptedAlarmPlan Alarm(string id) => new(id, "eq-1", "scr-1", "msg"); - private static EquipmentTagPlan Tag(string id, string equipmentId, string fullName = "40001", bool writable = false) => - new(id, equipmentId, "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: fullName, Writable: writable, Alarm: null); + private static RawTagPlan Tag(string id, string device) => + new(id, $"{device}/{id}", ParentNodeId: device, "drv", Name: id, DataType: "Float", Writable: false, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()); private static EquipmentVirtualTagPlan Vtag(string id, string equipmentId, string name = "Efficiency", string expression = "ctx.GetTag(\"a\")") => new(id, equipmentId, FolderPath: "", Name: name, DataType: "Float64", Expression: expression, DependencyRefs: new[] { "a" }); @@ -256,9 +201,8 @@ public sealed class AddressSpaceChangeClassifierTests IReadOnlyList? addedAlarms = null, IReadOnlyList? removedAlarms = null, IReadOnlyList? changedAlarms = null, - IReadOnlyList? addedTags = null, - IReadOnlyList? removedTags = null, - IReadOnlyList? changedTags = null, + IReadOnlyList? addedTags = null, + IReadOnlyList? removedTags = null, IReadOnlyList? addedVtags = null, IReadOnlyList? removedVtags = null, IReadOnlyList? changedVtags = null, @@ -274,9 +218,8 @@ public sealed class AddressSpaceChangeClassifierTests removedAlarms ?? Array.Empty(), changedAlarms ?? Array.Empty()) { - AddedEquipmentTags = addedTags ?? Array.Empty(), - RemovedEquipmentTags = removedTags ?? Array.Empty(), - ChangedEquipmentTags = changedTags ?? Array.Empty(), + AddedRawTags = addedTags ?? Array.Empty(), + RemovedRawTags = removedTags ?? Array.Empty(), AddedEquipmentVirtualTags = addedVtags ?? Array.Empty(), RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty(), ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs index 7f01b9b3..227dba03 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs @@ -30,31 +30,6 @@ public sealed class AddressSpacePlannerTests plan.IsEmpty.ShouldBeTrue(); } - /// Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change) - /// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the - /// IsEmpty gate before materialising the new equipment variables. - [Fact] - public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag() - { - var prev = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()); - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(prev, next); - - plan.IsEmpty.ShouldBeFalse(); - plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1"); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); - } - /// Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change) /// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that /// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate. @@ -174,42 +149,6 @@ public sealed class AddressSpacePlannerTests plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); } - /// An equipment Tag with the same id but a toggled IsArray flag (and otherwise - /// identical fields) must route to ChangedEquipmentTags. This pins that IsArray is part of - /// (record value-equality) so an array-flag-only deploy is not - /// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag. - [Fact] - public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags() - { - var prev = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16), - }, - }; - - var plan = AddressSpacePlanner.Compute(prev, next); - - plan.IsEmpty.ShouldBeFalse(); - plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse(); - plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue(); - plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - } - /// Regression guard for structural equality on : /// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan /// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with From deaa31479bacd0f254f8411976b5eb3fe8b0c509 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:36:34 -0400 Subject: [PATCH 06/13] perf(runtime): parse + plan a deploy off the pinned OPC UA thread (B6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 02 P5. OpcUaPublishActor ran the whole apply — DeploymentArtifact .Open, ParseComposition, AddressSpacePlanner.Compute and the sink materialisation — on the opcua-synchronized-dispatcher, so a large artifact serialised every live value batch (and, because the node manager lock is the SDK's, every client Publish) behind the parse. StartRebuild now runs Open + ParseDataPlaneAuthority + ParseComposition + Compute in Task.Run and pipes a PlanReady (or PlanFailed) back to Self; only ApplyPlan — the sink passes — runs on the actor thread. Rebuilds are applied strictly in arrival order (a plan is a diff against _lastApplied, which only the apply advances): one in flight, later ones queue in _pendingRebuilds while value/status batches keep flowing. The apply is closed (CompleteAddressSpaceApply) and the RebuildCompleted reply sent on every path, PlanFailed included. The bytes-less ConfigDb fallback (LoadArtifact / LoadLatestArtifact) is deleted: it had no production producer and would have blocked the pinned thread on central SQL; the dbFactory Props/ctor parameter is kept for wiring compatibility (F4). Tests: OpcUaPublishActorOffThreadPlanTests — a value batch sent right after a 50k-tag rebuild lands before that rebuild's first materialise call (positive control: all 50k tags still materialise); back-to-back rebuilds apply in arrival order (the second diffs against the first); unreadable bytes answer degraded with nothing torn down. The rebuild / apply-failure suites now hand the actor the artifact bytes every production producer carries (SealArtifact records them; Rebuild(dep)). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OpcUa/OpcUaPublishActor.cs | 328 +++++++++--------- .../OpcUaPublishActorApplyFailureTests.cs | 14 +- .../OpcUaPublishActorOffThreadPlanTests.cs | 128 +++++++ .../OpcUa/OpcUaPublishActorRebuildTests.cs | 145 +++----- 4 files changed, 360 insertions(+), 255 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs 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 207585c9..cbe96919 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -86,19 +86,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// The namespace realm the condition lives in ( for native). public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm); /// - /// Triggers an address-space rebuild. is the deployment - /// just applied by the host; the rebuild loads THAT artifact so materialisation matches the - /// applied config + the SubscribeBulk pass. It is null only for legacy/dev callers, which - /// fall back to the latest sealed deployment (lags a not-yet-sealed apply by one revision). + /// Triggers an address-space rebuild of the artifact carried in . + /// is the deployment just applied by the host (echoed in the reply and + /// the logs). Review 02 P5: the ConfigDb fallback for a message carrying no bytes is gone — it had no + /// production producer and would have blocked the pinned OPC UA thread on central SQL — so a message + /// without bytes is "no answer" and holds the served address space. /// /// Correlation id for tracing this rebuild. - /// The applied deployment whose artifact to materialise, or null for the latest-sealed fallback. + /// The applied deployment whose artifact this is, or null for a legacy caller. /// - /// The artifact bytes already in hand, used INSTEAD of loading them from the ConfigDb. This is - /// what makes boot-from-cache actually serve its address space: on a central-SQL outage the - /// host has the cached blob but alone would drive a ConfigDb read - /// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null - /// on the normal path, where loading from the ConfigDb by id is correct. + /// The artifact bytes. Every producer (the deploy path, boot-from-cache, FetchAndCache) has them in + /// hand; null or empty is treated as no answer. /// /// /// when , the actor replies to the sender with a @@ -128,11 +126,29 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers public sealed record ServiceLevelChanged(byte ServiceLevel); + /// + /// Review 02 P5 — a rebuild whose artifact parse, cluster-scoped composition and plan were computed + /// OFF the pinned OPC UA thread (a Task.Run piped back to Self), ready for the sink passes, + /// which are the only part that must run on this actor's thread. Carries the and + /// the original so the reply reaches whoever asked. + /// + private sealed record PlanReady( + RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact, DataPlaneAuthorityProjection? Authority, + AddressSpaceComposition Composition, AddressSpacePlan Plan, IReadOnlyList Inconsistencies); + + /// The off-thread parse / plan step threw (unreadable bytes, a composer fault). + private sealed record PlanFailed(RebuildAddressSpace Request, IActorRef Requester, Exception Error); + + /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in + /// arrival order — the plan is a diff against , which only the apply advances — so a + /// rebuild arriving meanwhile waits in ; value and status batches do not. + private bool _planInFlight; + private readonly Queue<(RebuildAddressSpace Request, IActorRef Requester)> _pendingRebuilds = new(); + private readonly IOpcUaAddressSpaceSink _sink; private readonly IServiceLevelPublisher _serviceLevel; private readonly bool _subscribeRedundancyTopic; private readonly NodeId? _localNode; - private readonly IDbContextFactory? _dbFactory; private readonly AddressSpaceApplier? _applier; /// Receives the API keys + ACL grants carried by each applied artifact, so /// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node @@ -184,9 +200,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// Production Props — pins the OPC UA dispatcher + subscribes to the /// redundancy-state DPS topic so cluster transitions drive the local ServiceLevel - /// publish path. When + are supplied, - /// reads the latest deployment artifact + drives the - /// applier through the sink. When is supplied the local + /// publish path. When is supplied, + /// diffs + applies the artifact bytes it carries through the sink ( is + /// accepted for wiring compatibility only — review 02 P5 removed the ConfigDb fallback). When is supplied the local /// ServiceLevel is computed via from real DB-health + /// staleness + role-leader inputs; otherwise the legacy role-only switch is used. /// The OPC UA address space sink. @@ -335,7 +351,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers _serviceLevel = serviceLevel; _subscribeRedundancyTopic = subscribeRedundancyTopic; _localNode = localNode; - _dbFactory = dbFactory; + // dbFactory is accepted for wiring compatibility only (review 02 P5): the bytes-less ConfigDb read the + // rebuild used to fall back to is gone — every producer carries the artifact bytes in the message. + _ = dbFactory; _applier = applier; _dbHealthProbe = dbHealthProbe; _dbLess = dbLess; @@ -350,6 +368,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Receive(HandleAlarmUpdate); Receive(HandleAlarmQualityUpdate); Receive(HandleRebuild); + Receive(HandlePlanReady); + Receive(HandlePlanFailed); Receive(HandleServiceLevelChanged); Receive(HandleRedundancyStateChanged); Receive(HandleDbHealthStatus); @@ -480,114 +500,147 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } - private void HandleRebuild(RebuildAddressSpace msg) + private void HandleRebuild(RebuildAddressSpace msg) => StartRebuild(msg, Sender); + + /// + /// The entry half of a rebuild, on the actor thread. The raw-sink fallback (no applier) and the + /// no-artifact case complete synchronously; otherwise the artifact parse, the cluster-scoped composition + /// and the plan — the O(artifact) work that used to serialise every live value batch and (because the + /// node manager's lock is the SDK's) every client Publish behind it — run on the thread pool and come + /// back as a . Only the sink materialisation () runs here. + /// + private void StartRebuild(RebuildAddressSpace msg, IActorRef requester) { - // Captured up front: the pipeline below is synchronous on this actor's thread today, but the reply - // must reach whoever asked even if a future edit introduces an await into it. - var requester = Sender; - (bool Degraded, string? Reason) outcome; - try - { - outcome = HandleRebuildCore(msg); - } - finally - { - // close the apply on EVERY exit, early returns and a throwing pass included: the node - // manager reports here the native conditions a rebuild carried over (after the notifier wiring the - // passes did) and discards any captured state no materialise claimed. Skipping it on a throw would - // leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is - // pending; never throws out of the handler. - try { _sink.CompleteAddressSpaceApply(); } - catch (Exception ex) - { - _log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation); - } - } - - // the outcome goes out only after the apply (including its completion step) is done. - if (msg.ReportOutcome) - requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason)); - } - - /// The rebuild pipeline. Returns whether the apply was degraded, and why. - private (bool Degraded, string? Reason) HandleRebuildCore(RebuildAddressSpace msg) - { - using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); - span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); - - // Two modes. With an applier wired, do a REAL diff-and-apply pass — driven either by the - // artifact bytes already in hand (a per-cluster-mesh driver-only / FetchAndCache node, whose - // ConfigDb is admin-only now so _dbFactory is null, hands its fetched/cached bytes in - // msg.Artifact) or, when no bytes are carried, by loading the artifact from the ConfigDb (the - // Direct path, which requires _dbFactory). Only a node with NO applier at all (the F10b/dev - // seam) falls back to the raw-sink rebuild — and that fallback WIPES the served address space - // without re-materialising, so it must NEVER be reached by a real driver node that has an - // applier but simply has no ConfigDb (that node diff-and-applies from its in-hand bytes below). + // Only a node with NO applier at all (the F10b/dev seam) falls back to the raw-sink rebuild — and that + // fallback WIPES the served address space without re-materialising, so it must NEVER be reached by a + // real driver node that has an applier (that node diff-and-applies from its in-hand bytes below). if (_applier is null) { + (bool Degraded, string? Reason) outcome; try { _sink.RebuildAddressSpace(); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild")); + outcome = (false, null); } catch (Exception ex) { - _log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})", - msg.Correlation); - return (true, $"address-space rebuild threw: {ex.Message}"); + _log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})", msg.Correlation); + outcome = (true, $"address-space rebuild threw: {ex.Message}"); } - return (false, null); + + FinishRebuild(msg, requester, outcome); + return; } + // An artifact we could not obtain is NOT an empty configuration. Parsing zero bytes yields an empty + // composition, which the planner diffs against the live one as a PureRemove and the applier then + // faithfully tears down. Nothing legitimate produces a zero-length blob (an operator who really deletes + // everything still deploys a JSON document with empty arrays), so the only honest reading of "no bytes" + // is "no answer" — hold the last-known-good address space and let the next deploy (or the next boot) + // supply a real one. Leaving _lastApplied intact means the retry diffs against what is materialised. + if (msg.Artifact is null or { Length: 0 }) + { + if (_hasAppliedComposition) + _log.Warning( + "OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down", + msg.DeploymentId, msg.Correlation); + else + _log.Debug( + "OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})", + msg.DeploymentId, msg.Correlation); + FinishRebuild(msg, requester, (true, "no usable artifact reached the address-space apply; the previously served address space was kept")); + return; + } + + if (_planInFlight) + { + _pendingRebuilds.Enqueue((msg, requester)); + return; + } + + _planInFlight = true; + var artifact = msg.Artifact; + var baseComposition = _lastApplied; + var nodeId = _localNode?.Value; + var wantAuthority = _dataPlaneAuthority is not null; + Task.Run(() => + { + // Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an EMPTY + // result — and an empty composition is not a benign no-op here (see the no-bytes guard above). The + // throw lands in PlanFailed: logged at Error, _lastApplied untouched, last-known-good still served. + using var parsed = DeploymentArtifact.Open(artifact); + var authority = wantAuthority ? parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty) : null; + var inconsistencies = new List(); + var composition = nodeId is not null + ? parsed.ParseComposition(nodeId, inconsistencies.Add) + : parsed.ParseComposition(); + var plan = AddressSpacePlanner.Compute(baseComposition, composition); + return new PlanReady(msg, requester, artifact, authority, composition, plan, inconsistencies); + }) + .PipeTo(Self, failure: ex => new PlanFailed(msg, requester, ex)); + } + + private void HandlePlanReady(PlanReady ready) + { + (bool Degraded, string? Reason) outcome; + try + { + outcome = ApplyPlan(ready); + } + finally + { + _planInFlight = false; + } + + FinishRebuild(ready.Request, ready.Requester, outcome); + StartNextPendingRebuild(); + } + + private void HandlePlanFailed(PlanFailed failed) + { + _planInFlight = false; + _log.Error(failed.Error, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", failed.Request.Correlation); + FinishRebuild(failed.Request, failed.Requester, (true, $"address-space apply threw: {failed.Error.Message}")); + StartNextPendingRebuild(); + } + + private void StartNextPendingRebuild() + { + if (_pendingRebuilds.TryDequeue(out var next)) StartRebuild(next.Request, next.Requester); + } + + /// + /// Closes an apply on EVERY exit — early returns and a throwing pass included: the node manager reports + /// here the native conditions a rebuild carried over (after the notifier wiring the passes did) and + /// discards any captured state no materialise claimed. Skipping it on a throw would leave that state for a + /// later surgical re-add of the same id to resurrect. No-op when nothing is pending; never throws. The + /// outcome goes out only after the apply (including its completion step) is done. + /// + private void FinishRebuild(RebuildAddressSpace msg, IActorRef requester, (bool Degraded, string? Reason) outcome) + { + try { _sink.CompleteAddressSpaceApply(); } + catch (Exception ex) + { + _log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation); + } + + if (msg.ReportOutcome) + requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason)); + } + + /// The actor-thread half of a rebuild: install the artifact's authority, apply the plan through the + /// sink passes, announce. Returns whether the apply was degraded, and why. + private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready) + { + var msg = ready.Request; + using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); + span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); + try { - // An in-hand artifact (boot-from-cache / FetchAndCache) wins: the host already has the - // cached/fetched bytes, and the ConfigDb read the DeploymentId path would do is exactly what - // is unreachable during the outage this exists to survive — and is simply absent on a - // driver-only node with no ConfigDb. Otherwise, when a ConfigDb is present (Direct mode), - // prefer the artifact of the deployment the host just applied — at apply time it is not yet - // Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a stale - // composition (variables that don't match the SubscribeBulk refs); fall back to latest-sealed - // only for legacy callers that carry neither. With NO bytes AND no ConfigDb there is nothing - // to load, so yield Array.Empty() — the guard below reads that as "no answer" and - // abandons the rebuild, holding last-known-good rather than wiping the address space. - var artifact = msg.Artifact - ?? (_dbFactory is not null - ? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact()) - : Array.Empty()); - - // An artifact we could not obtain is NOT an empty configuration. Parsing zero - // bytes yields an empty composition, which the planner diffs against the live one as a - // PureRemove and the applier then faithfully tears down: a transient ConfigDb blip empties the - // served address space. Nothing legitimate produces a zero-length blob (an operator who really - // deletes everything still deploys a JSON document with empty arrays), so the only honest - // reading of "no bytes" is "no answer" — hold the last-known-good address space and let the - // next deploy (or the next boot) supply a real one. Returning here also leaves _lastApplied - // intact, so the retry diffs against what is actually materialised. - if (artifact is null or { Length: 0 }) - { - if (_hasAppliedComposition) - _log.Warning( - "OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down", - msg.DeploymentId, msg.Correlation); - else - _log.Debug( - "OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})", - msg.DeploymentId, msg.Correlation); - return (true, "no usable artifact reached the address-space apply; the previously served address space was kept"); - } - - // Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the - // composition) reads this one JsonDocument instead of rebuilding it per question — the rebuild - // used to pay a full parse-and-throw-away per static call. Disposed when the handler returns. - // - // Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an - // EMPTY result — and an empty composition is not a benign no-op here: the planner diffs it as a - // PureRemove and the applier tears the served address space down. So the throw is the honest - // outcome, and it lands in this method's catch below: logged at Error, _lastApplied untouched, - // last-known-good still served. Same "no answer ≠ empty configuration" rule as the guard - // immediately above, applied to bytes that arrived unreadable rather than absent. - using var parsed = DeploymentArtifact.Open(artifact); + foreach (var inconsistency in ready.Inconsistencies) + _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", _localNode, inconsistency); // install this artifact's API keys + ACL grants BEFORE the address-space work // below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a @@ -595,13 +648,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // feeding this after that return would leave a revoked key authenticating until some unrelated // structural change happened to redeploy. Authorization and topology change on their own // schedules. - FeedDataPlaneAuthority(parsed, artifact); + if (ready.Authority is not null) FeedDataPlaneAuthority(ready.Authority, ready.Artifact); - var composition = _localNode is { } ln - ? parsed.ParseComposition(ln.Value, - inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency)) - : parsed.ParseComposition(); - var plan = AddressSpacePlanner.Compute(_lastApplied, composition); + var composition = ready.Composition; + var plan = ready.Plan; if (plan.IsEmpty) { @@ -610,7 +660,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // The composition is unchanged but the last apply of it was a degraded // rebuild: re-ensure every node (each Ensure* no-ops on a node that stands) so the // re-deploy actually repairs what that rebuild could not place. - var repairFailed = _applier.MaterialiseHierarchy(composition) + var repairFailed = _applier!.MaterialiseHierarchy(composition) + MaterialiseNodes(plan, composition, fullComposition: true); if (repairFailed > 0) { @@ -632,7 +682,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers return (false, null); } - var outcome = _applier.Apply(plan); + var outcome = _applier!.Apply(plan); // Sum swallowed per-node materialise failures across every pass together with Apply's own // removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node @@ -867,17 +917,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// the safe direction for availability but means a revocation carried by an artifact this node /// could not decode has not taken effect. It is logged at Error for exactly that reason. /// - /// The rebuild's single parse of the artifact, read for the keys + grants. + /// The keys + grants decoded (off-thread) from the rebuild's single parse of the artifact. /// The RAW artifact bytes — hashed for the revision id, which must be /// SHA-256 of the exact blob (see below); never re-parsed here. - private void FeedDataPlaneAuthority(ParsedDeploymentArtifact parsed, byte[] artifactBytes) + private void FeedDataPlaneAuthority(DataPlaneAuthorityProjection projection, byte[] artifactBytes) { if (_dataPlaneAuthority is null) return; try { - var nodeId = _localNode?.Value ?? string.Empty; - var projection = parsed.ParseDataPlaneAuthority(nodeId); // The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses, // which is what lets a node recognise a re-applied revision without the hash riding the wire. // Hashed off the RAW bytes for exactly that reason: a re-serialisation of the parsed document @@ -922,46 +970,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } - /// Read a specific deployment's artifact blob from ConfigDb (the one just applied, - /// which may not be Sealed yet). Empty array on any failure — treats that as - /// "no answer" and abandons the rebuild, leaving the served address space untouched. - private byte[] LoadArtifact(DeploymentId deploymentId) - { - try - { - using var db = _dbFactory!.CreateDbContext(); - return db.Deployments.AsNoTracking() - .Where(d => d.DeploymentId == deploymentId.Value) - .Select(d => d.ArtifactBlob) - .FirstOrDefault() ?? Array.Empty(); - } - catch (Exception ex) - { - _log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; the rebuild is abandoned", deploymentId); - return Array.Empty(); - } - } - - /// Read the most recent Sealed deployment's artifact blob from ConfigDb. - /// Empty array on any failure — see for how that is handled. - private byte[] LoadLatestArtifact() - { - try - { - using var db = _dbFactory!.CreateDbContext(); - return db.Deployments.AsNoTracking() - .Where(d => d.Status == Configuration.Enums.DeploymentStatus.Sealed) - .OrderByDescending(d => d.SealedAtUtc) - .Select(d => d.ArtifactBlob) - .FirstOrDefault() ?? Array.Empty(); - } - catch (Exception ex) - { - _log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; the rebuild is abandoned"); - return Array.Empty(); - } - } - private void HandleServiceLevelChanged(ServiceLevelChanged msg) { // Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index 8d1fd67e..6c07ca0f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -50,13 +50,13 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); // First deploy: PureAdd — no rebuild, so the throwing sink is not hit yet. - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); Thread.Sleep(200); recorder.Total.ShouldBe(0); // Second deploy: eq-1 RENAMED ⇒ ChangedEquipment ⇒ Rebuild ⇒ the sink's RebuildAddressSpace throws. var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed")); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); // A PRESENCE budget, so it is an upper bound before giving up rather than a wait: the helper polls // and returns the instant the meter fires, so a generous value costs nothing on the happy path and @@ -78,10 +78,10 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase var db = NewInMemoryDbFactory(); var sink = new NoopSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedEquipmentDeployment(db, ("eq-1", "eq-1")); + var dep = SeedEquipmentDeployment(db, ("eq-1", "eq-1")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); // Give the actor time to process, then confirm the failed meter never fired. Thread.Sleep(400); @@ -100,6 +100,11 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase if (last is not null) throw last; } + private static readonly System.Collections.Concurrent.ConcurrentDictionary Artifacts = new(); + + private static OpcUaPublishActor.RebuildAddressSpace Rebuild(Guid deploymentId) => + new(CorrelationId.NewId(), new DeploymentId(deploymentId), Artifacts[deploymentId]); + private static Guid SeedEquipmentDeployment(IDbContextFactory dbFactory, params (string Id, string Name)[] equipment) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new @@ -116,6 +121,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase }); var id = Guid.NewGuid(); + Artifacts[id] = artifact; using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs new file mode 100644 index 00000000..734edbba --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs @@ -0,0 +1,128 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Akka.Actor; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; + +/// +/// Review 02 P5 — a rebuild's artifact parse + plan no longer run on the pinned OPC UA thread. The +/// observable: value batches sent right after a large rebuild land on the sink BEFORE that rebuild's first +/// materialise call, because the actor is free while the thread pool parses and plans; two rebuilds sent +/// back to back still apply in arrival order; a plan that throws still answers the requester degraded. +/// +public sealed class OpcUaPublishActorOffThreadPlanTests : RuntimeActorTestBase +{ + [Fact] + public void Value_batches_keep_flowing_while_a_large_plan_computes() + { + var sink = new OrderedSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); + // A value node the batch names; the sink is a recorder, so it need not exist. + var batch = new OpcUaPublishActor.AttributeValueBatchUpdate( + [new ValueWrite("Plant/Modbus/dev1/live", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)]); + + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(50_000), ReportOutcome: true)); + actor.Tell(batch); + + var completed = ExpectMsg(TimeSpan.FromSeconds(120)); + completed.Degraded.ShouldBeFalse(completed.DegradedReason); + + var calls = sink.Calls.ToList(); + var batchIndex = calls.IndexOf("WV:Plant/Modbus/dev1/live"); + var firstMaterialise = calls.FindIndex(c => c.StartsWith("EV:", StringComparison.Ordinal) || c.StartsWith("EF:", StringComparison.Ordinal)); + batchIndex.ShouldBeGreaterThanOrEqualTo(0, "the batch must reach the sink"); + firstMaterialise.ShouldBeGreaterThanOrEqualTo(0, "the plan must still be applied (positive control)"); + batchIndex.ShouldBeLessThan(firstMaterialise, "the batch sent after the rebuild must land before the rebuild's materialisation — the parse/plan step no longer holds the actor thread"); + calls.Count(c => c.StartsWith("EV:", StringComparison.Ordinal)).ShouldBe(50_000); + } + + [Fact] + public void Back_to_back_rebuilds_apply_in_arrival_order() + { + var sink = new OrderedSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); + + // The first (large) plan is still computing when the second (tiny) arrives; the second must wait for + // it — its plan is a diff against what the first applies. + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(20_000), ReportOutcome: true)); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(2), ReportOutcome: true)); + + var first = ExpectMsg(TimeSpan.FromSeconds(120)); + var second = ExpectMsg(TimeSpan.FromSeconds(120)); + first.Degraded.ShouldBeFalse(first.DegradedReason); + second.Degraded.ShouldBeFalse(second.DegradedReason); + + // The second composition is a strict subset (2 of the same tags), so it diffs as a PureRemove of the + // other 19,998 — which only works if it was planned AFTER the first apply, against its composition. + sink.Calls.Count(c => c.StartsWith("RV:", StringComparison.Ordinal)).ShouldBe(19_998); + } + + [Fact] + public void Unreadable_bytes_answer_degraded_and_keep_the_served_space() + { + var sink = new OrderedSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); + + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), "not json"u8.ToArray(), ReportOutcome: true)); + + var completed = ExpectMsg(PresenceBudget); + completed.Degraded.ShouldBeTrue(); + completed.DegradedReason.ShouldNotBeNull().ShouldContain("threw"); + sink.Calls.ShouldBeEmpty("nothing may be torn down for bytes that could not be read"); + sink.Completed.ShouldBe(1, "the apply is still closed"); + } + + private static byte[] RawTagArtifact(int tagCount) => + 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 = Enumerable.Range(0, tagCount).Select(i => new + { + TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", + DataType = "Float", AccessLevel = 0, TagConfig = "{}", + }).ToArray(), + UnsTagReferences = Array.Empty(), + ScriptedAlarms = Array.Empty(), + }); + + private sealed class OrderedSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink + { + public ConcurrentQueue Calls { get; } = new(); + public int Completed; + + public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm) => Calls.Enqueue($"WV:{nodeId}"); + public void WriteValues(IReadOnlyList values) { foreach (var v in values) Calls.Enqueue($"WV:{v.NodeId}"); } + public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime ts, AddressSpaceRealm realm) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm) => Calls.Enqueue($"WA:{alarmNodeId}"); + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) => Calls.Enqueue($"MA:{alarmNodeId}"); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) => Calls.Enqueue($"EF:{folderNodeId}"); + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) => Calls.Enqueue($"EV:{variableNodeId}"); + public void RebuildAddressSpace() => Calls.Enqueue("RB"); + public void CompleteAddressSpaceApply() => Interlocked.Increment(ref Completed); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => 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 bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) { Calls.Enqueue($"UT:{variableNodeId}"); return true; } + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true; + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RV:{variableNodeId}"); return true; } + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RA:{alarmNodeId}"); return true; } + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RE:{equipmentNodeId}"); return true; } + } +} 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 2e0d239d..fd05748c 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 @@ -41,14 +41,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" }); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" }); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); AwaitAssert(() => { @@ -72,10 +72,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" }); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" }); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget); sink.CompletedAfterCallCount.Single().ShouldBe(sink.Calls.Count, "nothing may follow the completion"); @@ -126,17 +126,17 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild var callsAfterFirst = sink.Calls.Count; - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); Thread.Sleep(200); // Same composition ⇒ plan IsEmpty ⇒ applier + materialise passes not run again. sink.Calls.Count.ShouldBe(callsAfterFirst); @@ -232,7 +232,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// and drives the applier. The guard split must leave the Direct path byte-identical. /// [Fact] - public void Rebuild_direct_mode_with_dbFactory_and_deploymentId_still_loads_and_applies() + public void Rebuild_with_deploymentId_and_in_hand_bytes_applies() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); @@ -242,7 +242,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep))); + actor.Tell(Rebuild(dep)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); @@ -267,7 +267,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dbA = NewInMemoryDbFactory(); var sinkA = new RecordingSink(); var applierA = new AddressSpaceApplier(sinkA, NullLogger.Instance); - SeedMultiClusterDeployment(dbA); + var depA = SeedMultiClusterDeployment(dbA); var siteActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sinkA, @@ -275,7 +275,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase applier: applierA, localNode: NodeId.Parse("site-a-1:4053"))); - siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + siteActor.Tell(Rebuild(depA)); // PureAdd ⇒ no full rebuild; the materialise passes still run the cluster slice. AwaitAssert(() => sinkA.Calls.ShouldContain("EV:SA/Modbus/dev-sa/S1"), duration: PresenceBudget); @@ -287,7 +287,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dbM = NewInMemoryDbFactory(); var sinkM = new RecordingSink(); var applierM = new AddressSpaceApplier(sinkM, NullLogger.Instance); - SeedMultiClusterDeployment(dbM); + var depM = SeedMultiClusterDeployment(dbM); var mainActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sinkM, @@ -295,7 +295,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase applier: applierM, localNode: NodeId.Parse("central-1:4053"))); - mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + mainActor.Tell(Rebuild(depM)); AwaitAssert(() => sinkM.Calls.ShouldContain("EV:Main/Modbus/dev-main/M1"), duration: PresenceBudget); sinkM.RebuildCalls.ShouldBe(0); @@ -308,7 +308,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// tag chain plus its UNS area/line/equipment. Used by /// . /// - private static void SeedMultiClusterDeployment(IDbContextFactory dbFactory) + private static Guid SeedMultiClusterDeployment(IDbContextFactory dbFactory) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { @@ -358,17 +358,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - using var ctx = dbFactory.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = Guid.NewGuid(), - RevisionHash = new string('b', 64), - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); + return SealArtifact(dbFactory, artifact); } /// R2-07 T4b — a PureAdd deploy (here a single new equipment) does NOT rebuild, and the @@ -381,10 +371,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); @@ -410,7 +400,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:")); @@ -418,7 +408,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // Second deploy: eq-1 RENAMED (ChangedEquipment ⇒ Rebuild) AND a new eq-2 (an add). The rebuild // fires, so the announce hook is skipped despite eq-2 being added. var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2")); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); // No new NodeAdded announcement was raised for the rebuild-kind deploy. @@ -442,7 +432,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"), ("eq-2", "Pump-2")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget); var callsAfterFirst = sink.Calls.Count; @@ -458,7 +448,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // …and the actor still remembers WHAT is applied: once the database is back, re-applying the same // composition diffs to an empty plan. A clobbered _lastApplied would re-materialise everything. db.Fail = false; - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); Thread.Sleep(200); sink.Calls.Count.ShouldBe(callsAfterFirst); } @@ -477,12 +467,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"), ("eq-2", "Pump-2")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget); // eq-2 really is gone from the next artifact — that IS a configuration change, so it must apply. var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: PresenceBudget); } @@ -534,24 +524,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase IDbContextFactory dbFactory, params (string Id, string Name)[] equipment) { - var artifact = BuildNamedEquipmentArtifact(equipment); - - var id = Guid.NewGuid(); - using var ctx = dbFactory.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id, - RevisionHash = new string('d', 64), - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; + return SealArtifact(dbFactory, BuildNamedEquipmentArtifact(equipment)); } - private static void SeedDeployment( + private static Guid SeedDeployment( IDbContextFactory dbFactory, string[] equipmentIds, string[] driverIds) @@ -575,17 +551,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - using var ctx = dbFactory.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = Guid.NewGuid(), - RevisionHash = new string('a', 64), - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); + return SealArtifact(dbFactory, artifact); } /// OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename (the area Name differs; @@ -606,14 +572,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd // (area + line + equipment all added), so NO full rebuild; the folder is materialised directly. - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and // drive a surgical in-place folder rename (NOT a rebuild, NOT a no-op). var dep2 = SeedAreaDeployment(db, areaName: "Plant South"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => { @@ -639,19 +605,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - var id = Guid.NewGuid(); - using var ctx = dbFactory.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id, - RevisionHash = new string('c', 64), - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; + return SealArtifact(dbFactory, artifact); } // --------------------------------------------------------------------------------------------------- @@ -677,13 +631,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase 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))); + actor.Tell(Rebuild(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))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/D"), duration: PresenceBudget); Thread.Sleep(200); // let any (unwanted) extra pass finish before counting @@ -707,13 +661,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase 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))); + actor.Tell(Rebuild(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))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); AwaitAssert( () => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/b"), @@ -746,13 +700,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase 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))); + actor.Tell(Rebuild(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))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/b"), duration: PresenceBudget); var second = sink.Calls.Skip(afterFirst).ToList(); @@ -784,13 +738,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase 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))); + actor.Tell(Rebuild(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))); + actor.Tell(Rebuild(dep2)); // The mirror now carries the backing tag's historian tagname — the repair itself, unchanged by I2. AwaitAssert( @@ -818,12 +772,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase 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))); + actor.Tell(Rebuild(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))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.Calls.ShouldContain("UT:Plant/Modbus/dev1/A"), duration: PresenceBudget); Thread.Sleep(200); // let any (unwanted) extra pass finish before counting @@ -855,7 +809,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase 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))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget); Thread.Sleep(200); sink.RebuildCalls.ShouldBe(0); // genuinely a non-rebuild (PureAdd) apply @@ -867,7 +821,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // EMPTY plan and A would be gone from the served address space forever. sink.FailEnsureVariableFor = null; var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" }); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert( () => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/A"), @@ -894,14 +848,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase 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))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); // Renaming the equipment is ChangedEquipment ⇒ the classifier's default-closed Rebuild; the sink // faults on one variable of the full re-materialise that follows. sink.FailEnsureVariableFor = "Plant/Modbus/dev1/b"; var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); Thread.Sleep(200); var afterSecond = sink.Calls.Count; @@ -911,7 +865,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // rebuild again) — but the degraded rebuild is remembered, so the failed variable is re-ensured. sink.FailEnsureVariableFor = null; var dep3 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep3))); + actor.Tell(Rebuild(dep3)); AwaitAssert( () => sink.Calls.Skip(afterSecond).ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); @@ -921,7 +875,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase Thread.Sleep(200); var afterRepair = sink.Calls.Count; var dep4 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep4))); + actor.Tell(Rebuild(dep4)); Thread.Sleep(300); sink.Calls.Count.ShouldBe(afterRepair); sink.RebuildCalls.ShouldBe(1); @@ -1017,10 +971,19 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase return SealArtifact(dbFactory, artifact); } + /// The artifact bytes behind each sealed deployment id, so a test can hand the actor the bytes its + /// production producers always carry (review 02 P5 removed the actor's ConfigDb fallback). + private static readonly System.Collections.Concurrent.ConcurrentDictionary Artifacts = new(); + + /// The rebuild message a producer sends for deployment : its id AND its bytes. + private static OpcUaPublishActor.RebuildAddressSpace Rebuild(Guid deploymentId) => + new(CorrelationId.NewId(), new DeploymentId(deploymentId), Artifacts[deploymentId]); + /// Seal as a new deployment and return its id. private static Guid SealArtifact(IDbContextFactory dbFactory, byte[] artifact) { var id = Guid.NewGuid(); + Artifacts[id] = artifact; using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { From d90f35b6bede758dd76c5b90afd7bff0ebdaed97 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:43:21 -0400 Subject: [PATCH 07/13] fix(opcua): refresh historian capability bits on late source/updater assignment (B7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 U3. EnsureFolderIsEventNotifier granted the EventNotifiers .HistoryRead bit (and registered the event-history source) only when a real HistorianDataSource was wired at promotion time, and EnsureVariable composed the AccessLevels.HistoryWrite bit from whether a HistorianDataUpdater existed at materialise time — so an assignment after the first apply left existing folders without event history and existing nodes refused by the SDK's HistoryUpdate admission until a full rebuild, and a source reset to Null never took the bit back. The two setters now re-evaluate: HistorianDataSource walks every promoted folder (bit + source registration, both directions), HistorianDataUpdater recomposes the AccessLevel of every historized variable (Writable read back off the node's own CurrentWrite bit). The Host's boot ordering is no longer load-bearing. Tests: NodeManagerCapabilityRefreshTests (late source lights the bit on an already-promoted folder and a reset takes it back, live-event subscription untouched; late updater grants HistoryWrite to historized nodes only and a reset takes it back, authored Writable preserved). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 95 ++++++++++--- .../NodeManagerCapabilityRefreshTests.cs | 125 ++++++++++++++++++ 2 files changed, 205 insertions(+), 15 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 8f03cd75..4edeac81 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -368,9 +368,73 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { _historianDataSource = value ?? NullHistorianDataSource.Instance; SyncAdvertisedAggregates(); + RefreshEventHistoryCapability(); } } + /// + /// Review 01 U3 — re-evaluates the bit (and the event-history + /// source registration resolves against) on every folder already promoted + /// to an event notifier, so a assigned AFTER the first apply advertises + /// event history without a full rebuild — and a source reset to the Null default takes the bit back + /// instead of leaving folders answering BadHistoryOperationUnsupported. Folders promoted later go + /// through , which reads the same source. + /// + private void RefreshEventHistoryCapability() + { + var wired = _historianDataSource is not NullHistorianDataSource; + lock (Lock) + { + foreach (var (nodeId, folder) in _notifierFolders) + { + var has = (folder.EventNotifier & EventNotifiers.HistoryRead) != 0; + if (has == wired) continue; + var key = MapKey(nodeId); + if (wired) + { + folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead); + _eventNotifierSources[key] = nodeId.Identifier?.ToString() ?? string.Empty; + } + else + { + folder.EventNotifier = (byte)(folder.EventNotifier & ~EventNotifiers.HistoryRead); + _eventNotifierSources.TryRemove(key, out _); + } + + folder.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } + + /// + /// Review 01 U3 — re-evaluates the bit on every historized + /// variable, so a assigned (or cleared) after nodes exist changes what + /// the SDK base admits to HistoryUpdate without a full rebuild. Writable is read back off the node's + /// own CurrentWrite bit — the authored value was given. + /// + private void RefreshHistoryWriteCapability() + { + var historyWritable = _historianDataUpdater is not null; + lock (Lock) + { + foreach (var (key, variable) in _variables) + { + if (!_historizedTagnames.ContainsKey(key)) continue; + var writable = (variable.AccessLevel & AccessLevels.CurrentWrite) != 0; + var access = ComposeAccessLevel(writable, historized: true, historyWritable); + if (variable.AccessLevel == access && variable.UserAccessLevel == access) continue; + variable.AccessLevel = access; + variable.UserAccessLevel = access; + variable.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } + + /// Test observability: whether is registered as an event-history + /// source (what accepts). + internal bool IsEventHistorySource(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _eventNotifierSources.ContainsKey(MapKey(realm, folderNodeId)); + /// /// Trim HistoryServerCapabilities/AggregateFunctions (i=11201) to the current source's /// (null ⇒ all, ). @@ -408,20 +472,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// delete of historized samples). null (the default) means the selected historian backend /// cannot modify history: every HistoryUpdate is reported BadHistoryOperationUnsupported. /// - /// ⚠️ Assign it before any node materialises. Whether an updater is present decides the - /// HistoryWrite AccessLevel bit a historized node is created with - /// (), and the SDK base admits a node to HistoryUpdate only when - /// that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Setting the updater after - /// nodes exist does NOT retro-update their AccessLevel. The Host assigns it in - /// OtOpcUaServerHostedService.StartAsync before installing the address-space sink, which - /// precedes every deploy apply. Volatile for the same start-thread / request-thread handoff as - /// . + /// Whether an updater is present decides the HistoryWrite AccessLevel bit a historized node + /// carries (), and the SDK base admits a node to HistoryUpdate only + /// when that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Assigning the updater + /// after nodes exist re-evaluates the bit on every historized node (review 01 U3 — + /// ), so the Host's ordering (assigned in + /// OtOpcUaServerHostedService.StartAsync before the sink is installed) is no longer load-bearing. + /// Volatile for the same start-thread / request-thread handoff as . /// /// public IHistorianDataUpdater? HistorianDataUpdater { get => _historianDataUpdater; - set => _historianDataUpdater = value; + set + { + _historianDataUpdater = value; + RefreshHistoryWriteCapability(); + } } /// @@ -2914,12 +2981,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// ), the folder ALSO gets the /// bit OR-ed in (keeping SubscribeToEvents) and registers /// its NodeId identifier as an event-history source so the override - /// accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at - /// the moment of promotion: the Host wires the source at StartAsync — BEFORE any deployment - /// materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder - /// promoted while the source is still Null advertises live-event subscription but NOT event history - /// until the next re-promotes it (acceptable, documented). The - /// HistoryRead bit + source registration happen inside the same first-time + /// accepts it. A folder promoted while the source is still Null advertises live-event subscription + /// only; a later assignment re-evaluates every promoted folder + /// (review 01 U3 — ), so the Host's boot ordering is no + /// longer load-bearing. The HistoryRead bit + source registration happen inside the same first-time /// block so the idempotency guard covers them too. /// private void EnsureFolderIsEventNotifier(FolderState folder) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs new file mode 100644 index 00000000..428f74a2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a +/// or +/// assigned after the first apply left existing folders without event history (and existing nodes +/// without HistoryUpdate) until a full rebuild, and a source reset to Null never took the bit back. +/// Both setters now re-evaluate the bits on every existing node. +/// +public sealed class NodeManagerCapabilityRefreshTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-caprefresh-{Guid.NewGuid():N}"); + + [Fact] + public async Task A_late_historian_source_grants_event_history_to_already_promoted_folders_and_a_reset_takes_it_back() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, AddressSpaceRealm.Uns); + var folder = nm.TryGetFolder("eq-1")!; + + // Promoted while the source is Null: live events only (non-vacuity for the refresh). + (folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0); + (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0); + nm.IsEventHistorySource("eq-1").ShouldBeFalse(); + + nm.HistorianDataSource = new StubSource(); + + (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldNotBe((byte)0, "a late source must light the bit on the existing folder"); + nm.IsEventHistorySource("eq-1").ShouldBeTrue(); + + nm.HistorianDataSource = null!; // the setter maps null to the Null source + + (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0, "a reset source must take the bit back"); + (folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0, "live-event subscription is untouched"); + nm.IsEventHistorySource("eq-1").ShouldBeFalse(); + } + finally { await host.DisposeAsync(); } + } + + [Fact] + public async Task A_late_updater_grants_history_write_to_historized_nodes_only_and_a_reset_takes_it_back() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("Plant/dev1/hist", null, "hist", "Float", writable: true, realm: AddressSpaceRealm.Raw, historianTagname: "Hist.A"); + nm.EnsureVariable("Plant/dev1/plain", null, "plain", "Float", writable: false, realm: AddressSpaceRealm.Raw); + var hist = nm.TryGetVariable("Plant/dev1/hist", AddressSpaceRealm.Raw)!; + var plain = nm.TryGetVariable("Plant/dev1/plain", AddressSpaceRealm.Raw)!; + (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "no updater at materialise time"); + + nm.HistorianDataUpdater = new StubUpdater(); + + (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0); + (hist.UserAccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0); + (hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0, "the authored Writable survives the refresh"); + (hist.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0); + (plain.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "a non-historized node never gets HistoryWrite"); + + nm.HistorianDataUpdater = null; + + (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0); + (hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0); + } + finally { await host.DisposeAsync(); } + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.CapRefreshTest", + ApplicationUri = $"urn:OtOpcUa.CapRefreshTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + private sealed class StubSource : IHistorianDataSource + { + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + public void Dispose() { } + } + + private sealed class StubUpdater : IHistorianDataUpdater + { + public Task> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + public Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + public Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + } +} From 8a1116e658e601fa9412c7339804ef03e413b3be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:43:21 -0400 Subject: [PATCH 08/13] fix(opcua): fit the raw end bound against the page's effective cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 fuzz finding (routed to Lane B because AppendEndBoundAsync lives in OtOpcUaNodeManager.cs): a bounded HistoryRead-Raw page sized BELOW NumValuesPerNode by the backend's row ceiling (RawPageLimit.Plan) took the end bound whenever its data fitted, because the fit was judged against NumValuesPerNode instead of the page's effective cap — a full ceiling-sized page came back with cap + 1 values. The bound is now fitted against the page allowance (the ceiling-adjusted data cap plus the start bound a first page already carries); when the page is full the bound goes to the next page through the continuation point. Test: NodeManagerHistoryReadBoundsTests .A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page (ceiling 4 ⇒ 3 data rows; first page = start bound + 3 with a CP, second page = the end bound alone; control: no ceiling ⇒ one page of 5). Refs #732 #736 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 16 ++++++--- .../NodeManagerHistoryReadBoundsTests.cs | 34 ++++++++++++++++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 4edeac81..9e6091e9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -6781,13 +6781,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The data is drained — the end bound ends this page when it has room (Part 11: bounds count toward // NumValuesPerNode), else it is the whole next page. Returns the continuation point to emit, if any. - async Task AppendEndBoundAsync(List page, DateTime resumedAtUtc) + // `pageAllowance` is the EFFECTIVE cap of this page — NumValuesPerNode, less whatever the backend's row + // ceiling (RawPageLimit) took off it, plus the start bound a first page already carries — not + // NumValuesPerNode itself: against N a ceiling-sized page that was full still took the bound, returning + // cap + 1 values (#736 fuzz). 0 = unbounded. + async Task AppendEndBoundAsync(List page, DateTime resumedAtUtc, uint pageAllowance) { // The previous returned value stamps an open bound's placeholder (Part 11 Table 1 footnotes a/b): this // page's last, else the last of an earlier page (carried in the cursor), else where the read resumed. var previous = (page.Count > 0 ? page[^1].SourceTimestampUtc : null) ?? endBound!.LastReturnedUtc ?? resumedAtUtc; - if (numValuesPerNode == 0 || page.Count < numValuesPerNode) + if (pageAllowance == 0 || page.Count < pageAllowance) { page.Add(await HistoryBounds.EndBoundAsync( HistorianDataSource, tagname, endBound!, previous, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false)); @@ -6832,7 +6836,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 && (endBound.DataExhausted || HistoryBounds.DataWindowDrained(startUtc, endBound, resolution))) { var onlyBound = new List(1); - var afterCp = await AppendEndBoundAsync(onlyBound, startUtc).ConfigureAwait(false); + var afterCp = await AppendEndBoundAsync(onlyBound, startUtc, numValuesPerNode).ConfigureAwait(false); results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.Good, @@ -7052,7 +7056,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else if (endBound is not null) { // The data is drained — the end bound ends this page, or is the next one when this one is full. - clusterCp = await AppendEndBoundAsync(slice, startUtc).ConfigureAwait(false); + clusterCp = await AppendEndBoundAsync(slice, startUtc, readCap).ConfigureAwait(false); } results[handle.Index] = new SdkHistoryReadResult @@ -7105,7 +7109,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The data is drained — the end bound ends this page, or is the next one when this one is full. var page = new List(samples.Count + 1); page.AddRange(samples); - outboundCp = await AppendEndBoundAsync(page, startUtc).ConfigureAwait(false); + // This page's allowance: the (ceiling-adjusted) data cap, plus the start bound it already leads with. + var allowance = readCap == 0 ? 0u : readCap + (startBound is not null ? 1u : 0u); + outboundCp = await AppendEndBoundAsync(page, startUtc, allowance).ConfigureAwait(false); samples = page; historyData = ToHistoryDataFromSamples(samples); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs index 8b618b9e..74096a4f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs @@ -292,6 +292,35 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05"); } + /// + /// #736 (H1 fuzz) — a backend row ceiling sizes the page BELOW NumValuesPerNode (RawPageLimit: ceiling 4 ⇒ 3 data + /// rows per page). When the data exactly fills that page, the end bound must go to the NEXT page through the + /// continuation point, not be appended to a page that is already at its effective cap: the fit used to be + /// judged against NumValuesPerNode (10 here), so the page came back with cap + 1 values. + /// + [Fact] + public async Task A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page() + { + var source = new SeriesSource(TableData(), reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: 4); + var (first, error, cp) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp: null); + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + + // Start bound 5:00 + the three data rows the ceiling allows = the page's effective cap, exactly. + Render(first, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05"); + cp.ShouldNotBeNull("the page is full, so the end bound is owed on the next page"); + + var (second, error2, cp2) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp); + error2.StatusCode.Code.ShouldBe(StatusCodes.Good); + Render(second, T("5:01"), T("5:06")).ShouldBe("5:06"); + cp2.ShouldBeNull(); + + // Control: without a ceiling the same read fits on ONE page of NumValuesPerNode — the fit keys on the + // effective cap, not on a smaller constant. + var (whole, _, cpWhole) = await ReadAsync(Source(Shape.Reverse, TableData()), T("5:01"), T("5:06"), max: 10, bounds: true, cp: null); + Render(whole, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05, 5:06"); + cpWhole.ShouldBeNull(); + } + /// /// #691 review: no data AND no bound found ⇒ GoodNoData with no values — the answer a tag with no history (and a /// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only @@ -468,7 +497,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : /// a point read is the instant. > 1 truncates every window bound to it first (QuestDB /// µs); the series must already be stored at that resolution. /// - private sealed class SeriesSource(IReadOnlyList series, bool reverse, bool inclusiveForwardEnd, long resolution) + private sealed class SeriesSource(IReadOnlyList series, bool reverse, bool inclusiveForwardEnd, long resolution, int ceiling = 0) : IHistorianDataSource { public int ReverseWindowsOnForwardOnly; @@ -477,6 +506,9 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : public long TimestampResolutionTicks => resolution; + /// A strict row ceiling (0 = none), so a page is sized below NumValuesPerNode (RawPageLimit). + public int MaxRawValuesPerRead => ceiling; + private DateTime Trunc(DateTime t) => new(t.Ticks - t.Ticks % resolution, DateTimeKind.Utc); public Task ReadRawAsync( From be22b48b1d00ba12421a41f14d7b57c64abc7b8b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:45:24 -0400 Subject: [PATCH 09/13] feat(opcua): advertise the historian raw cap as MaxReturnDataValues on source sync Lane H1 follow-through: SyncAdvertisedAggregates now also calls HistoryPaging.AdvertiseMaxReturnDataValues(Server, source .MaxRawValuesPerRead) so HistoryServerCapabilities/MaxReturnDataValues (i=11273) follows the historian source on every assignment (the Null default advertises 0 = no limit); a false return logs a Warning (the cap is enforced by HistoryRead regardless). Test: NodeManagerMaxReturnDataValuesSyncTests reads i=11273 back through the SDK master node manager after setting a capped source (7000, 250) and after resetting to the Null source (0). Refs #732 #736 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 9 +++ ...NodeManagerMaxReturnDataValuesSyncTests.cs | 67 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 9e6091e9..6f536cb0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -458,6 +458,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 advertised.Count, source.GetType().Name, string.Join(", ", advertised.Select(id => AggregateFunctionMap.Map(id)?.ToString() ?? id.ToString()))); } + + // #736: HistoryServerCapabilities/MaxReturnDataValues (i=11273) carries the source's raw-page cap + // (0 = unlimited), re-synced on every source assignment like the aggregate set above. + if (!HistoryPaging.AdvertiseMaxReturnDataValues(Server, source.MaxRawValuesPerRead)) + { + _logger.LogWarning( + "OtOpcUaNodeManager: could not advertise HistoryServerCapabilities/MaxReturnDataValues ({Cap}) for historian {Source}; the cap is still enforced by HistoryRead", + source.MaxRawValuesPerRead, source.GetType().Name); + } } catch (Exception ex) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs new file mode 100644 index 00000000..ea47eedd --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs @@ -0,0 +1,67 @@ +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// #736 — assigning re-syncs +/// HistoryServerCapabilities/MaxReturnDataValues (i=11273) to the source's +/// , read back the way a client would. The Null +/// source (the historian-disabled default) advertises Part 11's "no limit". +/// +public sealed class NodeManagerMaxReturnDataValuesSyncTests(BoundsServerFixture fx) : IClassFixture +{ + [Fact] + public async Task Setting_the_historian_source_advertises_its_raw_cap() + { + await fx.Gate.WaitAsync(TestContext.Current.CancellationToken); + try + { + fx.Nm.HistorianDataSource = new CappedSource(7_000); + (await ReadAdvertisedAsync()).ShouldBe(7_000u); + + fx.Nm.HistorianDataSource = new CappedSource(250); + (await ReadAdvertisedAsync()).ShouldBe(250u); + + fx.Nm.HistorianDataSource = null!; // the Null default: no cap + (await ReadAdvertisedAsync()).ShouldBe(0u); + } + finally + { + fx.Nm.HistorianDataSource = null!; + fx.Gate.Release(); + } + } + + private async Task ReadAdvertisedAsync() + { + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null); + var nodesToRead = new ReadValueIdCollection + { + new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value }, + }; + var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken); + values.Count.ShouldBe(1); + StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString()); + return (uint)values[0].Value; + } + + private sealed class CappedSource(int cap) : IHistorianDataSource + { + public int MaxRawValuesPerRead => cap; + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + public void Dispose() { } + } +} From 0117e1501b2b1926b5263a1b68a0219e0d273467 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 13:29:07 -0400 Subject: [PATCH 10/13] fix(opcua): trace every rebuild path; align history tests with the B7 contract B6 moved the address-space-rebuild span into ApplyPlan, so the two rebuilds that complete synchronously on the actor thread (no applier -> raw-sink rebuild; no artifact bytes -> held) stopped emitting it (OtOpcUaTelemetryHookTests.RebuildAddressSpace_starts_an_address_space_rebuild_span). Both now open their own span. B7 (late HistorianDataSource / HistorianDataUpdater re-evaluate the capability bits) reversed three tests that pinned the old "fixed at materialisation" contract. Re-expressed against the new one, end to end: - events: a folder promoted without a source is unsupported, then served once a source is wired (source reached); - in-process HistoryUpdate: clearing the updater retracts HistoryWrite and the old updater is never reached; - over the wire: a late updater lights HistoryWrite and an insert reaches it; a reset retracts the bit and the insert is BadHistoryOperationUnsupported. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OpcUa/OpcUaPublishActor.cs | 6 ++++ .../HistoryUpdateOverTheWireTests.cs | 29 ++++++++++++++----- .../History/HistoryUpdateTests.cs | 18 ++++++++---- .../NodeManagerHistoryReadEventsTests.cs | 28 +++++++++--------- 4 files changed, 55 insertions(+), 26 deletions(-) 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 cbe96919..702396a8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -516,6 +516,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // real driver node that has an applier (that node diff-and-applies from its in-hand bytes below). if (_applier is null) { + // Every rebuild is traced, whichever path completes it: the applier path opens its span in ApplyPlan + // (the actor-thread half), the two synchronous completions below open their own. + using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); + span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); (bool Degraded, string? Reason) outcome; try { @@ -541,6 +545,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // supply a real one. Leaving _lastApplied intact means the retry diffs against what is materialised. if (msg.Artifact is null or { Length: 0 }) { + using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); + span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); if (_hasAppliedComposition) _log.Warning( "OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs index 43fa05e2..e3eb2acc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs @@ -215,11 +215,13 @@ public sealed class HistoryUpdateOverTheWireTests } /// - /// Control (no retro-update): materialising the node BEFORE the updater is installed leaves it without - /// the HistoryWrite bit, so the SDK base answers BadHistoryOperationUnsupported — even for an editor. + /// Review 01 U3 (B7): an updater installed AFTER the node materialised re-composes the HistoryWrite bit on + /// the existing node (no rebuild), so an editor's insert reaches the updater over the wire; clearing the + /// updater again retracts the bit and the SDK base answers BadHistoryOperationUnsupported. (Before B7 the + /// bit was fixed at materialisation and a late install stayed unsupported.) /// [Fact] - public async Task An_updater_installed_after_the_node_materialised_is_unsupported() + public async Task An_updater_installed_after_the_node_materialised_grants_history_write_and_a_reset_takes_it_back() { var ct = TestContext.Current.CancellationToken; var store = new SharedStore(); @@ -228,16 +230,27 @@ public sealed class HistoryUpdateOverTheWireTests { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor)); var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname); - rig.Nm.HistorianDataUpdater = store; using var session = await rig.ConnectAsync(anonymous: false, ct); var node = RawNode(session, id); (await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite) - .ShouldBe(0, "the bit is fixed at materialisation"); + .ShouldBe(0, "no updater at materialisation (non-vacuity for the refresh)"); - var result = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0)); - result.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - store.UpdateCalls.ShouldBe(0); + rig.Nm.HistorianDataUpdater = store; + + (await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite) + .ShouldBe(AccessLevels.HistoryWrite, "a late updater lights the bit on the existing node"); + var ok = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0)); + ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + store.UpdateCalls.ShouldBe(1); + + rig.Nm.HistorianDataUpdater = null; + + (await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite) + .ShouldBe(0, "a reset updater takes the bit back"); + var refused = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T2, 2.0)); + refused.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + store.UpdateCalls.ShouldBe(1, "the reset updater is never reached"); } finally { await rig.DisposeAsync(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs index 12665523..da4f1a57 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs @@ -223,8 +223,10 @@ public sealed class HistoryUpdateTests : IDisposable } /// - /// An updater removed AFTER materialisation (AccessLevel is not retro-updated) still yields unsupported - /// rather than a null dereference — the override re-checks the updater per request. + /// An updater removed AFTER materialisation retracts the node's HistoryWrite bit (review 01 U3 / B7), so + /// the SDK base refuses the insert with BadHistoryOperationUnsupported before our override runs and the + /// old updater is never reached. (Only errors is asserted: on the SDK-base refusal path the + /// per-node result is left for the master node manager to fill from it.) /// [Fact] public async Task An_updater_cleared_after_materialisation_is_unsupported() @@ -233,15 +235,21 @@ public sealed class HistoryUpdateTests : IDisposable try { var nm = server.NodeManager!; - nm.HistorianDataUpdater = new FakeUpdater(); + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + (nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite) + .ShouldNotBe((byte)0, "granted while the updater was wired (non-vacuity)"); + nm.HistorianDataUpdater = null; - var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); + (nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite) + .ShouldBe((byte)0, "clearing the updater retracts the bit"); + var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + updater.UpdateCalls.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs index 36679959..1984fdb8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs @@ -211,25 +211,21 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable } /// A folder promoted to an event notifier while NO historian was wired (Null source at - /// materialise time) is NOT registered as an event-history source ⇒ a HistoryReadEvents over it yields - /// BadHistoryOperationUnsupported, and the (later-wired) source is never invoked. + /// materialise time) is not an event-history source ⇒ a HistoryReadEvents over it yields + /// BadHistoryOperationUnsupported. Wiring a real source AFTER promotion re-evaluates the folder + /// (review 01 U3 / B7 — before B7 it stayed unsupported until a rebuild), so the same read then reaches + /// the source. [Fact] - public async Task Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported() + public async Task Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - // Materialise the alarm while the source is still the Null default — the folder is promoted to - // SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration. const string equipmentId = "eq-nosrc"; nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; - // Wire a real source AFTER promotion — it must NOT retroactively make the folder a source. - var fake = new RecordingHistorianDataSource(); - nm.HistorianDataSource = fake; - var details = new ReadEventDetails { StartTime = DateTime.UtcNow.AddHours(-1), @@ -238,11 +234,17 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable Filter = SelectFilter("EventId"), }; + var (_, unsupported) = InvokeHistoryRead(server, nm, details, notifierNodeId); + unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + + var fake = new RecordingHistorianDataSource(); + nm.HistorianDataSource = fake; + var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - fake.LastSourceName.ShouldBeNull(); // source never reached - fake.EventsCalled.ShouldBeFalse(); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + fake.EventsCalled.ShouldBeTrue(); + fake.LastSourceName.ShouldBe(equipmentId); await host.DisposeAsync(); } @@ -258,7 +260,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable /// the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins /// that base-level rejection of variable nodes for event reads. /// The override's own source-guard (miss in _eventNotifierSources) is exercised by the - /// Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported test instead. + /// Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired test instead. /// /// [Fact] From f966efcfad1832b9816fce17f5387aeac574097f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:30:11 -0400 Subject: [PATCH 11/13] fix(opcua): replay writes deferred during an off-thread plan; cache-ref and guard fixes (Lane B fix round 1) C1 (critical): B6 computes the plan off the pinned thread while B1 made writes non-creating, so a value/status/alarm write for a node the in-flight plan adds was skipped, and change-driven drivers never republish it (BadWaitingForInitialData indefinitely; on a full rebuild every re-subscribe value landed on discarded nodes). OpcUaPublishActor now applies writes immediately AND remembers the latest per (realm, id) while a plan is in flight or queued (value+status compose; alarm state/quality coalesce to the latest, which the node's delta gate makes inert on an existing condition), replays it after each apply before answering, and drops it once the pipeline is idle. Bounded (250k keys by default; beyond it new keys are not held, logged once). I2: the data-plane-authority parse runs in its own try inside the plan task; a throw is logged and never degrades the address-space apply. I3: the SDK's DeleteMonitoredItems releases an item's component-cache reference on a Good delete; a tombstoned item's reference was already released, so the delete took one from the re-created node's entry. Released items are remembered and the orphan delete takes one back first. Event items never held a reference (EventManager + SubscribeToEvents), so the three event-item releases are gone. I4: direct tests for the HistoryUpdate override's per-request updater check and the HistoryReadEvents override's source guard (both reached by restoring the bit after a reset - the B7 race window); fixed the false remark. Minors: the MaterialiseAlarmCondition kind-swap tombstones the dropped instance; a bytes-less rebuild arriving mid-plan queues behind it (replies in order); a PlanReady/PlanFailed from a previous actor incarnation is ignored and its requester answered degraded; the tie-cluster end bound of an uncapped read fits against the backend ceiling (defence in depth - guarded by a sweep test); the lock-time benchmark stays in the default run, headroom noted. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 49 +- .../OpcUa/OpcUaPublishActor.cs | 324 ++++++++++++- .../StaleMonitoredNodeWireTests.cs | 35 ++ .../History/HistoryUpdateTests.cs | 32 ++ .../NodeManagerHistoryReadBoundsTests.cs | 48 ++ .../NodeManagerHistoryReadEventsTests.cs | 43 +- .../NodeManagerLockTimeBenchmarkTests.cs | 5 + .../NodeManagerStaleMonitoredNodeTests.cs | 33 ++ .../OpcUaPublishActorDeferredWriteTests.cs | 438 ++++++++++++++++++ 9 files changed, 977 insertions(+), 30 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 6f536cb0..6b6f5970 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1494,6 +1494,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown // path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace. UnwireAlarmNotifiers(conditionKey); + // A removal path like any other (review 01 L2): the dropped instance's items are told and its + // fan-out forgotten, or a new item on the swapped-in condition would bind to the dead one. + TombstoneMonitoredSubtree(existing, UtcNow); existing.Parent?.RemoveChild(existing); ForgetPredefinedSubtree(existing); // The materialise tail raises only the new instance's unit (no full reconcile), so give the @@ -4413,6 +4416,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 || current.DataChangeMonitoredItems?.Contains(dataItem) != true)) { OnMonitoredItemDeleted(context, handle, dataItem); + + // Review fix I3 — the SDK's DeleteMonitoredItems releases the item's component-cache reference + // whenever this returns Good. A tombstoned item's reference was ALREADY released by the removal, so + // that second release would take one from the RE-CREATED node's entry (a new item on the same NodeId) + // and evict it while still referenced. Take one back first so the SDK's release nets to zero — + // returning a non-Good status instead would fail the client's delete. + if (_cacheReferenceReleased.TryGetValue(dataItem, out _)) + { + _cacheReferenceReleased.Remove(dataItem); + AddNodeToComponentCache(context, handle, handle.Node); + } + return ServiceResult.Good; } } @@ -4472,11 +4487,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (monitored.EventMonitoredItems is { Count: > 0 } eventItems) { - foreach (var item in eventItems.ToArray()) - { - _eventItems.TryRemove(item, out _); - ReleaseComponentCacheReference(item); - } + // No component-cache release: an event item never took a reference (the master node manager + // creates it through the EventManager + SubscribeToEvents, not AddNodeToComponentCache), so a + // release here would take one from a DATA item on the same NodeId. + foreach (var item in eventItems.ToArray()) _eventItems.TryRemove(item, out _); } } @@ -4534,7 +4548,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; if (!_eventItems.TryRemove(item, out _)) continue; Interlocked.Decrement(ref _suppressedItems); - ReleaseComponentCacheReference(item); + // Event items hold no component-cache reference (see TombstoneMonitoredNode). } } @@ -4555,13 +4569,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - /// Gives back the component-cache reference the SDK took for at create, so - /// the cache stops handing out the removed node for that NodeId. + /// Gives back the component-cache reference the SDK took for the DATA item at + /// create, so the cache stops handing out the removed node for that NodeId — and remembers that it did, so the + /// item's later client delete does not give it back a second time (see ). private void ReleaseComponentCacheReference(IMonitoredItem item) { - if (item.ManagerHandle is NodeHandle handle) RemoveNodeFromComponentCache(SystemContext, handle); + if (item.ManagerHandle is not NodeHandle handle) return; + RemoveNodeFromComponentCache(SystemContext, handle); + _cacheReferenceReleased.AddOrUpdate(item, ReleasedMarker); } + /// Data items whose component-cache reference a removal already gave back (review fix I3). Weak, so an + /// item the SDK drops without a delete (it always deletes, but a leak here would be silent) is not kept alive. + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _cacheReferenceReleased = new(); + private static readonly object ReleasedMarker = new(); + /// Test observability: how many NodeIds currently carry a fan-out. internal int MonitoredNodeCount { @@ -5352,8 +5374,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { _eventItems.TryRemove(item, out _); Interlocked.Decrement(ref _suppressedItems); - ReleaseComponentCacheReference(item); - continue; + continue; // an event item holds no component-cache reference (see TombstoneMonitoredNode) } SubscribeToEvents(SystemContext, tracked.Handle.Node, item, unsubscribe: !permitted); @@ -7065,7 +7086,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else if (endBound is not null) { // The data is drained — the end bound ends this page, or is the next one when this one is full. - clusterCp = await AppendEndBoundAsync(slice, startUtc, readCap).ConfigureAwait(false); + // An uncapped read (readCap 0) on a backend with a row ceiling is still a page of at most that + // ceiling — the MaxReturnDataValues it advertises — so the bound fits against it, not "unbounded". + // (Today the over-fetch bound keeps a cluster slice at most L − 1, so this is defence in depth.) + var clusterAllowance = readCap > 0 ? readCap : pageLimit > 0 ? (uint)pageLimit : 0u; + clusterCp = await AppendEndBoundAsync(slice, startUtc, clusterAllowance).ConfigureAwait(false); } results[handle.Index] = new SdkHistoryReadResult 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 702396a8..833525a0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -132,16 +132,56 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// which are the only part that must run on this actor's thread. Carries the and /// the original so the reply reaches whoever asked. /// + /// stamps the actor instance that started the plan: a PipeTo(Self) + /// outlives a restart (the ActorRef survives it), and a plan computed against a previous instance's + /// must not be applied by the next one. carries a + /// data-plane-authority parse failure, which never fails the address-space apply (review fix I2). + /// private sealed record PlanReady( - RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact, DataPlaneAuthorityProjection? Authority, + object Incarnation, RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact, + DataPlaneAuthorityProjection? Authority, Exception? AuthorityError, AddressSpaceComposition Composition, AddressSpacePlan Plan, IReadOnlyList Inconsistencies); /// The off-thread parse / plan step threw (unreadable bytes, a composer fault). - private sealed record PlanFailed(RebuildAddressSpace Request, IActorRef Requester, Exception Error); + private sealed record PlanFailed(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error); + + /// Test seams for the off-thread plan step. Production passes none. + public sealed class TestHooks + { + /// Runs on the thread pool at the start of every plan step (a test blocks it to hold a plan in flight). + public Action? BeforePlan { get; init; } + + /// Replaces (a test makes it throw). + internal Func? ParseAuthority { get; init; } + } + + /// The default bound on : distinct (realm, id) keys held while a plan is in flight. + public const int DefaultDeferredWriteCapacity = 250_000; + + /// Identity of THIS actor instance (see ). + private readonly object _incarnation = new(); + private readonly TestHooks? _hooks; + + /// + /// Review fix C1 — the latest value / status / alarm write per (realm, id) received while a rebuild's plan is + /// in flight (or queued), replayed through the sink after each apply. Writes are non-creating (review 01 L1), + /// so a driver's first publish for a node the in-flight plan ADDS used to be skipped — and a change-driven + /// driver (or a poll group whose last-value cache already holds it) never sends it again, leaving the node + /// BadWaitingForInitialData indefinitely. On a full rebuild the same holds for every node: the + /// re-subscribe's first values land on old nodes the apply then discards. + /// Writes are still applied IMMEDIATELY (existing nodes keep updating during the plan); the buffer + /// only remembers each key's latest so the replay can land it on a node the apply created. Replaying onto a + /// node that already holds it is inert: the same value/status/timestamp is filtered by every monitored + /// item's data-change filter, and an alarm write is delta-gated against the node's current state. That is + /// also why alarms coalesce to the latest state per id rather than replaying every transition: replaying an + /// older transition onto a live condition would move it backwards and fire spurious events. + /// + private readonly DeferredWrites _deferred; /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in /// arrival order — the plan is a diff against , which only the apply advances — so a - /// rebuild arriving meanwhile waits in ; value and status batches do not. + /// rebuild arriving meanwhile waits in ; value, status and alarm writes do not — they + /// are applied at once AND remembered in for replay after the apply. private bool _planInFlight; private readonly Queue<(RebuildAddressSpace Request, IActorRef Requester)> _pendingRebuilds = new(); @@ -309,6 +349,18 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers dataPlaneAuthority, utcNow)); + /// Test-only Props exposing the deferred-write bound and the plan-step seams. + internal static Props PropsForTests( + IOpcUaAddressSpaceSink sink, + AddressSpaceApplier? applier, + TestHooks? hooks, + int deferredWriteCapacity = DefaultDeferredWriteCapacity, + NodeId? localNode = null, + IDataPlaneAuthoritySink? dataPlaneAuthority = null) => + Akka.Actor.Props.Create(() => new OpcUaPublishActor( + sink, NullServiceLevelPublisher.Instance, false, localNode, null, applier, null, null, null, null, + false, dataPlaneAuthority, null, deferredWriteCapacity, hooks)); + /// Initializes a new instance of the class. /// The OPC UA address space sink. /// The service level publisher. @@ -331,6 +383,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// ServiceLevel DB-reachability input is retired (DbReachable treated constant-true, only /// staleness demotes). No is wired on such a node. /// The optional sink that receives the API keys and ACL grants carried by each applied artifact. + /// The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight. + /// Test seams for the off-thread plan step; null in production. public OpcUaPublishActor( IOpcUaAddressSpaceSink sink, IServiceLevelPublisher serviceLevel, @@ -344,8 +398,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? healthTickInterval = null, bool dbLess = false, IDataPlaneAuthoritySink? dataPlaneAuthority = null, - Func? utcNow = null) + Func? utcNow = null, + int deferredWriteCapacity = DefaultDeferredWriteCapacity, + TestHooks? hooks = null) { + _deferred = new DeferredWrites(Math.Max(1, deferredWriteCapacity)); + _hooks = hooks; _sink = sink; _dataPlaneAuthority = dataPlaneAuthority; _serviceLevel = serviceLevel; @@ -400,6 +458,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers { try { + if (PlanPipelineBusy) DeferValue(new ValueWrite(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm)); _sink.WriteValue(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm); Interlocked.Increment(ref _writes); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "value")); @@ -421,6 +480,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// a whole batch because one item was bad. private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg) { + if (PlanPipelineBusy) + foreach (var item in msg.Items) DeferValue(item); try { _sink.WriteValues(msg.Items); @@ -451,6 +512,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// value write on the same counter. private void HandleAttributeStatusBatchUpdate(AttributeStatusBatchUpdate msg) { + if (PlanPipelineBusy) + foreach (var item in msg.Items) DeferStatus(item); try { _sink.WriteStatuses(msg.Items); @@ -474,6 +537,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private void HandleAlarmUpdate(AlarmStateUpdate msg) { + if (PlanPipelineBusy) DeferAlarm(msg); try { _sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm); @@ -488,6 +552,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg) { + if (PlanPipelineBusy) DeferAlarmQuality(msg); try { _sink.WriteAlarmQuality(msg.AlarmNodeId, msg.StatusCode, msg.TimestampUtc, msg.Realm); @@ -511,6 +576,16 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// private void StartRebuild(RebuildAddressSpace msg, IActorRef requester) { + // Strict arrival order for EVERY rebuild, including the ones that complete synchronously (no applier, no + // bytes): a bytes-less request arriving mid-plan used to answer before the plan ahead of it, so replies went + // out of order. HandleRebuild is the only entry that can find the pipeline busy; the drain calls this only + // when it is idle. + if (_planInFlight) + { + _pendingRebuilds.Enqueue((msg, requester)); + return; + } + // Only a node with NO applier at all (the F10b/dev seam) falls back to the raw-sink rebuild — and that // fallback WIPES the served address space without re-materialising, so it must NEVER be reached by a // real driver node that has an applier (that node diff-and-applies from its in-hand bytes below). @@ -559,36 +634,58 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers return; } - if (_planInFlight) - { - _pendingRebuilds.Enqueue((msg, requester)); - return; - } - _planInFlight = true; var artifact = msg.Artifact; var baseComposition = _lastApplied; var nodeId = _localNode?.Value; var wantAuthority = _dataPlaneAuthority is not null; + var incarnation = _incarnation; + var hooks = _hooks; Task.Run(() => { + hooks?.BeforePlan?.Invoke(); // Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an EMPTY // result — and an empty composition is not a benign no-op here (see the no-bytes guard above). The // throw lands in PlanFailed: logged at Error, _lastApplied untouched, last-known-good still served. using var parsed = DeploymentArtifact.Open(artifact); - var authority = wantAuthority ? parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty) : null; + // The authority parse is isolated from the address-space plan (review fix I2): before the plan moved + // off-thread a throw here was contained by FeedDataPlaneAuthority's own catch; it must not now + // become PlanFailed and degrade an apply whose topology is perfectly readable. + DataPlaneAuthorityProjection? authority = null; + Exception? authorityError = null; + if (wantAuthority) + { + try + { + authority = hooks?.ParseAuthority is { } parse + ? parse(parsed, nodeId ?? string.Empty) + : parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty); + } + catch (Exception ex) + { + authorityError = ex; + } + } + var inconsistencies = new List(); var composition = nodeId is not null ? parsed.ParseComposition(nodeId, inconsistencies.Add) : parsed.ParseComposition(); var plan = AddressSpacePlanner.Compute(baseComposition, composition); - return new PlanReady(msg, requester, artifact, authority, composition, plan, inconsistencies); + return new PlanReady(incarnation, msg, requester, artifact, authority, authorityError, composition, plan, inconsistencies); }) - .PipeTo(Self, failure: ex => new PlanFailed(msg, requester, ex)); + .PipeTo(Self, failure: ex => new PlanFailed(incarnation, msg, requester, ex)); } private void HandlePlanReady(PlanReady ready) { + if (IsFromAnotherIncarnation(ready.Incarnation, ready.Request, ready.Requester)) return; + + if (ready.AuthorityError is { } authorityError) + _log.Error(authorityError, + "OpcUaPublish: data-plane authority parse threw (correlation={Correlation}) — the address space is applied; the previously installed keys and grants stay in force", + ready.Request.Correlation); + (bool Degraded, string? Reason) outcome; try { @@ -600,20 +697,211 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } FinishRebuild(ready.Request, ready.Requester, outcome); - StartNextPendingRebuild(); + AfterPlanSettled(); } private void HandlePlanFailed(PlanFailed failed) { + if (IsFromAnotherIncarnation(failed.Incarnation, failed.Request, failed.Requester)) return; + _planInFlight = false; _log.Error(failed.Error, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", failed.Request.Correlation); FinishRebuild(failed.Request, failed.Requester, (true, $"address-space apply threw: {failed.Error.Message}")); - StartNextPendingRebuild(); + AfterPlanSettled(); } + /// A plan piped back to a RESTARTED instance (the ActorRef survives a restart) was computed against the + /// previous instance's : never applied. Its requester is still owed an answer. + private bool IsFromAnotherIncarnation(object incarnation, RebuildAddressSpace request, IActorRef requester) + { + if (ReferenceEquals(incarnation, _incarnation)) return false; + _log.Warning( + "OpcUaPublish: ignoring a rebuild plan started by a previous incarnation of this actor (correlation={Correlation})", + request.Correlation); + if (request.ReportOutcome) + requester.Tell(new RebuildCompleted(request.Correlation, request.DeploymentId, true, + "the address-space publisher restarted while this rebuild was being planned; it was not applied")); + return true; + } + + /// After an apply (or a failed plan) has been answered — its deferred writes already replayed by + /// — start what queued behind it, and drop the buffer once nothing is in flight or + /// queued (a queued plan may add nodes the buffer names, so it is replayed again after that apply). + private void AfterPlanSettled() + { + StartNextPendingRebuild(); + if (!PlanPipelineBusy) _deferred.Clear(); + } + + /// Starts queued rebuilds in arrival order until one goes off-thread (or the queue empties) — the + /// synchronous completions (no bytes) answer in their turn. private void StartNextPendingRebuild() { - if (_pendingRebuilds.TryDequeue(out var next)) StartRebuild(next.Request, next.Requester); + while (!_planInFlight && _pendingRebuilds.TryDequeue(out var next)) + StartRebuild(next.Request, next.Requester); + } + + /// True while a plan is computing or a rebuild is queued behind one. + private bool PlanPipelineBusy => _planInFlight || _pendingRebuilds.Count > 0; + + private void DeferValue(in ValueWrite write) + { + if (!_deferred.Value(write)) WarnDeferredOverflow(); + } + + private void DeferStatus(in StatusWrite write) + { + if (!_deferred.Status(write)) WarnDeferredOverflow(); + } + + private void DeferAlarm(AlarmStateUpdate msg) + { + if (!_deferred.Alarm(msg)) WarnDeferredOverflow(); + } + + private void DeferAlarmQuality(AlarmQualityUpdate msg) + { + if (!_deferred.AlarmQuality(msg)) WarnDeferredOverflow(); + } + + private void WarnDeferredOverflow() + { + if (_deferred.Dropped == 1) + _log.Warning( + "OpcUaPublish: more than {Capacity} distinct nodes written while a rebuild plan is in flight — later new nodes are not held for replay (their first value lands only if the driver republishes it)", + _deferred.Capacity); + } + + /// Re-applies the deferred writes through the sink: values, then status-only annotations, then alarm + /// states and alarm qualities. Each pass isolated like the live handlers; not re-metered (counted on arrival). + private void ReplayDeferredWrites() + { + if (_deferred.IsEmpty) return; + var (values, statuses) = _deferred.VariableWrites(); + if (values.Count > 0) + { + try { _sink.WriteValues(values); } + catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred value writes threw", values.Count); } + } + + if (statuses.Count > 0) + { + try { _sink.WriteStatuses(statuses); } + catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); } + } + + foreach (var alarm in _deferred.Alarms) + { + try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); } + catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); } + } + + foreach (var quality in _deferred.AlarmQualities) + { + try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); } + catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); } + } + + _log.Debug("OpcUaPublish: replayed {Values} value, {Statuses} status and {Alarms} alarm writes deferred during a rebuild plan", + values.Count, statuses.Count, _deferred.AlarmCount); + } + + /// + /// The latest write per (realm, id) received while a plan is in flight — see . A value + /// followed by a status-only write for the same node composes to the value under the new status (exactly what + /// the two live writes leave on the node). Bounded by distinct keys across all kinds; + /// beyond it a NEW key is not held (an existing key still updates), counted in . + /// + private sealed class DeferredWrites(int capacity) + { + private struct VariableEntry + { + public bool HasValue; + public object? Value; + public uint Status; + public DateTime TimestampUtc; + } + + private readonly Dictionary<(AddressSpaceRealm, string), VariableEntry> _variables = new(); + private readonly Dictionary<(AddressSpaceRealm, string), AlarmStateUpdate> _alarms = new(); + private readonly Dictionary<(AddressSpaceRealm, string), AlarmQualityUpdate> _qualities = new(); + + public int Capacity { get; } = capacity; + public long Dropped { get; private set; } + public int Count => _variables.Count + _alarms.Count + _qualities.Count; + public bool IsEmpty => Count == 0; + public int AlarmCount => _alarms.Count + _qualities.Count; + public IEnumerable Alarms => _alarms.Values; + public IEnumerable AlarmQualities => _qualities.Values; + + public bool Value(in ValueWrite write) + { + if (string.IsNullOrEmpty(write.NodeId)) return true; + var key = (write.Realm, write.NodeId); + if (!_variables.ContainsKey(key) && !HasRoom()) return false; + _variables[key] = new VariableEntry + { + HasValue = true, Value = write.Value, Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc, + }; + return true; + } + + public bool Status(in StatusWrite write) + { + if (string.IsNullOrEmpty(write.NodeId)) return true; + var key = (write.Realm, write.NodeId); + if (_variables.TryGetValue(key, out var entry)) + { + entry.Status = write.StatusCode; + entry.TimestampUtc = write.SourceTimestampUtc; + _variables[key] = entry; + return true; + } + + if (!HasRoom()) return false; + _variables[key] = new VariableEntry { Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc }; + return true; + } + + public bool Alarm(AlarmStateUpdate msg) => Put(_alarms, (msg.Realm, msg.AlarmNodeId), msg); + + public bool AlarmQuality(AlarmQualityUpdate msg) => Put(_qualities, (msg.Realm, msg.AlarmNodeId), msg); + + public (List Values, List Statuses) VariableWrites() + { + var values = new List(); + var statuses = new List(); + foreach (var ((realm, id), e) in _variables) + { + if (e.HasValue) values.Add(new ValueWrite(id, e.Value, e.Status, e.TimestampUtc, realm)); + else statuses.Add(new StatusWrite(id, e.Status, e.TimestampUtc, realm)); + } + + return (values, statuses); + } + + public void Clear() + { + _variables.Clear(); + _alarms.Clear(); + _qualities.Clear(); + Dropped = 0; + } + + private bool Put(Dictionary<(AddressSpaceRealm, string), T> map, (AddressSpaceRealm, string) key, T value) + { + if (string.IsNullOrEmpty(key.Item2)) return true; + if (!map.ContainsKey(key) && !HasRoom()) return false; + map[key] = value; + return true; + } + + private bool HasRoom() + { + if (Count < Capacity) return true; + Dropped++; + return false; + } } /// @@ -631,6 +919,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers _log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation); } + // Land what arrived while the plan was in flight on the nodes the apply just created — before the answer, + // so "applied" means the deferred first values are on the address space too (review fix C1). + ReplayDeferredWrites(); + if (msg.ReportOutcome) requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason)); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs index 6cacc60e..6893fc2e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs @@ -139,6 +139,10 @@ public sealed class StaleMonitoredNodeWireTests "the new item must receive a value written after its creation"); lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); + // Review fix I3: the cache entry the new item holds is ITS reference alone — the old item's was released + // by the removal. + CacheRefCount(nm, nodeId).ShouldBe(1, "one live item, one component-cache reference"); + // The old item was told, and the dead node's fan-out is gone. await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); }, TimeSpan.FromSeconds(5), "the item on the removed node must be told BadNodeIdUnknown"); @@ -150,9 +154,18 @@ public sealed class StaleMonitoredNodeWireTests (oldItem.Status.Error is null || ServiceResult.IsGood(oldItem.Status.Error)).ShouldBeTrue( $"deleting the orphaned item must succeed, got {oldItem.Status.Error}"); + // The SDK releases a deleted item's cache reference on a Good delete; the old item's was released by the + // removal already, so a second release would evict the NEW item's live entry (review fix I3). + CacheRefCount(nm, nodeId).ShouldBe(1, "deleting the orphaned item must not take the new item's reference"); + // And the new item is unaffected by that delete. nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5)); + + // Positive control: deleting the live item gives its reference back. + subscription.RemoveItem(newItem); + await subscription.ApplyChangesAsync(ct); + CacheRefCount(nm, nodeId).ShouldBe(0); } finally { @@ -160,6 +173,28 @@ public sealed class StaleMonitoredNodeWireTests } } + /// The SDK's component-cache reference count for (private SDK state, read by + /// reflection: CustomNodeManager2.m_componentCache → CacheEntry.RefCount); 0 when no entry. + private static int CacheRefCount(OtOpcUaNodeManager nm, NodeId nodeId) + { + const System.Reflection.BindingFlags Any = + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; + var field = typeof(Opc.Ua.Server.CustomNodeManager2).GetField("m_componentCache", Any) + .ShouldNotBeNull("the SDK's component cache field (SDK internals moved?)"); + lock (nm.Lock) + { + if (field.GetValue(nm) is not System.Collections.IDictionary cache || !cache.Contains(nodeId)) return 0; + var entry = cache[nodeId]!; + var member = (object?)entry.GetType().GetField("RefCount", Any) ?? entry.GetType().GetProperty("RefCount", Any); + return member switch + { + System.Reflection.FieldInfo f => (int)f.GetValue(entry)!, + System.Reflection.PropertyInfo p => (int)p.GetValue(entry)!, + _ => throw new InvalidOperationException("CacheEntry.RefCount not found"), + }; + } + } + private static void Materialise(OtOpcUaNodeManager nm) { nm.EnsureFolder(Device, null, "dev1", AddressSpaceRealm.Raw); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs index da4f1a57..1ddf9a28 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs @@ -254,6 +254,38 @@ public sealed class HistoryUpdateTests : IDisposable finally { await host.DisposeAsync(); } } + /// + /// Review fix I4 — the override's OWN per-request updater check, which the bit retraction above now hides. + /// It is the B7 race window: a request the SDK base admitted on the HistoryWrite bit just before the updater + /// was cleared. Reconstructed by restoring the bit after the clear, so the request reaches the override with + /// no updater; it must answer unsupported on both slots (the override sets results itself) and never reach the + /// old updater. + /// + [Fact] + public async Task The_override_rechecks_the_updater_per_request_when_the_bit_is_still_set() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + nm.HistorianDataUpdater = null; + var variable = nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!; + variable.AccessLevel |= AccessLevels.HistoryWrite; + variable.UserAccessLevel |= AccessLevels.HistoryWrite; + + var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override, not left for the master node manager"); + updater.UpdateCalls.ShouldBeEmpty(); + } + finally { await host.DisposeAsync(); } + } + /// With an updater wired, both the raw node and its UNS mirror carry HistoryWrite; a non-historized node does not. [Fact] public async Task An_updater_grants_history_write_to_raw_and_uns_historized_nodes_only() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs index 74096a4f..4875ac61 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs @@ -321,6 +321,54 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : cpWhole.ShouldBeNull(); } + /// + /// Lane B review fix round 1 (minor): an UNCAPPED read (N = 0) against a backend with a row ceiling L pages at + /// L, and MaxReturnDataValues advertises L — so no page, the tie-cluster slices and their end bound included, + /// may carry more than L values; and the pages still concatenate to the uncapped single-shot answer. Swept + /// over ceilings and tie-cluster sizes up to what one over-fetch read can hold (L − 1). + /// + [Fact] + public async Task An_uncapped_read_never_returns_a_page_larger_than_the_backend_ceiling() + { + foreach (var ceiling in new[] { 2, 3, 4, 6 }) + { + for (var ties = 1; ties < ceiling; ties++) + { + var series = new List { Snap("5:00", 0) }; + for (var i = 0; i < ties; i++) series.Add(Snap("5:02", 10 + i)); + series.Add(Snap("5:03", 3)); + for (var i = 0; i < ties; i++) series.Add(Snap("5:05", 50 + i)); + series.Add(Snap("5:06", 6)); + + foreach (var (start, end) in new[] { ("5:01", "5:04"), ("5:00", "5:06"), ("5:01", "5:07"), ("5:06", "5:01") }) + { + var expected = Render( + (await ReadAsync(new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1), T(start), T(end), 0, true, null)).Values, + T(start), T(end)); + + var capped = new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: ceiling); + var all = new List(); + byte[]? cp = null; + var pages = 0; + do + { + var (values, error, next) = await ReadAsync(capped, T(start), T(end), 0, true, cp); + error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"L={ceiling} ties={ties} {start}->{end}"); + values.Count.ShouldBeLessThanOrEqualTo(ceiling, $"L={ceiling} ties={ties} {start}->{end} page {pages}"); + all.AddRange(values); + cp = next; + (++pages).ShouldBeLessThan(60, "paging must terminate"); + } + while (cp is not null); + + Render(all, T(start), T(end)).ShouldBe(expected, $"L={ceiling} ties={ties} {start}->{end}"); + } + } + } + + static DataValueSnapshot Snap(string at, double v) => new(v, StatusCodes.Good, T(at), T(at)); + } + /// /// #691 review: no data AND no bound found ⇒ GoodNoData with no values — the answer a tag with no history (and a /// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs index 1984fdb8..3df484bc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs @@ -249,6 +249,45 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable await host.DisposeAsync(); } + /// + /// Review fix I4 — the HistoryReadEvents override's OWN source guard. Every other "unsupported" case here is + /// answered by the SDK base, which admits only notifiers carrying the EventNotifier.HistoryRead bit — + /// and since B7 the bit and the source registration move together. So the guard is reached only by a request + /// the base admitted on the bit just before a source reset took the registration back; reconstructed by + /// restoring the bit after the reset. The override must answer unsupported on both slots and reach no source. + /// + [Fact] + public async Task The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var fake = new RecordingHistorianDataSource(); + nm.HistorianDataSource = fake; + nm.EnsureFolder("eq-guard", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-g", "eq-guard", "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns); + nm.IsEventHistorySource("eq-guard").ShouldBeTrue("non-vacuity: registered while the source was wired"); + + nm.HistorianDataSource = null!; + var folder = nm.TryGetFolder("eq-guard")!; + nm.IsEventHistorySource("eq-guard").ShouldBeFalse(); + folder.EventNotifier |= EventNotifiers.HistoryRead; + + var details = new ReadEventDetails + { + StartTime = DateTime.UtcNow.AddHours(-1), + EndTime = DateTime.UtcNow, + NumValuesPerNode = 10, + Filter = SelectFilter("EventId"), + }; + var (results, errors) = InvokeHistoryRead(server, nm, details, folder.NodeId); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard"); + fake.EventsCalled.ShouldBeFalse(); + + await host.DisposeAsync(); + } + /// A variable node targeted by a HistoryReadEvents request ⇒ BadHistoryOperationUnsupported; /// the source is never invoked. /// @@ -259,8 +298,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable /// SDK base therefore rejects it and does NOT pass it to our HistoryReadEvents override; /// the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins /// that base-level rejection of variable nodes for event reads. - /// The override's own source-guard (miss in _eventNotifierSources) is exercised by the - /// Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired test instead. + /// The override's own source-guard (a miss in _eventNotifierSources) is exercised by + /// The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source. /// /// [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs index 848529f3..865bef48 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs @@ -20,6 +20,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit /// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine. +/// Kept in the default run on purpose (not a Benchmark category the CI filter would have to learn to exclude): +/// measured after the fix at 5–6 ms (P1) and 58–64 ms (P2) on a loaded dev machine against the 1 s budget — +/// ≥ 15× headroom, so it does not flake on slow CI. The flip side: the pre-fix code (75–150 ms / 470–680 ms) also +/// fits 1 s at these sizes, so the wall-clock legs guard against gross (order-of-magnitude) regressions only; the +/// machine-independent P3 visit counter is the strict pin. /// [Trait("Category", "Benchmark")] public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) : IDisposable diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs index fcd966e5..5ddd6b71 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs @@ -93,6 +93,39 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable finally { await host.DisposeAsync(); } } + /// + /// Review fix round 1 (minor): the same-id kind swap (scripted ⇄ native) in MaterialiseAlarmCondition + /// drops the old condition instance — one more removal path, so it tombstones like the others. Believed + /// unreachable from a composition (native ids are RawPaths in Raw, scripted ids live in Uns), so it is driven + /// directly. + /// + [Fact] + public async Task A_condition_kind_swap_tells_and_forgets_items_on_the_dropped_instance() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment", AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: false); + var nodeId = new NodeId("al-1", nm.TryGetFolder("eq-1")!.NodeId.NamespaceIndex); + var handle = (NodeHandle)nm.GetManagerHandle(nodeId); + handle.ShouldNotBeNull("non-vacuity: the condition is materialised"); + var item = new StubItem(handle, Identity()); + nm.TrackLiveMonitoredItem(handle, item); + nm.Authorizer = Authorizer(permits: false); + nm.RevalidateMonitoredItems(); + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: stood down"); + + nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: true); + + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown, "the dropped instance's item is told"); + nm.LiveItemCount.ShouldBe(0); + ((NodeHandle)nm.GetManagerHandle(nodeId)).ShouldNotBeNull("positive control: the swapped-in condition exists"); + } + finally { await host.DisposeAsync(); } + } + /// Registers a stub item on and stands it down through a denying pass. private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs new file mode 100644 index 00000000..7b795e3d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -0,0 +1,438 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Akka.Actor; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; + +/// +/// Lane B review fix round 1. C1: the plan runs off the pinned thread (R02:P5) while writes are non-creating +/// (R01:L1), so a value, status or alarm write for a node the IN-FLIGHT plan adds used to be skipped — and a +/// change-driven driver never republishes it. The actor now remembers the latest write per node while a plan +/// is in flight and replays it after the apply. Every test holds the plan open with a gate, so "during the +/// plan" is deterministic, and runs against a sink that — like the real node manager — drops writes for nodes +/// that do not exist. +/// Also here: I2 (an authority parse throw never fails the apply), the bytes-less request queued behind an +/// in-flight plan, and a plan piped back to a restarted actor. +/// +public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase +{ + private const string NewTag = "Plant/Modbus/dev1/T1"; + private static readonly TimeSpan PlanBudget = TimeSpan.FromSeconds(30); + + [Fact] + public void A_value_for_a_node_the_in_flight_plan_adds_lands_on_that_node_after_the_apply() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(RawTagArtifact(3))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [new ValueWrite(NewTag, 7, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)])); + AwaitAssert(() => sink.SkippedWrites.ShouldContain(NewTag), PlanBudget); // non-vacuity: it really missed + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.Exists(NewTag).ShouldBeTrue("the plan materialised the node"); + sink.ValueOf(NewTag).ShouldBe((7, OpcUaStatus.Good, at), "the value sent while the plan was in flight must end up on the node"); + } + + [Fact] + public void A_status_after_a_value_composes_onto_the_new_node_in_order() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(RawTagArtifact(3))); + var t1 = DateTime.UtcNow; + var t2 = t1.AddSeconds(1); + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(NewTag, 7, OpcUaStatus.Good, t1, AddressSpaceRealm.Raw)])); + actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate([new StatusWrite(NewTag, OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Raw)])); + gate.Set(); + ExpectMsg(PlanBudget); + + sink.ValueOf(NewTag).ShouldBe((7, OpcUaStatus.BadNoCommunication, t2), "last known value under the later status"); + } + + [Fact] + public void Existing_nodes_keep_updating_while_the_plan_computes() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(RawTagArtifact(1))); + gate.Set(); + ExpectMsg(PlanBudget); + var existing = "Plant/Modbus/dev1/T0"; + sink.Exists(existing).ShouldBeTrue(); + + gate.Reset(); + actor.Tell(Rebuild(RawTagArtifact(3))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(existing, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)])); + + // Applied immediately, while the plan is still held open. + AwaitAssert(() => sink.ValueOf(existing).ShouldBe((5, OpcUaStatus.Good, at)), PlanBudget); + sink.Exists(NewTag).ShouldBeFalse("the plan is still in flight"); + + gate.Set(); + ExpectMsg(PlanBudget); + sink.ValueOf(existing).ShouldBe((5, OpcUaStatus.Good, at)); + } + + [Fact] + public void An_alarm_state_sent_during_the_plan_lands_on_the_condition_it_materialises() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(AlarmArtifact())); + var state = new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high"); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", state, DateTime.UtcNow, AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.SkippedAlarms.ShouldContain("al-1"), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.ConditionExists("al-1").ShouldBeTrue("the plan materialised the condition"); + sink.AlarmStateOf("al-1").ShouldBeSameAs(state); + sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.BadNoCommunication); + } + + [Fact] + public void The_deferred_buffer_is_bounded_under_a_flood_and_keeps_the_earliest_keys() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + sink, new AddressSpaceApplier(sink, NullLogger.Instance), + new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() }, deferredWriteCapacity: 2)); + + actor.Tell(Rebuild(RawTagArtifact(5))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(Enumerable.Range(0, 5) + .Select(i => new ValueWrite($"Plant/Modbus/dev1/T{i}", i, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)).ToList())); + // An existing key still updates when the buffer is full. + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite("Plant/Modbus/dev1/T0", 100, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)])); + AwaitAssert(() => sink.SkippedWrites.Count.ShouldBe(6), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.ValueOf("Plant/Modbus/dev1/T0")!.Value.Value.ShouldBe(100); + sink.ValueOf("Plant/Modbus/dev1/T1")!.Value.Value.ShouldBe(1); + for (var i = 2; i < 5; i++) + sink.ValueOf($"Plant/Modbus/dev1/T{i}").ShouldBeNull("beyond the bound a new key is not held"); + sink.ReplayedItems.ShouldBe(2); + } + + [Fact] + public void Writes_after_the_pipeline_drains_are_not_replayed_again() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(RawTagArtifact(3))); + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(NewTag, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)])); + gate.Set(); + ExpectMsg(PlanBudget); + var replayed = sink.ReplayedItems; + replayed.ShouldBe(1); + + // A later plan replays only what arrived during IT. + actor.Tell(Rebuild(RawTagArtifact(3))); + ExpectMsg(PlanBudget); + sink.ReplayedItems.ShouldBe(replayed); + } + + [Fact] + public void A_throwing_authority_parse_does_not_fail_the_apply() + { + var sink = new ExistenceSink(); + var authority = new RecordingAuthoritySink(); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + sink, new AddressSpaceApplier(sink, NullLogger.Instance), + new OpcUaPublishActor.TestHooks { ParseAuthority = (_, _) => throw new InvalidOperationException("bad grants") }, + dataPlaneAuthority: authority)); + + actor.Tell(Rebuild(RawTagArtifact(2))); + + var completed = ExpectMsg(PlanBudget); + completed.Degraded.ShouldBeFalse(completed.DegradedReason); + sink.Exists("Plant/Modbus/dev1/T1").ShouldBeTrue("the address space is applied"); + authority.Applies.ShouldBe(0, "nothing is installed from an authority that could not be read"); + + // Positive control: the same actor installs an authority that CAN be read. + var healthy = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + new ExistenceSink(), new AddressSpaceApplier(new ExistenceSink(), NullLogger.Instance), + hooks: null, dataPlaneAuthority: authority)); + healthy.Tell(Rebuild(RawTagArtifact(2))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + authority.Applies.ShouldBe(1); + } + + [Fact] + public void A_bytes_less_rebuild_arriving_mid_plan_answers_after_the_plan_ahead_of_it() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + var first = Rebuild(RawTagArtifact(2)); + var second = new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), Artifact: null, ReportOutcome: true); + actor.Tell(first); + actor.Tell(second); + ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // the bytes-less one must not overtake the held plan + + gate.Set(); + var a = ExpectMsg(PlanBudget); + var b = ExpectMsg(PlanBudget); + a.Correlation.ShouldBe(first.Correlation); + a.Degraded.ShouldBeFalse(); + b.Correlation.ShouldBe(second.Correlation); + b.Degraded.ShouldBeTrue(); + } + + [Fact] + public void A_plan_piped_back_to_a_restarted_actor_is_not_applied() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var childProps = OpcUaPublishActor.PropsForTests(sink, applier, new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() }); + var parent = Sys.ActorOf(Props.Create(() => new RestartingParent(childProps))); + parent.Tell(RestartingParent.GetChild.Instance); + var child = ExpectMsg(); + + var stale = Rebuild(RawTagArtifact(3)); + child.Tell(stale); + child.Tell(Kill.Instance); // the parent's strategy RESTARTS on any failure, ActorKilledException included + ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + + gate.Set(); + var answer = ExpectMsg(PlanBudget); + answer.Correlation.ShouldBe(stale.Correlation); + answer.Degraded.ShouldBeTrue("the requester is still answered"); + sink.Exists(NewTag).ShouldBeFalse("a plan from the previous incarnation must not be applied"); + + // Positive control: the restarted instance applies a fresh rebuild. + child.Tell(Rebuild(RawTagArtifact(3))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.Exists(NewTag).ShouldBeTrue(); + } + + // ───────────────────────────── helpers ───────────────────────────── + + private IActorRef Spawn(ExistenceSink sink, ManualResetEventSlim gate) => + Sys.ActorOf(OpcUaPublishActor.PropsForTests( + sink, new AddressSpaceApplier(sink, NullLogger.Instance), + new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() })); + + private static OpcUaPublishActor.RebuildAddressSpace Rebuild(byte[] artifact) => + new(CorrelationId.NewId(), DeploymentId.NewId(), artifact, ReportOutcome: true); + + private static byte[] RawTagArtifact(int tagCount) => + 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 = Enumerable.Range(0, tagCount).Select(i => new + { + TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", + DataType = "Float", AccessLevel = 0, TagConfig = "{}", + }).ToArray(), + UnsTagReferences = Array.Empty(), + ScriptedAlarms = Array.Empty(), + }); + + private static byte[] AlarmArtifact() => + JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, + UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, + Equipment = new[] + { + new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", MachineCode = "EQ-1" }, + }, + DriverInstances = Array.Empty(), + Tags = Array.Empty(), + ScriptedAlarms = new[] + { + new + { + ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "High", AlarmType = "AlarmCondition", + Severity = 500, MessageTemplate = "high", PredicateScriptId = "s-1", HistorizeToAveva = false, + Retain = true, Enabled = true, + }, + }, + Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return false;" } }, + }); + + /// Restarts its one child on any failure, and lets the test reach the child. + private sealed class RestartingParent : ReceiveActor + { + public sealed class GetChild { public static readonly GetChild Instance = new(); } + + public RestartingParent(Props childProps) + { + var child = Context.ActorOf(childProps, "publish"); + Receive(_ => Sender.Tell(child)); + } + + protected override SupervisorStrategy SupervisorStrategy() => + new OneForOneStrategy(_ => Directive.Restart); + } + + /// An authority sink that counts installs. + private sealed class RecordingAuthoritySink : IDataPlaneAuthoritySink + { + private int _applies; + public int Applies => Volatile.Read(ref _applies); + + public DataPlaneAuthoritySnapshot Apply( + string? clusterId, string revision, IReadOnlyList keys, + IReadOnlyList grants, + IReadOnlyDictionary> scopePaths) + { + Interlocked.Increment(ref _applies); + return new DataPlaneAuthorityState().Apply(clusterId, revision, keys, grants, scopePaths); + } + } + + /// + /// Models the node manager's non-creating writes: a write to a node that was never ensured (or was removed) + /// is skipped and recorded; a full sink rebuild forgets every node. counts the + /// items written after an apply closed and before the next apply starts — the actor's replay, since the + /// tests send nothing between releasing a plan and its answer. + /// + private sealed class ExistenceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink + { + private readonly object _gate = new(); + private readonly HashSet _variables = new(); + private readonly HashSet _conditions = new(); + private readonly Dictionary _values = new(); + private readonly Dictionary _alarmStates = new(); + private readonly Dictionary _alarmQualities = new(); + + public ConcurrentQueue SkippedWrites { get; } = new(); + public ConcurrentQueue SkippedAlarms { get; } = new(); + public int ReplayedItems; + + public bool Exists(string id) { lock (_gate) return _variables.Contains(id); } + public bool ConditionExists(string id) { lock (_gate) return _conditions.Contains(id); } + public (object? Value, uint Status, DateTime At)? ValueOf(string id) { lock (_gate) return _values.TryGetValue(id, out var v) ? v : null; } + public AlarmConditionSnapshot? AlarmStateOf(string id) { lock (_gate) return _alarmStates.GetValueOrDefault(id); } + public uint? AlarmQualityOf(string id) { lock (_gate) return _alarmQualities.TryGetValue(id, out var q) ? q : null; } + + public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm) => + WriteValues([new ValueWrite(nodeId, value, statusCode, ts, realm)]); + + public void WriteValues(IReadOnlyList values) + { + lock (_gate) + { + if (_applyClosed) ReplayedItems += values.Count; + foreach (var v in values) + { + if (!_variables.Contains(v.NodeId)) { SkippedWrites.Enqueue(v.NodeId); continue; } + _values[v.NodeId] = (v.Value, v.StatusCode, v.SourceTimestampUtc); + } + } + } + + public void WriteStatuses(IReadOnlyList statuses) + { + lock (_gate) + { + if (_applyClosed) ReplayedItems += statuses.Count; + foreach (var s in statuses) + { + if (!_variables.Contains(s.NodeId)) { SkippedWrites.Enqueue(s.NodeId); continue; } + var current = _values.GetValueOrDefault(s.NodeId); + _values[s.NodeId] = (current.Value, s.StatusCode, s.SourceTimestampUtc); + } + } + } + + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm) + { + lock (_gate) + { + if (!_conditions.Contains(alarmNodeId)) { SkippedAlarms.Enqueue(alarmNodeId); return; } + _alarmStates[alarmNodeId] = state; + } + } + + public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime ts, AddressSpaceRealm realm) + { + lock (_gate) + { + if (!_conditions.Contains(alarmNodeId)) { SkippedAlarms.Enqueue(alarmNodeId); return; } + _alarmQualities[alarmNodeId] = statusCode; + } + } + + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) + { + lock (_gate) { _applyClosed = false; _conditions.Add(alarmNodeId); } + } + + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) + { + lock (_gate) _applyClosed = false; + } + + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) + { + lock (_gate) { _applyClosed = false; _variables.Add(variableNodeId); } + } + + public void RebuildAddressSpace() + { + lock (_gate) + { + _applyClosed = false; + _variables.Clear(); + _conditions.Clear(); + _values.Clear(); + _alarmStates.Clear(); + _alarmQualities.Clear(); + } + } + + private bool _applyClosed; + + public void CompleteAddressSpaceApply() + { + lock (_gate) _applyClosed = true; + } + + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } + 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 bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) => true; + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true; + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { lock (_gate) return _variables.Remove(variableNodeId); } + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { lock (_gate) return _conditions.Remove(alarmNodeId); } + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) => true; + } +} From a8284f499e287c70c89eafc1d34a8129baffbbe1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:08:44 -0400 Subject: [PATCH 12/13] fix(opcua): scope the deferred-write replay to nodes the apply created (Lane B fix round 2) N1: a full alarm-state snapshot also sets the condition's Quality, so it now supersedes an earlier quality-only write for the same id in the deferred buffer; a quality arriving after the state is kept and replays after it. N2 / M1: the replay is not inert on a node the apply KEPT - an SDK-applied Acknowledge/Confirm/Shelve or an optimistic client Write in the window would be rolled back (with spurious Part 9 events). The replay is now scoped to nodes the apply created: variables the applier ensured during this apply (AddressSpaceApplier.BeginRecordingEnsuredVariables) or every variable after a full rebuild; conditions in the applied composition but not the previous one (also on a rebuild, which carries a kept condition's state over). Option (b), a sink that reports whether a live write landed, needs the Commons DeferredAddressSpaceSink seam outside this lane (follow-up F7). M2: the benchmark remark now matches its Category=Benchmark trait. M3: TestHooks and the seam-carrying constructor are internal; PropsForTests produces the actor through an IIndirectActorProducer. M4: pinned replay-nothing-and-drop-the-buffer after a failed plan. M5: PreRestart answers every queued rebuild requester (degraded). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 26 +- .../OpcUa/OpcUaPublishActor.cs | 162 ++++++++++-- .../NodeManagerLockTimeBenchmarkTests.cs | 3 +- .../OpcUaPublishActorDeferredWriteTests.cs | 235 +++++++++++++++++- 4 files changed, 400 insertions(+), 26 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 9d42e409..bb0f2752 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -1153,10 +1153,34 @@ public sealed class AddressSpaceApplier /// true when the variable was ensured; false when the sink threw. private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { - try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); return true; } + try + { + _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); + _ensuredVariables?.Add((realm, nodeId)); + return true; + } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } } + /// While non-null, every variable this applier ensures (realm, NodeId). See . + private HashSet<(AddressSpaceRealm, string)>? _ensuredVariables; + + /// + /// Starts recording every variable the following materialise passes ensure, until + /// . The publish actor scopes the replay of writes it deferred while a + /// plan was in flight to these (a surgical apply ensures only its delta), so a variable the apply kept is not + /// rewritten. Single-caller (the publish actor's thread), like the rest of the apply. + /// + /// The live set the recording fills. + public IReadOnlySet<(AddressSpaceRealm, string)> BeginRecordingEnsuredVariables() + { + _ensuredVariables = new HashSet<(AddressSpaceRealm, string)>(); + return _ensuredVariables; + } + + /// Stops the recording started (the returned set is kept). + public void EndRecordingEnsuredVariables() => _ensuredVariables = null; + // The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the // whole delta classification is one pure, table-testable unit; the applier references them there (and the // classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there 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 833525a0..94f2f24d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -146,17 +146,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private sealed record PlanFailed(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error); /// Test seams for the off-thread plan step. Production passes none. - public sealed class TestHooks + internal sealed class TestHooks { /// Runs on the thread pool at the start of every plan step (a test blocks it to hold a plan in flight). - public Action? BeforePlan { get; init; } + internal Action? BeforePlan { get; init; } /// Replaces (a test makes it throw). internal Func? ParseAuthority { get; init; } } /// The default bound on : distinct (realm, id) keys held while a plan is in flight. - public const int DefaultDeferredWriteCapacity = 250_000; + internal const int DefaultDeferredWriteCapacity = 250_000; /// Identity of THIS actor instance (see ). private readonly object _incarnation = new(); @@ -178,6 +178,32 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// private readonly DeferredWrites _deferred; + /// + /// Review fix round 2 (N2 / M1) — WHICH deferred writes the next replay may land: only the ones whose live write + /// could not have, i.e. onto nodes the apply just CREATED. A node the apply kept took the live write already, + /// and replaying it is not inert: a client changed it in the window (an SDK-applied Acknowledge / Confirm / + /// Shelve on a condition, an optimistic Write on a variable) and the replay would roll that back and fire + /// spurious Part 9 events. Set by , consumed by . + /// + /// Variables: after a FULL rebuild every one (each was recreated, its live write landed on the + /// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them). + /// Conditions: those in the applied composition but not the previous one — on a rebuild too, because the + /// node manager carries a kept condition's state over to its re-materialised instance. + /// + /// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through + /// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.) + /// + private ReplayScope _replayScope = ReplayScope.Nothing; + + private sealed record ReplayScope( + bool AllVariables, + IReadOnlySet<(AddressSpaceRealm, string)> Variables, + IReadOnlySet<(AddressSpaceRealm, string)> Conditions) + { + private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>(); + public static readonly ReplayScope Nothing = new(false, None, None); + } + /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in /// arrival order — the plan is a diff against , which only the apply advances — so a /// rebuild arriving meanwhile waits in ; value, status and alarm writes do not — they @@ -357,9 +383,19 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers int deferredWriteCapacity = DefaultDeferredWriteCapacity, NodeId? localNode = null, IDataPlaneAuthoritySink? dataPlaneAuthority = null) => - Akka.Actor.Props.Create(() => new OpcUaPublishActor( + // CreateBy, not Create(expression): Akka's expression path activates through a PUBLIC constructor, and the + // seam-carrying constructor is internal so the test seams stay off the public API (review M3). + Akka.Actor.Props.CreateBy(new TestProducer(() => new OpcUaPublishActor( sink, NullServiceLevelPublisher.Instance, false, localNode, null, applier, null, null, null, null, - false, dataPlaneAuthority, null, deferredWriteCapacity, hooks)); + false, dataPlaneAuthority, null, deferredWriteCapacity, hooks))); + + /// Produces the actor through its internal (seam-carrying) constructor, for . + private sealed class TestProducer(Func create) : IIndirectActorProducer + { + public Type ActorType => typeof(OpcUaPublishActor); + public ActorBase Produce() => create(); + public void Release(ActorBase actor) { } + } /// Initializes a new instance of the class. /// The OPC UA address space sink. @@ -383,8 +419,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// ServiceLevel DB-reachability input is retired (DbReachable treated constant-true, only /// staleness demotes). No is wired on such a node. /// The optional sink that receives the API keys and ACL grants carried by each applied artifact. - /// The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight. - /// Test seams for the off-thread plan step; null in production. public OpcUaPublishActor( IOpcUaAddressSpaceSink sink, IServiceLevelPublisher serviceLevel, @@ -398,9 +432,31 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? healthTickInterval = null, bool dbLess = false, IDataPlaneAuthoritySink? dataPlaneAuthority = null, - Func? utcNow = null, - int deferredWriteCapacity = DefaultDeferredWriteCapacity, - TestHooks? hooks = null) + Func? utcNow = null) + : this(sink, serviceLevel, subscribeRedundancyTopic, localNode, dbFactory, applier, dbHealthProbe, staleWindow, + probeFreshnessWindow, healthTickInterval, dbLess, dataPlaneAuthority, utcNow, DefaultDeferredWriteCapacity, hooks: null) + { + } + + /// The full constructor: also the deferred-write bound and the test seams (internal — tests only). + /// The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight. + /// Test seams for the off-thread plan step; null in production. + internal OpcUaPublishActor( + IOpcUaAddressSpaceSink sink, + IServiceLevelPublisher serviceLevel, + bool subscribeRedundancyTopic, + NodeId? localNode, + IDbContextFactory? dbFactory, + AddressSpaceApplier? applier, + IActorRef? dbHealthProbe, + TimeSpan? staleWindow, + TimeSpan? probeFreshnessWindow, + TimeSpan? healthTickInterval, + bool dbLess, + IDataPlaneAuthoritySink? dataPlaneAuthority, + Func? utcNow, + int deferredWriteCapacity, + TestHooks? hooks) { _deferred = new DeferredWrites(Math.Max(1, deferredWriteCapacity)); _hooks = hooks; @@ -436,6 +492,24 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Receive(_ => { /* PubSub ack */ }); } + /// + /// Review M5 — a restart discards ; answer each queued requester (degraded) rather + /// than leave it to the deploy timeout. The in-flight plan's requester is answered when its plan pipes back to + /// the new instance (). + /// + /// + protected override void PreRestart(Exception reason, object message) + { + while (_pendingRebuilds.TryDequeue(out var queued)) + { + if (!queued.Request.ReportOutcome) continue; + queued.Requester.Tell(new RebuildCompleted(queued.Request.Correlation, queued.Request.DeploymentId, true, + "the address-space publisher restarted before this rebuild was applied; it was not applied")); + } + + base.PreRestart(reason, message); + } + /// protected override void PreStart() { @@ -774,10 +848,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// Re-applies the deferred writes through the sink: values, then status-only annotations, then alarm /// states and alarm qualities. Each pass isolated like the live handlers; not re-metered (counted on arrival). - private void ReplayDeferredWrites() + private void ReplayDeferredWrites(ReplayScope scope) { if (_deferred.IsEmpty) return; - var (values, statuses) = _deferred.VariableWrites(); + var (values, statuses) = _deferred.VariableWrites( + key => scope.AllVariables || scope.Variables.Contains(key)); if (values.Count > 0) { try { _sink.WriteValues(values); } @@ -792,18 +867,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers foreach (var alarm in _deferred.Alarms) { + if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue; try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); } } foreach (var quality in _deferred.AlarmQualities) { + if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue; try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); } } - _log.Debug("OpcUaPublish: replayed {Values} value, {Statuses} status and {Alarms} alarm writes deferred during a rebuild plan", - values.Count, statuses.Count, _deferred.AlarmCount); + _log.Debug("OpcUaPublish: replayed {Values} value and {Statuses} status writes (of {Held} held) deferred during a rebuild plan", + values.Count, statuses.Count, _deferred.Count); } /// @@ -830,7 +907,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers public long Dropped { get; private set; } public int Count => _variables.Count + _alarms.Count + _qualities.Count; public bool IsEmpty => Count == 0; - public int AlarmCount => _alarms.Count + _qualities.Count; public IEnumerable Alarms => _alarms.Values; public IEnumerable AlarmQualities => _qualities.Values; @@ -863,16 +939,26 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers return true; } - public bool Alarm(AlarmStateUpdate msg) => Put(_alarms, (msg.Realm, msg.AlarmNodeId), msg); + /// A full snapshot also sets the condition's Quality (from its StatusCode), so it SUPERSEDES an + /// earlier quality-only write for the same id (review N1): replayed state-then-quality, the stale quality + /// would overwrite the snapshot's. A quality arriving AFTER the state is kept and replays after it. + public bool Alarm(AlarmStateUpdate msg) + { + var key = (msg.Realm, msg.AlarmNodeId); + if (!Put(_alarms, key, msg)) return false; + _qualities.Remove(key); + return true; + } public bool AlarmQuality(AlarmQualityUpdate msg) => Put(_qualities, (msg.Realm, msg.AlarmNodeId), msg); - public (List Values, List Statuses) VariableWrites() + public (List Values, List Statuses) VariableWrites(Func<(AddressSpaceRealm, string), bool> include) { var values = new List(); var statuses = new List(); foreach (var ((realm, id), e) in _variables) { + if (!include((realm, id))) continue; if (e.HasValue) values.Add(new ValueWrite(id, e.Value, e.Status, e.TimestampUtc, realm)); else statuses.Add(new StatusWrite(id, e.Status, e.TimestampUtc, realm)); } @@ -921,7 +1007,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // Land what arrived while the plan was in flight on the nodes the apply just created — before the answer, // so "applied" means the deferred first values are on the address space too (review fix C1). - ReplayDeferredWrites(); + ReplayDeferredWrites(_replayScope); + _replayScope = ReplayScope.Nothing; if (msg.ReportOutcome) requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason)); @@ -931,6 +1018,44 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// sink passes, announce. Returns whether the apply was degraded, and why. private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready) { + var previous = _lastApplied; + var ensured = _applier!.BeginRecordingEnsuredVariables(); + try + { + var result = ApplyPlanCore(ready, out var rebuilt); + // What this apply created (see _replayScope). A throwing pass still scopes to what it did create. + _replayScope = new ReplayScope(rebuilt, ensured, AddedConditions(previous, ready.Composition)); + return result; + } + finally + { + _applier.EndRecordingEnsuredVariables(); + } + } + + /// The condition ids in that did not materialise: + /// enabled scripted alarms (Uns, by ScriptedAlarmId) and native alarms (Raw, at their RawPath). + private static HashSet<(AddressSpaceRealm, string)> AddedConditions( + AddressSpaceComposition previous, AddressSpaceComposition next) + { + var added = ConditionIds(next); + added.ExceptWith(ConditionIds(previous)); + return added; + + static HashSet<(AddressSpaceRealm, string)> ConditionIds(AddressSpaceComposition c) + { + var ids = new HashSet<(AddressSpaceRealm, string)>(); + foreach (var a in c.EquipmentScriptedAlarms) + if (a.Enabled) ids.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId)); + foreach (var t in c.RawTags) + if (t.Alarm is not null) ids.Add((AddressSpaceRealm.Raw, t.NodeId)); + return ids; + } + } + + private (bool Degraded, string? Reason) ApplyPlanCore(PlanReady ready, out bool rebuilt) + { + rebuilt = false; var msg = ready.Request; using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); @@ -981,6 +1106,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } var outcome = _applier!.Apply(plan); + rebuilt = outcome.RebuildCalled; // Sum swallowed per-node materialise failures across every pass together with Apply's own // removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs index 865bef48..7940bd09 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs @@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit /// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine. -/// Kept in the default run on purpose (not a Benchmark category the CI filter would have to learn to exclude): +/// Tagged Category=Benchmark, but no filter excludes that category (CI drops only E2E + LiveIntegration), +/// so it runs by default — on purpose: /// measured after the fix at 5–6 ms (P1) and 58–64 ms (P2) on a loaded dev machine against the 1 s budget — /// ≥ 15× headroom, so it does not flake on slow CI. The flip side: the pre-fix code (75–150 ms / 470–680 ms) also /// fits 1 s at these sizes, so the wall-clock legs guard against gross (order-of-magnitude) regressions only; the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs index 7b795e3d..82bc6d8a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -235,6 +235,196 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase sink.Exists(NewTag).ShouldBeTrue(); } + // ───────────────────── fix round 2: N1 / N2 / M1 / M4 / M5 ───────────────────── + + /// N1 — a full state snapshot carries quality, so it supersedes an EARLIER quality-only write for the + /// same condition (the engine sends a transition with the recovered worst quality and no QualityChanged). + [Fact] + public void A_state_after_a_quality_only_write_supersedes_it_on_replay() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(AlarmArtifact())); + actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.SkippedAlarms.Count.ShouldBe(2), PlanBudget); + gate.Set(); + ExpectMsg(PlanBudget); + + sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.Good, "the later snapshot's quality is what live would leave"); + sink.AlarmStateOf("al-1")!.Active.ShouldBeTrue(); + } + + /// N1 positive control — the reverse order: a quality-only write AFTER the state still lands last. + [Fact] + public void A_quality_only_write_after_a_state_still_lands_after_it_on_replay() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(AlarmArtifact())); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.SkippedAlarms.Count.ShouldBe(2), PlanBudget); + gate.Set(); + ExpectMsg(PlanBudget); + + sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.BadNoCommunication); + } + + /// + /// N2 — a condition the plan KEEPS took the live write already; replaying it would roll back an operator's + /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. Driven over + /// both apply shapes: a surgical pure-add, and a full rebuild (which carries the condition's state over). + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay(bool rebuild) + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact())); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.ConditionExists("al-1").ShouldBeTrue(); + + gate.Reset(); + // A surgical pure-add (new raw tags) or a full rebuild (an equipment rename) that keeps al-1. + actor.Tell(Rebuild(rebuild ? AlarmArtifact(equipmentName: "Eq-renamed") : AlarmArtifact(rawTags: 2))); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // the live write landed + sink.AcknowledgeOutOfBand("al-1"); // an operator acks in the window + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBe(rebuild, "non-vacuity: the intended apply shape ran"); + sink.AlarmStateOf("al-1")!.Acknowledged.ShouldBeTrue("the operator's ack must survive the apply"); + sink.AlarmWritesTo("al-1").ShouldBe(1, "no replayed write (and so no extra Part 9 event) on a kept condition"); + } + + /// M1 — a variable the surgical plan keeps took the live write; replaying it would revert a client + /// Write applied in the window. Positive control in the same apply: the node the plan ADDS gets its value. + [Fact] + public void A_surgical_apply_replays_values_only_onto_the_variables_it_added() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(RawTagArtifact(1))); + ExpectMsg(PlanBudget); + const string kept = "Plant/Modbus/dev1/T0"; + + gate.Reset(); + actor.Tell(Rebuild(RawTagArtifact(3))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [ + new ValueWrite(kept, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + new ValueWrite(NewTag, 7, OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + ])); + AwaitAssert(() => sink.ValueOf(kept)!.Value.Value.ShouldBe(5), PlanBudget); + sink.SetValueOutOfBand(kept, 9); // a client Write in the window + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical apply"); + sink.ValueOf(kept)!.Value.Value.ShouldBe(9, "the kept node's client write is not reverted"); + sink.ValueOf(NewTag)!.Value.Value.ShouldBe(7, "positive control: the added node gets its deferred value"); + } + + /// M1 — a FULL rebuild recreates every variable, so every deferred value replays (the live write landed + /// on a node the rebuild discarded). + [Fact] + public void A_rebuild_replays_every_deferred_value() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact(rawTags: 1))); + ExpectMsg(PlanBudget); + const string kept = "Plant/Modbus/dev1/T0"; + sink.Exists(kept).ShouldBeTrue(); + + gate.Reset(); + // An equipment rename is a ChangedEquipment delta ⇒ full rebuild; the raw tag keeps its path. + actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed", rawTags: 1))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(kept, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)])); + AwaitAssert(() => sink.ValueOf(kept)!.Value.Value.ShouldBe(5), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran"); + sink.ValueOf(kept)!.Value.Value.ShouldBe(5, "the recreated node gets the value its predecessor took"); + } + + /// M4 — a FAILED plan applies nothing, so nothing is replayed, and the buffer is dropped once the + /// pipeline is idle: a later plan that adds the node does not receive a write from the failed window. + [Fact] + public void A_failed_plan_replays_nothing_and_drops_the_buffer() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(RawTagArtifact(2))); + ExpectMsg(PlanBudget); + + gate.Reset(); + actor.Tell(Rebuild("not json"u8.ToArray())); + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [new ValueWrite("Plant/Modbus/dev1/T2", 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)])); + AwaitAssert(() => sink.SkippedWrites.ShouldContain("Plant/Modbus/dev1/T2"), PlanBudget); + // Snapshot AFTER the live write (the sink's counter cannot tell a live write after an apply from a replay). + var replayedBefore = sink.ReplayedItems; + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeTrue(); + sink.ReplayedItems.ShouldBe(replayedBefore, "nothing was applied, so nothing is replayed"); + + actor.Tell(Rebuild(RawTagArtifact(3))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.Exists("Plant/Modbus/dev1/T2").ShouldBeTrue("positive control: the later plan adds it"); + sink.ValueOf("Plant/Modbus/dev1/T2").ShouldBeNull("the failed window's buffer was dropped"); + } + + /// M5 — a restart answers every QUEUED requester (degraded) instead of leaving it to the deploy timeout; + /// the in-flight one is answered by the incarnation check. + [Fact] + public void A_restart_answers_queued_rebuild_requesters() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var childProps = OpcUaPublishActor.PropsForTests(sink, applier, new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() }); + var parent = Sys.ActorOf(Props.Create(() => new RestartingParent(childProps))); + parent.Tell(RestartingParent.GetChild.Instance); + var child = ExpectMsg(); + + var inFlight = Rebuild(RawTagArtifact(2)); + var queued = Rebuild(RawTagArtifact(3)); + child.Tell(inFlight); + child.Tell(queued); + child.Tell(Kill.Instance); + + var first = ExpectMsg(PlanBudget); + first.Correlation.ShouldBe(queued.Correlation, "the queued requester is answered at the restart"); + first.Degraded.ShouldBeTrue(); + + gate.Set(); + var second = ExpectMsg(PlanBudget); + second.Correlation.ShouldBe(inFlight.Correlation); + second.Degraded.ShouldBeTrue(); + } + + private static AlarmConditionSnapshot ActiveUnacked(uint quality) => + new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high", quality); + // ───────────────────────────── helpers ───────────────────────────── private IActorRef Spawn(ExistenceSink sink, ManualResetEventSlim gate) => @@ -264,17 +454,30 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - private static byte[] AlarmArtifact() => + private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0) => JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, Equipment = new[] { - new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", MachineCode = "EQ-1" }, + new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" }, }, - DriverInstances = Array.Empty(), - Tags = Array.Empty(), + RawFolders = rawTags == 0 + ? Array.Empty() + : new object[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = rawTags == 0 + ? Array.Empty() + : new object[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = rawTags == 0 + ? Array.Empty() + : new object[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = Enumerable.Range(0, rawTags).Select(i => (object)new + { + TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", + DataType = "Float", AccessLevel = 0, TagConfig = "{}", + }).ToArray(), ScriptedAlarms = new[] { new @@ -334,6 +537,22 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase private readonly Dictionary _alarmQualities = new(); public ConcurrentQueue SkippedWrites { get; } = new(); + private readonly Dictionary _alarmWrites = new(); + private volatile bool _sawRebuild; + + /// Whether any apply called (the full-rebuild shape). + public bool SawRebuild => _sawRebuild; + public int AlarmWritesTo(string id) { lock (_gate) return _alarmWrites.GetValueOrDefault(id); } + public void SetValueOutOfBand(string id, object? value) + { + lock (_gate) { var c = _values.GetValueOrDefault(id); _values[id] = (value, c.Status, c.At); } + } + + /// What an SDK-applied Part 9 Acknowledge does: mutates the condition directly, no sink write. + public void AcknowledgeOutOfBand(string id) + { + lock (_gate) _alarmStates[id] = _alarmStates[id] with { Acknowledged = true }; + } public ConcurrentQueue SkippedAlarms { get; } = new(); public int ReplayedItems; @@ -378,7 +597,10 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase lock (_gate) { if (!_conditions.Contains(alarmNodeId)) { SkippedAlarms.Enqueue(alarmNodeId); return; } + _alarmWrites[alarmNodeId] = _alarmWrites.GetValueOrDefault(alarmNodeId) + 1; + // The node manager projects the snapshot's StatusCode onto the condition's Quality. _alarmStates[alarmNodeId] = state; + _alarmQualities[alarmNodeId] = state.StatusCode; } } @@ -410,12 +632,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase { lock (_gate) { + // Variables are recreated empty; a condition's state is captured and carried over to its + // re-materialised instance (the node manager's rebuild carry-over). _applyClosed = false; + _sawRebuild = true; _variables.Clear(); _conditions.Clear(); _values.Clear(); - _alarmStates.Clear(); - _alarmQualities.Clear(); } } From ff4e210a278d5f5d1a9118fe80a7a12c356466da Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:46:29 -0400 Subject: [PATCH 13/13] fix(opcua): replay kept scripted conditions after a rebuild; fit window values onto re-shaped nodes (Lane B fix round 3) N3 (critical): the node manager carries only NATIVE conditions' state over a full rebuild; scripted conditions come back fresh and rely on the engine's re-assert, which since the off-thread plan is handled in the window and lands on the discarded instance. On a rebuild the condition replay scope is now AddedConditions plus every enabled scripted condition in the new composition (native kept conditions stay carried-over, not replayed). The test sink again models the real rebuild: it clears non-native condition state only. N4: a surgical in-place shape edit (DataType / IsArray / ArrayLength) resets the raw node and its UNS mirror to BadWaitingForInitialData without ensuring them, so the window's value was not replayed and the two NodeIds disagreed. The applier records re-shaped nodes (MaterialisationRecording.Reshaped); their deferred value replays only once fitted to the new shape (VariableShape.TryFit: as is, or converted invariantly; never an old-typed value - WriteValues assigns the value verbatim with no type check), otherwise it is dropped at Debug and the node keeps waiting. Status-only entries are not replayed onto a re-shaped node. Minors: m1/m2 documented on _replayScope (degraded-path only); m3 corrected the false carry-over premise in the scope doc (the ScriptedAlarmHostActor ordering remark is outside this lane - F8); m4 the replay Debug log reports alarm-state and alarm-quality counts again. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 145 ++++++++++-- .../OpcUa/OpcUaPublishActor.cs | 73 +++++- .../VariableShapeTests.cs | 64 +++++ .../OpcUaPublishActorDeferredWriteTests.cs | 221 ++++++++++++++++-- 4 files changed, 457 insertions(+), 46 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index bb0f2752..961319f5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -219,6 +219,9 @@ public sealed class AddressSpaceApplier ok = false; } if (!ok) { allApplied = false; break; } + RecordReshape(AddressSpaceRealm.Raw, d.Current.NodeId, + d.Previous.DataType, d.Previous.IsArray, d.Previous.ArrayLength, + d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); } // v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's // IDENTITY (2026-08-15 review I2). @@ -270,6 +273,9 @@ public sealed class AddressSpaceApplier updated = false; } if (!updated) { allApplied = false; break; } + RecordReshape(AddressSpaceRealm.Uns, d.Current.NodeId, + d.Previous.DataType, d.Previous.BackingIsArray, d.Previous.BackingArrayLength, + d.Current.DataType, d.Current.BackingIsArray, d.Current.BackingArrayLength); continue; } @@ -1156,30 +1162,49 @@ public sealed class AddressSpaceApplier try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); - _ensuredVariables?.Add((realm, nodeId)); + _recording?.Ensured.Add((realm, nodeId)); return true; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } } - /// While non-null, every variable this applier ensures (realm, NodeId). See . - private HashSet<(AddressSpaceRealm, string)>? _ensuredVariables; - - /// - /// Starts recording every variable the following materialise passes ensure, until - /// . The publish actor scopes the replay of writes it deferred while a - /// plan was in flight to these (a surgical apply ensures only its delta), so a variable the apply kept is not - /// rewritten. Single-caller (the publish actor's thread), like the rest of the apply. - /// - /// The live set the recording fills. - public IReadOnlySet<(AddressSpaceRealm, string)> BeginRecordingEnsuredVariables() + /// Records an in-place UpdateTagAttributes that changed the node's SHAPE (DataType / array-ness / + /// array length): the node manager drops such a node's value to BadWaitingForInitialData. + private void RecordReshape( + AddressSpaceRealm realm, string nodeId, + string previousDataType, bool previousIsArray, uint? previousArrayLength, + string dataType, bool isArray, uint? arrayLength) { - _ensuredVariables = new HashSet<(AddressSpaceRealm, string)>(); - return _ensuredVariables; + if (_recording is null) return; + if (string.Equals(previousDataType, dataType, StringComparison.Ordinal) + && previousIsArray == isArray + && (!isArray || previousArrayLength == arrayLength)) + { + return; + } + + _recording.Reshaped[(realm, nodeId)] = new VariableShape(dataType, isArray, arrayLength); } - /// Stops the recording started (the returned set is kept). - public void EndRecordingEnsuredVariables() => _ensuredVariables = null; + /// The recording in progress, or null. See . + private MaterialisationRecording? _recording; + + /// + /// Starts recording what the following apply and materialise passes do to VARIABLES, until + /// : every variable ensured, and every variable an in-place attribute update + /// re-shaped (which resets its value). The publish actor scopes the replay of writes it deferred while a plan + /// was in flight to these — a variable the apply kept took the live write already, and rewriting it would + /// revert a client write made in the window. Single-caller (the publish actor's thread), like the apply. + /// + /// The live recording. + public MaterialisationRecording BeginRecording() + { + _recording = new MaterialisationRecording(); + return _recording; + } + + /// Stops the recording started (the returned recording is kept). + public void EndRecording() => _recording = null; // The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the // whole delta classification is one pure, table-testable unit; the applier references them there (and the @@ -1293,3 +1318,91 @@ public sealed record AddressSpaceApplyOutcome( bool RebuildCalled, bool RebuildFailed = false, int FailedNodes = 0); + +/// What one apply did to variables (see ). +public sealed class MaterialisationRecording +{ + /// Every variable an EnsureVariable was issued for, as (realm, NodeId). + public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new(); + + /// Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape. + public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new(); +} + +/// A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length. +/// The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits). +/// Whether the node is a one-dimensional array. +/// The fixed array length, when known (null / 0 ⇒ any length). +public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength) +{ + /// + /// Fits to this shape for a write onto a re-shaped node: unchanged when it already + /// fits, converted (invariant culture) when a lossless-enough conversion exists, otherwise refused — an + /// old-typed value must never be served under the new DataType (the reset exists to prevent exactly that). + /// A null value always fits (a status annotation carries none). + /// + /// The value to fit. + /// The fitted value, when this returns true. + /// True when the value fits (as is, or converted). + public bool TryFit(object? value, out object? fitted) + { + fitted = null; + if (value is null) return true; + var element = ElementType(DataType); + if (!IsArray) + { + if (value is Array) return false; + return TryConvert(value, element, out fitted); + } + + if (value is not Array array || value is string) return false; + if (ArrayLength is > 0 && array.Length != ArrayLength) return false; + if (element is null) { fitted = value; return true; } + var result = Array.CreateInstance(element, array.Length); + for (var i = 0; i < array.Length; i++) + { + if (!TryConvert(array.GetValue(i), element, out var item) || item is null) return false; + result.SetValue(item, i); + } + + fitted = result; + return true; + } + + private static bool TryConvert(object? value, Type? target, out object? converted) + { + converted = value; + if (value is null || target is null || target.IsInstanceOfType(value)) return true; + if (value is not IConvertible) return false; + try + { + converted = Convert.ChangeType(value, target, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) + { + converted = null; + return false; + } + } + + /// The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set); + /// null ⇒ BaseDataType, where any value fits. + private static Type? ElementType(string dataType) => dataType switch + { + "Boolean" => typeof(bool), + "SByte" => typeof(sbyte), + "Byte" => typeof(byte), + "Int16" => typeof(short), + "UInt16" => typeof(ushort), + "Int32" => typeof(int), + "UInt32" => typeof(uint), + "Int64" => typeof(long), + "UInt64" => typeof(ulong), + "Float" => typeof(float), + "Double" => typeof(double), + "String" => typeof(string), + "DateTime" => typeof(DateTime), + _ => null, + }; +} 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 94f2f24d..a1667540 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -187,8 +187,21 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// /// Variables: after a FULL rebuild every one (each was recreated, its live write landed on the /// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them). - /// Conditions: those in the applied composition but not the previous one — on a rebuild too, because the - /// node manager carries a kept condition's state over to its re-materialised instance. + /// Variables an in-place attribute update RE-SHAPED (review N4): the node manager reset their value to + /// BadWaitingForInitialData, so the window's value is replayed — but only once fitted to the new shape + /// (); a value that does not fit is dropped and the node left waiting. + /// The raw node and its UNS mirror are re-shaped by the same edit, so both get the same treatment. + /// Conditions: those in the applied composition but not the previous one; after a full rebuild ALSO + /// every kept SCRIPTED condition (review N3) — the node manager carries only NATIVE conditions' state over + /// a rebuild, so a scripted one is recreated fresh and needs the window's state. A kept native condition + /// is restored from the carry-over (which already holds the live write and any SDK ack), so it is not + /// replayed. Accepted cost on the scripted path: an SDK ack on the discarded instance in the window is + /// lost with it; the engine's own ack emission (a scripted ack routes to the engine) restores it. + /// Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance + /// _lastApplied, so the next apply counts its conditions as added again and may replay onto them; + /// and the empty-plan repair of a degraded composition scopes no condition (the composition is + /// unchanged), so a condition the repair newly places gets no window state — writes to it between the + /// two applies were already dropped before this replay existed. /// /// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through /// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.) @@ -198,10 +211,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private sealed record ReplayScope( bool AllVariables, IReadOnlySet<(AddressSpaceRealm, string)> Variables, + IReadOnlyDictionary<(AddressSpaceRealm, string), VariableShape> Reshaped, IReadOnlySet<(AddressSpaceRealm, string)> Conditions) { private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>(); - public static readonly ReplayScope Nothing = new(false, None, None); + public static readonly ReplayScope Nothing = + new(false, None, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), None); } /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in @@ -851,8 +866,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private void ReplayDeferredWrites(ReplayScope scope) { if (_deferred.IsEmpty) return; - var (values, statuses) = _deferred.VariableWrites( - key => scope.AllVariables || scope.Variables.Contains(key)); + var (allValues, allStatuses) = _deferred.VariableWrites( + key => scope.AllVariables || scope.Variables.Contains(key) || scope.Reshaped.ContainsKey(key)); + var values = new List(allValues.Count); + var statuses = new List(allStatuses.Count); + var unfit = 0; + foreach (var v in allValues) + { + if (!scope.Reshaped.TryGetValue((v.Realm, v.NodeId), out var shape)) { values.Add(v); continue; } + if (shape.TryFit(v.Value, out var fitted)) { values.Add(v with { Value = fitted }); continue; } + unfit++; + _log.Debug( + "OpcUaPublish: a value deferred for re-shaped {Realm} node {Node} does not fit its new shape ({DataType}, array={IsArray}) — not replayed; the node waits for the driver's next value", + v.Realm, v.NodeId, shape.DataType, shape.IsArray); + } + + foreach (var st in allStatuses) + { + // A status-only annotation onto a re-shaped (value-less) node would serve a null value under it; the + // node's honest state is BadWaitingForInitialData until a value of the new shape arrives. + if (scope.Reshaped.ContainsKey((st.Realm, st.NodeId))) { unfit++; continue; } + statuses.Add(st); + } if (values.Count > 0) { try { _sink.WriteValues(values); } @@ -865,22 +900,27 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); } } + var alarms = 0; foreach (var alarm in _deferred.Alarms) { if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue; + alarms++; try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); } } + var qualities = 0; foreach (var quality in _deferred.AlarmQualities) { if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue; + qualities++; try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); } } - _log.Debug("OpcUaPublish: replayed {Values} value and {Statuses} status writes (of {Held} held) deferred during a rebuild plan", - values.Count, statuses.Count, _deferred.Count); + _log.Debug( + "OpcUaPublish: replayed {Values} value, {Statuses} status, {Alarms} alarm-state and {Qualities} alarm-quality writes (of {Held} held; {Unfit} not fitting a re-shaped node) deferred during a rebuild plan", + values.Count, statuses.Count, alarms, qualities, _deferred.Count, unfit); } /// @@ -1019,17 +1059,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready) { var previous = _lastApplied; - var ensured = _applier!.BeginRecordingEnsuredVariables(); + var recording = _applier!.BeginRecording(); try { var result = ApplyPlanCore(ready, out var rebuilt); - // What this apply created (see _replayScope). A throwing pass still scopes to what it did create. - _replayScope = new ReplayScope(rebuilt, ensured, AddedConditions(previous, ready.Composition)); + // What this apply created or reset (see _replayScope). A throwing pass still scopes to what it did. + var conditions = AddedConditions(previous, ready.Composition); + if (rebuilt) + { + // Review N3: a full rebuild recreates every SCRIPTED condition fresh — the node manager carries only + // NATIVE conditions' state over (OtOpcUaNodeManager.RebuildAddressSpace). The engine's re-assert + // (or any transition) handled in the window landed on the discarded instance, so every kept scripted + // condition must be replayed onto its new one. Native kept conditions stay "added only". + foreach (var a in ready.Composition.EquipmentScriptedAlarms) + if (a.Enabled) conditions.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId)); + } + + _replayScope = new ReplayScope(rebuilt, recording.Ensured, recording.Reshaped, conditions); return result; } finally { - _applier.EndRecordingEnsuredVariables(); + _applier.EndRecording(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs new file mode 100644 index 00000000..52e6daff --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs @@ -0,0 +1,64 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Lane B review N4 — decides whether a value deferred during a plan window may +/// be replayed onto a node an in-place edit re-shaped: never an old-typed value the new DataType contradicts. +/// +public sealed class VariableShapeTests +{ + [Fact] + public void A_value_of_the_new_type_fits_unchanged() + { + new VariableShape("Int32", false, null).TryFit(7, out var fitted).ShouldBeTrue(); + fitted.ShouldBe(7); + } + + [Fact] + public void A_convertible_old_typed_value_is_converted_to_the_new_type() + { + new VariableShape("Int32", false, null).TryFit(5.0f, out var fitted).ShouldBeTrue(); + fitted.ShouldBeOfType().ShouldBe(5); + new VariableShape("Double", false, null).TryFit((short)3, out var d).ShouldBeTrue(); + d.ShouldBeOfType().ShouldBe(3d); + } + + [Theory] + [InlineData("Int32", "fast")] // not a number + [InlineData("Byte", 300)] // overflow + [InlineData("DateTime", 12)] // no conversion + public void A_value_the_new_type_cannot_hold_does_not_fit(string dataType, object value) + { + new VariableShape(dataType, false, null).TryFit(value, out var fitted).ShouldBeFalse(); + fitted.ShouldBeNull(); + } + + [Fact] + public void Scalar_and_array_never_cross() + { + new VariableShape("Int32", false, null).TryFit(new[] { 1, 2 }, out _).ShouldBeFalse(); + new VariableShape("Int32", true, 2).TryFit(1, out _).ShouldBeFalse(); + new VariableShape("String", true, null).TryFit("ab", out _).ShouldBeFalse("a string is not an array of strings"); + } + + [Fact] + public void An_array_is_converted_element_wise_and_its_fixed_length_enforced() + { + new VariableShape("Int16", true, 3).TryFit(new[] { 1, 2, 3 }, out var fitted).ShouldBeTrue(); + fitted.ShouldBeOfType().ShouldBe(new short[] { 1, 2, 3 }); + new VariableShape("Int16", true, 4).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeFalse("wrong length"); + new VariableShape("Int16", true, null).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeTrue("no fixed length"); + new VariableShape("Byte", true, null).TryFit(new[] { 1, 999 }, out _).ShouldBeFalse("an element overflows"); + } + + [Fact] + public void Null_and_unknown_types_always_fit() + { + new VariableShape("Int32", false, null).TryFit(null, out var n).ShouldBeTrue(); + n.ShouldBeNull(); + new VariableShape("Structure", false, null).TryFit("anything", out var any).ShouldBeTrue(); + any.ShouldBe("anything"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs index 82bc6d8a..2ab304a8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -277,13 +277,11 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase /// /// N2 — a condition the plan KEEPS took the live write already; replaying it would roll back an operator's - /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. Driven over - /// both apply shapes: a surgical pure-add, and a full rebuild (which carries the condition's state over). + /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. The surgical + /// apply shape; the rebuild shapes are the two tests below (scripted: replayed; native: carried over). /// - [Theory] - [InlineData(false)] - [InlineData(true)] - public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay(bool rebuild) + [Fact] + public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay() { var sink = new ExistenceSink(); using var gate = new ManualResetEventSlim(true); @@ -293,8 +291,8 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase sink.ConditionExists("al-1").ShouldBeTrue(); gate.Reset(); - // A surgical pure-add (new raw tags) or a full rebuild (an equipment rename) that keeps al-1. - actor.Tell(Rebuild(rebuild ? AlarmArtifact(equipmentName: "Eq-renamed") : AlarmArtifact(rawTags: 2))); + // A surgical pure-add (new raw tags) that keeps al-1. + actor.Tell(Rebuild(AlarmArtifact(rawTags: 2))); actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // the live write landed sink.AcknowledgeOutOfBand("al-1"); // an operator acks in the window @@ -302,7 +300,7 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase gate.Set(); ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); - sink.SawRebuild.ShouldBe(rebuild, "non-vacuity: the intended apply shape ran"); + sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical apply"); sink.AlarmStateOf("al-1")!.Acknowledged.ShouldBeTrue("the operator's ack must survive the apply"); sink.AlarmWritesTo("al-1").ShouldBe(1, "no replayed write (and so no extra Part 9 event) on a kept condition"); } @@ -422,6 +420,149 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase second.Degraded.ShouldBeTrue(); } + + /// + /// N3 (i) — a full rebuild recreates every KEPT SCRIPTED condition fresh (only native conditions are carried + /// over), so the engine's re-assert handled in the window — landing on the discarded instance — must be + /// replayed onto the new one, or the active alarm reads Normal until its next transition (#487). + /// + [Fact] + public void A_rebuild_replays_the_window_state_onto_every_kept_scripted_condition() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact())); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + gate.Reset(); + actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed"))); // ChangedEquipment ⇒ full rebuild, al-1 kept + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // landed live on the old instance + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran"); + sink.AlarmStateOf("al-1").ShouldNotBeNull("the re-assert must reach the recreated condition") + .Active.ShouldBeTrue(); + } + + /// + /// N3 (ii) — the native counterpart: a kept NATIVE condition is restored from the node manager's carry-over + /// (which already holds the live write and the operator's ack), so it is not replayed — the ack survives and no + /// extra write lands. + /// + [Fact] + public void A_rebuild_does_not_replay_onto_a_kept_native_condition() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact(nativeAlarm: true))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.ConditionExists(NativeAlarm).ShouldBeTrue(); + + gate.Reset(); + actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed", nativeAlarm: true))); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(NativeAlarm, ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Raw)); + AwaitAssert(() => sink.AlarmWritesTo(NativeAlarm).ShouldBe(1), PlanBudget); + sink.AcknowledgeOutOfBand(NativeAlarm); + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran"); + sink.AlarmStateOf(NativeAlarm)!.Acknowledged.ShouldBeTrue("the carried-over ack survives"); + sink.AlarmWritesTo(NativeAlarm).ShouldBe(1, "no replayed write on a carried-over native condition"); + } + + /// + /// N4 — a surgical in-place DataType edit resets the raw node AND its UNS mirror (the node manager drops an + /// old-typed value), and neither is ensured by the apply. The window's value is replayed onto both, fitted to + /// the new type, so the two NodeIds of one value agree. + /// + [Fact] + public void A_surgical_shape_edit_replays_the_window_value_fitted_onto_the_raw_node_and_its_mirror() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(ShapeArtifact("Float"))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.Exists(RawSpeed).ShouldBeTrue(); + sink.Exists(UnsSpeed).ShouldBeTrue("non-vacuity: the mirror exists"); + + gate.Reset(); + actor.Tell(Rebuild(ShapeArtifact("Int32"))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [ + new ValueWrite(RawSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + new ValueWrite(UnsSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Uns), + ])); + AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe(5.0f), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical in-place edit"); + sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset"); + sink.ReshapedNodes.ShouldContain(UnsSpeed, "non-vacuity: the mirror was reset"); + var raw = sink.ValueOf(RawSpeed).ShouldNotBeNull(); + raw.Value.ShouldBeOfType().ShouldBe(5); + sink.ValueOf(UnsSpeed).ShouldBe(raw, "the raw node and its UNS mirror agree"); + } + + /// N4 — a window value that cannot be fitted to the new shape is never written: both NodeIds stay + /// value-less (BadWaitingForInitialData) until the driver's next value. + [Fact] + public void A_surgical_shape_edit_never_replays_a_value_that_does_not_fit_the_new_shape() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(ShapeArtifact("String"))); + ExpectMsg(PlanBudget); + + gate.Reset(); + actor.Tell(Rebuild(ShapeArtifact("Int32"))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [ + new ValueWrite(RawSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + new ValueWrite(UnsSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Uns), + ])); + AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe("fast"), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset"); + sink.ValueOf(RawSpeed).ShouldBeNull("\"fast\" is not an Int32 — the node keeps waiting"); + sink.ValueOf(UnsSpeed).ShouldBeNull(); + } + + private const string NativeAlarm = "Plant/Modbus/dev1/Hi"; + private const string RawSpeed = "Plant/Modbus/dev1/Speed"; + private const string UnsSpeed = "Area/Line/Eq/Speed"; + + /// One raw tag (Speed) of and one UNS reference to it under Area/Line/Eq. + private static byte[] ShapeArtifact(string dataType) => + JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, + UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, + Equipment = new[] { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", 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-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Speed", DataType = dataType, AccessLevel = 0, TagConfig = "{}" } }, + UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed", DisplayNameOverride = (string?)null } }, + ScriptedAlarms = Array.Empty(), + }); + private static AlarmConditionSnapshot ActiveUnacked(uint quality) => new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high", quality); @@ -454,7 +595,7 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0) => + private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0, bool nativeAlarm = false) => JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, @@ -463,13 +604,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" }, }, - RawFolders = rawTags == 0 + RawFolders = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, - DriverInstances = rawTags == 0 + DriverInstances = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, - Devices = rawTags == 0 + Devices = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, TagGroups = Array.Empty(), @@ -477,7 +618,16 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase { TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", DataType = "Float", AccessLevel = 0, TagConfig = "{}", - }).ToArray(), + }).Concat(nativeAlarm + ? new object[] + { + new + { + TagId = "t-hi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Hi", DataType = "Boolean", + AccessLevel = 0, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", + }, + } + : Array.Empty()).ToArray(), ScriptedAlarms = new[] { new @@ -615,9 +765,18 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { - lock (_gate) { _applyClosed = false; _conditions.Add(alarmNodeId); } + lock (_gate) + { + _applyClosed = false; + _conditions.Add(alarmNodeId); + if (isNative) _nativeConditions.Add(alarmNodeId); else _nativeConditions.Remove(alarmNodeId); + } } + private readonly HashSet _nativeConditions = new(); + private readonly Dictionary _shapes = new(); + public ConcurrentQueue ReshapedNodes { get; } = new(); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { lock (_gate) _applyClosed = false; @@ -625,20 +784,28 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { - lock (_gate) { _applyClosed = false; _variables.Add(variableNodeId); } + lock (_gate) + { + _applyClosed = false; + if (_variables.Add(variableNodeId)) _shapes[variableNodeId] = (dataType, isArray, arrayLength); + } } public void RebuildAddressSpace() { lock (_gate) { - // Variables are recreated empty; a condition's state is captured and carried over to its - // re-materialised instance (the node manager's rebuild carry-over). + // OtOpcUaNodeManager.RebuildAddressSpace: variables are recreated empty; only NATIVE conditions' + // state is captured and carried over to the re-materialised instance — a scripted condition comes + // back fresh (its engine re-asserts it). _applyClosed = false; _sawRebuild = true; _variables.Clear(); + _shapes.Clear(); _conditions.Clear(); _values.Clear(); + foreach (var id in _alarmStates.Keys.Where(k => !_nativeConditions.Contains(k)).ToList()) _alarmStates.Remove(id); + foreach (var id in _alarmQualities.Keys.Where(k => !_nativeConditions.Contains(k)).ToList()) _alarmQualities.Remove(id); } } @@ -652,7 +819,23 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } 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 bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) => true; + /// OtOpcUaNodeManager.UpdateTagAttributes: a SHAPE change (DataType / array-ness / length) drops the + /// node's value (BadWaitingForInitialData); any other attribute edit leaves it. + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) + { + lock (_gate) + { + if (!_variables.Contains(variableNodeId)) return false; + if (_shapes.GetValueOrDefault(variableNodeId) != (dataType, isArray, arrayLength)) + { + _shapes[variableNodeId] = (dataType, isArray, arrayLength); + _values.Remove(variableNodeId); + ReshapedNodes.Enqueue(variableNodeId); + } + + return true; + } + } public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true; public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { lock (_gate) return _variables.Remove(variableNodeId); } public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { lock (_gate) return _conditions.Remove(alarmNodeId); }