fix(opcua): refresh historian capability bits on late source/updater assignment (B7)
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
This commit is contained in:
@@ -368,9 +368,73 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
_historianDataSource = value ?? NullHistorianDataSource.Instance;
|
||||
SyncAdvertisedAggregates();
|
||||
RefreshEventHistoryCapability();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 U3 — re-evaluates the <see cref="EventNotifiers.HistoryRead"/> bit (and the event-history
|
||||
/// source registration <see cref="HistoryReadEvents"/> resolves against) on every folder already promoted
|
||||
/// to an event notifier, so a <see cref="HistorianDataSource"/> 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 <c>BadHistoryOperationUnsupported</c>. Folders promoted later go
|
||||
/// through <see cref="EnsureFolderIsEventNotifier"/>, which reads the same source.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 U3 — re-evaluates the <see cref="AccessLevels.HistoryWrite"/> bit on every historized
|
||||
/// variable, so a <see cref="HistorianDataUpdater"/> 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 <c>CurrentWrite</c> bit — the authored value <see cref="ComposeAccessLevel"/> was given.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test observability: whether <paramref name="folderNodeId"/> is registered as an event-history
|
||||
/// source (what <see cref="HistoryReadEvents"/> accepts).</summary>
|
||||
internal bool IsEventHistorySource(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||
_eventNotifierSources.ContainsKey(MapKey(realm, folderNodeId));
|
||||
|
||||
/// <summary>
|
||||
/// Trim <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) to the current source's
|
||||
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (null ⇒ all, <see cref="AdvertisedAggregateFunctions.ServedBy"/>).
|
||||
@@ -408,20 +472,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// delete of historized samples). <c>null</c> (the default) means the selected historian backend
|
||||
/// cannot modify history: every HistoryUpdate is reported <c>BadHistoryOperationUnsupported</c>.
|
||||
/// <para>
|
||||
/// ⚠️ <b>Assign it before any node materialises.</b> Whether an updater is present decides the
|
||||
/// <c>HistoryWrite</c> AccessLevel bit a historized node is created with
|
||||
/// (<see cref="ComposeAccessLevel"/>), 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
|
||||
/// <c>OtOpcUaServerHostedService.StartAsync</c> before installing the address-space sink, which
|
||||
/// precedes every deploy apply. Volatile for the same start-thread / request-thread handoff as
|
||||
/// <see cref="HistorianDataSource"/>.
|
||||
/// Whether an updater is present decides the <c>HistoryWrite</c> AccessLevel bit a historized node
|
||||
/// carries (<see cref="ComposeAccessLevel"/>), 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 —
|
||||
/// <see cref="RefreshHistoryWriteCapability"/>), so the Host's ordering (assigned in
|
||||
/// <c>OtOpcUaServerHostedService.StartAsync</c> before the sink is installed) is no longer load-bearing.
|
||||
/// Volatile for the same start-thread / request-thread handoff as <see cref="HistorianDataSource"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IHistorianDataUpdater? HistorianDataUpdater
|
||||
{
|
||||
get => _historianDataUpdater;
|
||||
set => _historianDataUpdater = value;
|
||||
set
|
||||
{
|
||||
_historianDataUpdater = value;
|
||||
RefreshHistoryWriteCapability();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2914,12 +2981,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <see cref="NullHistorianDataSource"/>), the folder ALSO gets the
|
||||
/// <see cref="EventNotifiers.HistoryRead"/> bit OR-ed in (keeping SubscribeToEvents) and registers
|
||||
/// its NodeId identifier as an event-history source so the <see cref="HistoryReadEvents"/> 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 <c>StartAsync</c> — 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 <see cref="RebuildAddressSpace"/> 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 <see cref="HistorianDataSource"/> assignment re-evaluates every promoted folder
|
||||
/// (review 01 U3 — <see cref="RefreshEventHistoryCapability"/>), so the Host's boot ordering is no
|
||||
/// longer load-bearing. The HistoryRead bit + source registration happen inside the same first-time
|
||||
/// <see cref="_notifierFolders"/> block so the idempotency guard covers them too.
|
||||
/// </remarks>
|
||||
private void EnsureFolderIsEventNotifier(FolderState folder)
|
||||
|
||||
+125
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a
|
||||
/// <see cref="OtOpcUaNodeManager.HistorianDataSource"/> or <see cref="OtOpcUaNodeManager.HistorianDataUpdater"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<OpcUaApplicationHost>.Instance),
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
||||
}
|
||||
|
||||
private sealed class StubSource : IHistorianDataSource
|
||||
{
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
||||
public Task<HistoricalEventsResult> 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<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user