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:
Joseph Doherty
2026-10-22 10:43:21 -04:00
parent deaa31479b
commit d90f35b6be
2 changed files with 205 additions and 15 deletions
@@ -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)