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]