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]