Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
T
Joseph Doherty 3c3513ea3e fix(historian): #694 page raw reads at a strict gateway's learned ceiling; opt-in EventReadHorizon
Item 1: IHistorianDataSource.MaxRawValuesPerRead (Gateway: learned ceiling - 16 = L; 0 elsewhere, guard-tested).
RawPageLimit plans every raw page on top of #692's lookahead: with L > 0 the read (page P + boundary ties k
+ lookahead) is <= L, P = min(N, L - k - 1) (uncapped: L - k - 1); no room => straight to the tie-cluster
over-fetch (never an uncapped or underflowing request). One re-plan + re-read when the read taught a new
limit or was refused for crowding pre-window rows (HistoryReadLimitExceededException.PreWindowRows): the
page shrinks by them. Over-fetch bound min(MaxTieClusterOverfetch, L - 1); slice size never 0.
Review fixes: F1 a window starting at/before the epoch counts AVEVA's synthetic row as pre-window (a
ceiling-truncated read was reported complete); F2 the page path disables ReverseRawReader's own re-run
(cold end-only ceiling 20/N=3000 cost 3,023 reads, now <= 40); F3 crowded look-back re-read around, not
refused; F4 the #687 crowding re-read room at least doubles (up to 8 re-reads), so a cluster of hundreds
just before a resume point no longer fails on a default gateway; a crowding refusal that leaves no room
is BadTooManyOperations, never a tick-by-tick cursor.

Item 2: ServerHistorian:EventReadHorizon (default 00:00:00 = exact, unchanged). When set, every fresh
HistoryReadEvents window's earlier bound is raised to now - horizon (both directions, all backends).
Lossy by design; documented.

Item 3 (native TimeAverage/Total re-check) stays blocked on historiangw#27.
2026-10-13 01:39:32 -04:00

467 lines
29 KiB
C#

