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
This commit is contained in:
@@ -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",
|
||||
|
||||
+21
-8
@@ -215,11 +215,13 @@ public sealed class HistoryUpdateOverTheWireTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.)
|
||||
/// </summary>
|
||||
[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(); }
|
||||
}
|
||||
|
||||
@@ -223,8 +223,10 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>errors</c> is asserted: on the SDK-base refusal path the
|
||||
/// per-node result is left for the master node manager to fill from it.)
|
||||
/// </summary>
|
||||
[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(); }
|
||||
}
|
||||
|
||||
+15
-13
@@ -211,25 +211,21 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// 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.</summary>
|
||||
[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 <c>_eventNotifierSources</c>) is exercised by the
|
||||
/// <c>Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported</c> test instead.
|
||||
/// <c>Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired</c> test instead.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user