using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// Owns the OPC UA SDK lifecycle on driver-role hosts. Reads
/// <see cref="OpcUaApplicationHostOptions"/> from the <c>OpcUa</c> config section, boots
/// an <see cref="OtOpcUaSdkServer"/> through <see cref="OpcUaApplicationHost"/>, then
/// swaps a real <see cref="SdkAddressSpaceSink"/> into the
/// <see cref="DeferredAddressSpaceSink"/> singleton so <c>OpcUaPublishActor</c>'s writes
/// start landing in the real address space.
///
/// Tests boot the OPC UA server directly via <see cref="OpcUaApplicationHost"/>; this
/// hosted service is the production wiring.
/// </summary>
public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposable
{
private readonly OpcUaApplicationHostOptions _options;
private readonly DeferredAddressSpaceSink _deferredSink;
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly IHistorianDataSource _historianDataSource;
private readonly IDataPlaneAuthoritySource _dataPlaneAuthority;
private readonly DataPlaneAuthorizer? _dataPlaneAuthorizer;
private readonly ServerHistorianOptions _serverHistorianOptions;
private readonly AclOptions _aclOptions;
private readonly TimeProvider _time;
private readonly Func<ActorSystem> _actorSystemAccessor;
private readonly ActorRegistry _actorRegistry;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OtOpcUaServerHostedService> _logger;
private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
private readonly HistorianUpdaterHolder _historianUpdater;
private readonly IHistoryUpdateRoleView? _historyUpdateRoleView;
private OpcUaApplicationHost? _appHost;
private OtOpcUaSdkServer? _server;
private AclRevalidationTimer? _revalidationTimer;
/// <summary>
/// Initializes a new instance of the OtOpcUaServerHostedService class.
/// </summary>
/// <param name="options">The validated OPC UA host options (bound from the <c>OpcUa</c> section and validated at startup via <c>ValidateOnStart</c>).</param>
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
/// <param name="historianDataSource">The server-side HistoryRead backend resolved from DI — the
/// <c>NullHistorianDataSource</c> default seeded by <c>AddOtOpcUaRuntime</c> (which runs on this driver
/// node, the same source the address-space sink + node-write gateway come from), or the configured
/// HistorianGateway read client when <c>AddServerHistorian</c> enabled it. Wired onto the node manager in
/// <see cref="StartAsync"/>.</param>
/// <param name="dataPlaneAuthority">#520 stage 4 — the node's data-plane authorization inputs (its
/// cluster, the current auth generation, and the ACL grants), refreshed from every applied deployment
/// artifact. Wired onto the node manager in <see cref="StartAsync"/> so the stage 5/6 enforcement seams
/// have a cluster identity to build a <c>NodeScope</c> with. Resolves to the empty source on a node with
/// no driver role.</param>
/// <param name="configuration">App configuration; the <c>ServerHistorian</c> section is bound here to
/// read <see cref="ServerHistorianOptions.MaxTieClusterOverfetch"/> for the node manager. Bound directly
/// (not via <c>IOptions</c>) so the wiring is self-contained in this hosted service — the option's
/// default survives when the section is absent. The companion <c>AddServerHistorian</c> registration in
/// <c>Program.cs</c> reads the same section for the read-client gating; this is the read-path only.</param>
/// <param name="actorSystemAccessor">Lazy accessor for the running <see cref="ActorSystem"/>, used to
/// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved
/// lazily (mirroring <c>DpsScriptLogPublisher</c>) so construction never races Akka startup.</param>
/// <param name="actorRegistry">The Akka.Hosting actor registry, used to resolve the local
/// <c>DriverHostActor</c> ref (<c>DriverHostActorKey</c>) the inbound node-write router Asks. Resolved
/// in <see cref="StartAsync"/> after the runtime actors have been registered.</param>
/// <param name="dataPlaneAuthorizer">#520 stage 5 — the node-scoped ACL gate. Nullable, and null means
/// "not wired" rather than "deny": an unregistered gate must not lock a node out of writes to protect a
/// feature that is off by default. Once wired AND enabled, every unresolvable case inside it denies.</param>
/// <param name="aclOptions">#520 / arch-review 03/S19 — the <c>Security:Acl</c> options, read for
/// <see cref="AclOptions.RevalidationIntervalSeconds"/>. Required, not optional: the built-in DI
/// container does not fall back to a default for an unregistered service, the same trap the
/// <c>IOpcUaUserAuthenticator</c> and <c>IDataPlaneAuthoritySource</c> parameters carry comments
/// about.</param>
/// <param name="time">The shared clock — the one instance the authorizer and the API-key store bound
/// also read. Its <c>CreateTimer</c> arms the revalidation tick.</param>
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
/// <param name="primaryRoleSource">Gitea #597 — the data-plane Primary verdict <c>DriverHostActor</c>
/// publishes on every redundancy snapshot (the same one its native-ack route and device-write gates apply).
/// Wired onto the node manager so a native-condition Acknowledge on a non-Primary node is refused rather than
/// applied and then reverted. Optional: a node with no driver role registers none, and null means "allow" —
/// the single-node posture <c>UnmanagedDriverPrimaryRoleSource</c> also answers with.</param>
/// <param name="historianUpdater">The selected historian backend's optional HistoryUpdate capability.
/// Optional: when unregistered it resolves to <see cref="HistorianUpdaterHolder.None"/> and HistoryUpdate is
/// reported unsupported on every node.</param>
/// <param name="historyUpdateRoleView">Gitea #674 — the HistoryUpdate verdict <c>DriverHostActor</c> publishes (the
/// data-plane Primary verdict, closed until this node's first redundancy snapshot). Optional: registered on every
/// driver-role node; an admin-only node has none and wires no HistoryUpdate Primary gate (allow).</param>
public OtOpcUaServerHostedService(
IOptions<OpcUaApplicationHostOptions> options,
DeferredAddressSpaceSink deferredSink,
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
IHistorianDataSource historianDataSource,
IDataPlaneAuthoritySource dataPlaneAuthority,
IConfiguration configuration,
Func<ActorSystem> actorSystemAccessor,
ActorRegistry actorRegistry,
DataPlaneAuthorizer? dataPlaneAuthorizer,
IOptions<AclOptions> aclOptions,
TimeProvider time,
ILoggerFactory loggerFactory,
IDriverPrimaryRoleSource? primaryRoleSource = null,
HistorianUpdaterHolder? historianUpdater = null,
IHistoryUpdateRoleView? historyUpdateRoleView = null)
{
ArgumentNullException.ThrowIfNull(aclOptions);
ArgumentNullException.ThrowIfNull(time);
_dataPlaneAuthorizer = dataPlaneAuthorizer;
_aclOptions = aclOptions.Value;
_time = time;
_options = options.Value;
_deferredSink = deferredSink;
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
_historianDataSource = historianDataSource;
_dataPlaneAuthority = dataPlaneAuthority;
// Bind the ServerHistorian section directly (defaults survive when it's absent) — we only need
// MaxTieClusterOverfetch here, and binding it ourselves keeps the wiring in this one file.
_serverHistorianOptions =
configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>()
?? new ServerHistorianOptions();
_actorSystemAccessor = actorSystemAccessor;
_actorRegistry = actorRegistry;
_loggerFactory = loggerFactory;
_primaryRoleSource = primaryRoleSource;
_historianUpdater = historianUpdater ?? HistorianUpdaterHolder.None;
_historyUpdateRoleView = historyUpdateRoleView;
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
}
/// <summary>
/// Starts the OPC UA server asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StartAsync(CancellationToken cancellationToken)
{
_server = new OtOpcUaSdkServer(_loggerFactory.CreateLogger<OtOpcUaNodeManager>());
_appHost = new OpcUaApplicationHost(
_options,
_loggerFactory.CreateLogger<OpcUaApplicationHost>(),
_userAuthenticator);
try
{
await _appHost.StartAsync(_server, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex,
"OtOpcUaServerHostedService: SDK start failed; OpcUaPublishActor writes will continue to no-op");
// Don't rethrow — the rest of the host (admin UI, driver actors, etc.) can still boot.
// Operators see the failure via the logs + can correct config without a process bounce
// of the whole binary.
return;
}
if (_server.NodeManager is null)
{
_logger.LogWarning(
"OtOpcUaServerHostedService: SDK reported started but NodeManager is null; sink stays Null");
return;
}
// The HistoryUpdate backend MUST be wired before SetSink: the node manager decides each historized
// node's HistoryWrite AccessLevel bit at materialise time from whether an updater is present, and the
// SDK base admits a node to HistoryUpdate only when that bit is set. Installing the real sink below is
// what lets OpcUaPublishActor's deploy applies start materialising nodes, so assigning the updater
// first guarantees every node sees it (nodes are never retro-updated).
_server.SetHistorianDataUpdater(_historianUpdater.Updater);
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
// Wire the reverse-path inbound-alarm-command router: a client Acknowledge/Confirm/Shelve that
// passes the node manager's AlarmAck gate publishes the mapped AlarmCommand onto the cluster
// `alarm-commands` topic (same DistributedPubSub mediator the `alerts`/`script-logs` topics use).
// The Tell is fire-and-forget so the handler — which runs under the SDK's Lock — never blocks.
// The mediator is resolved per-publish via the lazy ActorSystem accessor so a transient cluster
// condition is tolerated and construction never raced Akka startup.
_server.SetAlarmCommandRouter(cmd =>
{
try
{
var mediator = DistributedPubSub.Get(_actorSystemAccessor()).Mediator;
mediator.Tell(new Publish(ScriptedAlarmHostActor.AlarmCommandsTopic, cmd));
}
catch (Exception ex)
{
// The router runs under the SDK Lock on a server thread; a cluster hiccup must not
// escape into the SDK's Call path. Log + drop — the client still gets Good for the
// node-state change; the missed command surfaces as a non-applied engine transition.
_logger.LogWarning(ex,
"OtOpcUaServerHostedService: failed to route inbound alarm command {Operation} for {AlarmId}",
cmd.Operation, cmd.AlarmId);
}
});
// Wire the reverse-path native-alarm-ack router (H6e): a client Acknowledge of a NATIVE (driver-fed,
// e.g. Galaxy) Part 9 condition that passes the node manager's AlarmAck gate routes the acknowledge to
// the owning driver child. The node manager maps the inbound ack to an OpcUaServer-local NativeAlarmAck;
// HERE the host maps that to the Runtime DriverHostActor.RouteNativeAlarmAck (field-for-field) and Tells
// it into the local DriverHostActor, which Primary-gates + resolves the owning DriverInstanceActor. The
// mapping happens at this boundary so Runtime stays free of an OpcUaServer dependency. The Tell is
// fire-and-forget so the handler — which runs under the SDK's Lock — never blocks. The DriverHostActor
// ref is resolved LAZILY per ack (this StartAsync runs before the Akka DriverHostActor registers, so a
// one-shot resolve here would always miss); by ack time the registry has it, mirroring the node-write
// gateway below.
_server.SetNativeAlarmAckRouter(nativeAck =>
{
try
{
if (_actorRegistry.TryGet<DriverHostActorKey>(out var driverHost))
{
driverHost.Tell(new DriverHostActor.RouteNativeAlarmAck(
nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser));
}
else
{
// No driver host registered yet (admin-only node, or pre-registration race). The Part 9 ack
// already committed the local condition state; the missed routing surfaces as a non-applied
// upstream acknowledge, not a client-visible error.
_logger.LogWarning(
"OtOpcUaServerHostedService: native alarm ack for {ConditionNodeId} dropped — no DriverHostActor registered",
nativeAck.ConditionNodeId);
}
}
catch (Exception ex)
{
// The router runs under the SDK Lock on a server thread; a hiccup must not escape into the SDK's
// Call path. Log + drop — the client still gets Good for the condition-state change.
_logger.LogWarning(ex,
"OtOpcUaServerHostedService: failed to route native alarm ack for {ConditionNodeId}",
nativeAck.ConditionNodeId);
}
});
// Gitea #597: a native ack is accepted only on the Primary. The verdict is the one DriverHostActor
// publishes and gates the route above on, so a Secondary refuses (BadInvalidState) instead of applying an
// ack the driver will never see and then reverting it. A volatile property read — safe under the SDK Lock.
if (_primaryRoleSource is { } primaryRoleSource)
{
_server.SetNativeAlarmAckPrimaryGate(() => primaryRoleSource.ShouldActAsPrimary);
}
else
{
// Every driver node registers one (AddOtOpcUaRuntime), so this is a wiring regression, not a mode:
// say so rather than let a Secondary silently go back to accepting acks it will revert.
_logger.LogWarning(
"OtOpcUaServerHostedService: no IDriverPrimaryRoleSource registered — native-alarm acknowledges are accepted on every node regardless of redundancy role");
}
// Wire the reverse-path inbound operator-write gateway: a client write to a writable equipment-tag
// node that passes the node manager's WriteOperate gate routes the write to the owning driver child
// (RouteNodeWrite → NodeWriteResult) via the local DriverHostActor. The node manager calls the
// gateway's WriteAsync FIRE-AND-FORGET: the SDK's CustomNodeManager2.Write holds the node-manager
// Lock while invoking OnWriteValue, so a blocking Ask here would freeze ALL address-space operations
// (reads, subscription notifications, the publish path) for up to the Ask timeout. The gateway kicks
// off the Ask and resolves a NodeWriteOutcome; the node manager applies the client value optimistically
// and self-corrects (reverts to the pre-write value) when the device write comes back FAILED — but only
// while the node still holds the optimistic value, so a fresh driver poll is not clobbered. The
// DriverHostActor ref is resolved LAZILY per write (inside the gateway) — this hosted service's
// StartAsync runs before the Akka DriverHostActor registers, so a one-shot resolve here would always
// miss and leave every write unavailable. By write time (long after startup) the registry has it; a
// node that genuinely has no driver-host (admin-only, no writable driver nodes materialised) logs +
// resolves the write to "writes unavailable".
_server.SetNodeWriteGateway(new ActorNodeWriteGateway(
resolveDriverHost: () => _actorRegistry.TryGet<DriverHostActorKey>(out var driverHost) ? driverHost : null,
logger: _loggerFactory.CreateLogger<ActorNodeWriteGateway>()));
// Wire the server-side read backend resolved from DI — the NullHistorianDataSource default (when
// the ServerHistorian section is disabled) or the configured HistorianGateway read client (when enabled).
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
_server.SetHistorianDataSource(_historianDataSource);
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. It exists because
// the node manager had NO cluster identity, and NodeScope.ClusterId is required while the evaluator
// fails closed on a mismatch — every scope it could have built would have been denied.
_server.SetDataPlaneAuthority(_dataPlaneAuthority);
// #520 stage 5 — hand it the node-scoped ACL gate. Still behaviour-neutral unless
// Security:Acl:Enabled is set: the gate reports itself disabled and every seam keeps its
// server-wide role check.
_server.SetDataPlaneAuthorizer(_dataPlaneAuthorizer);
if (_dataPlaneAuthorizer is { Enabled: true })
{
_logger.LogWarning(
"OtOpcUaServerHostedService: node ACL enforcement is ENABLED — EVERY data-plane operation "
+ "(read, browse, subscribe, write, alarm commands, HistoryRead) now requires a matching "
+ "NodeAcl grant. A principal with no grant can still connect and read the standard OPC UA "
+ "server nodes (ns=0: ServerStatus, NamespaceArray, ServiceLevel, ...) — those are served by "
+ "the SDK core node manager and are deliberately ungated because clients need them for "
+ "session setup — but it sees no plant data (raw/UNS namespaces).");
}
// Propagate the configured tie-cluster over-fetch bound onto the node manager so HistoryRead-Raw
// can page WITHIN an oversized tie cluster (more samples sharing one SourceTimestamp than the page
// cap) rather than failing the read. The option's default (65536) survives when the ServerHistorian
// section is absent; NodeManager is non-null here (guarded above).
_server.NodeManager.MaxTieClusterOverfetch = _serverHistorianOptions.MaxTieClusterOverfetch;
// Gitea #691: the ReturnBounds kill switch (default on).
_server.NodeManager.HonorReturnBounds = HonorReturnBounds;
// Gitea #694: the opt-in HistoryReadEvents horizon (default off = exact to each read's own window).
_server.NodeManager.EventReadHorizon = EventReadHorizon;
// Propagate the S3 HistoryRead concurrency bounds (arch-review 03/S3): bounded per-node fan-out
// within a batch, the process-wide concurrent-batch limiter, and the per-request deadline. Defaults
// (4 / 16 / 60 s) survive when the ServerHistorian section is absent.
_server.NodeManager.HistoryReadBatchParallelism = _serverHistorianOptions.HistoryReadBatchParallelism;
_server.NodeManager.MaxConcurrentHistoryReadBatches = _serverHistorianOptions.MaxConcurrentHistoryReadBatches;
_server.NodeManager.HistoryReadDeadline = _serverHistorianOptions.HistoryReadDeadline;
// Gitea #674: HistoryUpdate is Primary-only (BadInvalidState elsewhere, and closed until this node's first
// redundancy snapshot) and refuses a SourceTimestamp beyond now + HistoryUpdateMaxFutureSkew (BadOutOfRange),
// judged against the host clock.
var historyUpdate = BuildHistoryUpdatePosture();
_server.NodeManager.HistoryUpdatePrimaryGate = historyUpdate.PrimaryGate;
_server.NodeManager.HistoryUpdateMaxFutureSkew = historyUpdate.MaxFutureSkew;
_server.NodeManager.Clock = historyUpdate.Clock;
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
{
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
serverInternal,
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
}
// arch-review 03/S19 — LAST, after the node manager is known non-null and after the ACL gate is
// attached, so the first tick cannot land on a half-wired server.
StartRevalidationTimer();
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
}
/// <summary>
/// Gitea #691 — the <c>ServerHistorian:HonorReturnBounds</c> kill switch <c>StartAsync</c> applies to the node manager
/// (default <c>true</c> when the section or key is absent). Exposed so the wiring is testable without booting the SDK.
/// </summary>
internal bool HonorReturnBounds => _serverHistorianOptions.HonorReturnBounds;
/// <summary>
/// Gitea #694 — the <c>ServerHistorian:EventReadHorizon</c> <c>StartAsync</c> applies to the node manager (default
/// <see cref="TimeSpan.Zero"/> = none). Exposed so the wiring is testable without booting the SDK.
/// </summary>
internal TimeSpan EventReadHorizon => _serverHistorianOptions.EventReadHorizon;
/// <summary>
/// Gitea #674 — what <c>StartAsync</c> wires onto the node manager for HistoryUpdate: the Primary gate (read from
/// the <see cref="IHistoryUpdateRoleView"/> on every call; null when none is registered, i.e. allow), the
/// future-timestamp bound from <c>ServerHistorian:HistoryUpdateMaxFutureSkew</c>, and the host clock it is judged
/// against. Separate from <c>StartAsync</c> so the wiring is testable without booting the SDK.
/// </summary>
/// <returns>The posture to apply.</returns>
internal HistoryUpdatePosture BuildHistoryUpdatePosture()
{
Func<bool>? gate = _historyUpdateRoleView is { } view ? () => view.ShouldServeHistoryUpdate : null;
if (gate is null)
_logger.LogInformation(
"OtOpcUaServerHostedService: no IHistoryUpdateRoleView registered (no redundancy manager on this node) — HistoryUpdate is not Primary-gated");
return new HistoryUpdatePosture(gate, _serverHistorianOptions.HistoryUpdateMaxFutureSkew, _time);
}
/// <summary>Gitea #674 — the HistoryUpdate settings <see cref="BuildHistoryUpdatePosture"/> produces.</summary>
/// <param name="PrimaryGate">The node manager's HistoryUpdate Primary gate; null allows.</param>
/// <param name="MaxFutureSkew">The future-timestamp bound; non-positive disables it.</param>
/// <param name="Clock">The clock the bound is judged against.</param>
internal sealed record HistoryUpdatePosture(Func<bool>? PrimaryGate, TimeSpan MaxFutureSkew, TimeProvider Clock);
/// <summary>
/// Arms the clock-driven monitored-item revalidation tick (arch-review 03/S19).
/// </summary>
/// <remarks>
/// Created unconditionally: the pass it drives early-returns unless the ACL gate is wired AND
/// <c>Security:Acl:Enabled</c> is on, so it stays inert on the default configuration, and
/// <c>RevalidationIntervalSeconds = 0</c> arms no timer at all.
/// <para>
/// The node manager is resolved <b>per tick</b> rather than captured: <see cref="_server"/> is
/// replaced across a restart, and a captured reference would go on revalidating a dead
/// manager's item map — the same detach hazard the authority-source setter documents.
/// </para>
/// <para>Separate from <see cref="StartAsync"/> so the timer's lifecycle is testable without booting the SDK.</para>
/// </remarks>
internal void StartRevalidationTimer()
{
// Dispose before overwriting. StartAsync runs again when a host restarts the service around the
// same instance, and an overwritten field would leave the previous timer armed with nothing left
// to dispose it — the same dispose-before-overwrite rule the driver reconnect paths carry.
_revalidationTimer?.Dispose();
_revalidationTimer = new AclRevalidationTimer(
() => _server?.NodeManager?.RevalidateMonitoredItems(),
TimeSpan.FromSeconds(_aclOptions.RevalidationIntervalSeconds),
_time,
_loggerFactory.CreateLogger<AclRevalidationTimer>());
}
/// <summary>
/// Stops the OPC UA server asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken cancellationToken)
{
// FIRST, ahead of every detach below (arch-review 03/S19). A tick landing on a node manager whose
// authority and gate have already been cleared is the shape this whole teardown block exists to
// prevent — a stopping node sheds the trigger before it sheds what the trigger drives.
_revalidationTimer?.Dispose();
_revalidationTimer = null;
// Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
// half-disposed NodeManager.
_deferredSink.SetSink(null);
_deferredServiceLevel.SetInner(null);
// Restore the Null write gateway so a late client write doesn't Ask a stopping DriverHostActor.
_server?.SetNodeWriteGateway(null);
// Clear the native-alarm-ack router so a late ack doesn't Tell a stopping DriverHostActor.
_server?.SetNativeAlarmAckRouter(null);
_server?.SetNativeAlarmAckPrimaryGate(null);
_server?.SetHistoryUpdatePrimaryGate(null);
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
_server?.SetHistorianDataSource(null);
_server?.SetHistorianDataUpdater(null);
// Restore the empty authority so a late session activation cannot authorize against a stopping node.
_server?.SetDataPlaneAuthority(null);
// Detach the ACL gate too. Leaving it attached alongside an emptied authority would deny every late
// request rather than let the seams fall back to their role checks — a stopping node should shed
// enforcement in the same order it acquired it.
_server?.SetDataPlaneAuthorizer(null);
return Task.CompletedTask;
}
/// <summary>
/// Disposes the hosted service and its resources asynchronously.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
// Before the app host, and idempotent with StopAsync — a host that disposes without stopping must
// not leave a tick armed against a server that is about to go away.
_revalidationTimer?.Dispose();
_revalidationTimer = null;
if (_appHost is not null) await _appHost.DisposeAsync();
}
}