Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
T
Joseph Doherty c8e3e279b4
v2-ci / unit-tests (pull_request) Waiting to run
Retain and replay ServiceLevel across publisher binding and failures
2026-10-13 13:26:27 -04:00

1143 lines
69 KiB
C#

using Akka.Actor;
using Akka.Cluster;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Cluster.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
namespace ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
/// <summary>
/// Single-threaded bridge between Akka messages and the OPC UA SDK address space. Hosted on
/// the pinned <c>opcua-synchronized-dispatcher</c> (HOCON) so the OPC UA SDK sees
/// only one thread per actor instance — its session/subscription locks expect strict
/// single-threaded access.
///
/// Address-space writes route through <see cref="IOpcUaAddressSpaceSink"/>; ServiceLevel
/// writes route through <see cref="IServiceLevelPublisher"/>. Production binds SDK-backed
/// implementations; dev/Mac/tests bind the Null* defaults so the actor stays decoupled from
/// <c>Opc.Ua.Server</c>. The remaining piece is wiring those bindings to a real
/// <c>StandardServer</c> address space — tracked as F10b.
/// </summary>
public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
public const string DispatcherId = "opcua-synchronized-dispatcher";
public const string RedundancyStateTopic = "redundancy-state";
/// <summary>Gets or sets the timer scheduler for the periodic DB-health refresh tick.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Self-tick that drives the periodic Ask of the local <see cref="DbHealthProbeActor"/>
/// for its cached status. Private singleton — the actor pumps this for itself, no external sender.</summary>
private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } }
/// <summary>A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the
/// node's <see cref="AddressSpaceRealm"/> so the sink resolves the right namespace — the driver fan-out
/// posts one of these per registered NodeId (the raw node in <see cref="AddressSpaceRealm.Raw"/> and each
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/status/timestamp.
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.
/// <para><see cref="StatusCode"/> is the full OPC UA status code, sub-code included (#519); the sink
/// hands it to the SDK verbatim.</para></summary>
public sealed record AttributeValueUpdate(string NodeId, object? Value, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>Batched form of <see cref="AttributeValueUpdate"/>: every fan-out write of one driver
/// production burst in one message — the sink takes its lock once for the whole batch. Items carry
/// their realm each (<see cref="ValueWrite"/>); status codes ride verbatim (#519).</summary>
public sealed record AttributeValueBatchUpdate(IReadOnlyList<ValueWrite> Items);
/// <summary>Status-only batch: every named node keeps its CURRENT value and only its status +
/// timestamp change (archreview 03/S15). The comms-loss fan-out sends one of these covering every
/// NodeId a disconnected driver owns — the raw node and each referencing UNS node — so a client sees
/// <see cref="OpcUaStatus.BadNoCommunication"/> over the last known value instead of a stale Good.
/// <para>Deliberately a separate message from <see cref="AttributeValueBatchUpdate"/> rather than a
/// flag on it: the host holds no value cache (v3's single-source design), so it has nothing to put in
/// a <see cref="ValueWrite"/>, and only the node knows what value to retain.</para></summary>
public sealed record AttributeStatusBatchUpdate(IReadOnlyList<StatusWrite> Items);
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
/// <c>Core.ScriptedAlarms</c>.</summary>
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for scripted conditions; == RawPath for
/// v3 native raw conditions).</param>
/// <param name="State">The full condition state to project onto the node.</param>
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>#477 — annotate a materialised condition's source-data Quality out of band from any alarm
/// transition (the driver-connectivity path: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>,
/// restored → <see cref="OpcUaStatus.Good"/>). Routed to <see cref="IOpcUaAddressSpaceSink.WriteAlarmQuality"/>,
/// which sets ONLY Quality and fires one Part 9 event on a status-code change.</summary>
/// <param name="AlarmNodeId">The condition node id (RawPath for a native alarm).</param>
/// <param name="StatusCode">The source-data status code to annotate, sub-code included.</param>
/// <param name="TimestampUtc">The connectivity transition timestamp in UTC.</param>
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
/// applied config + the SubscribeBulk pass. It is null only for legacy/dev callers, which
/// fall back to the latest sealed deployment (lags a not-yet-sealed apply by one revision).
/// </summary>
/// <param name="Correlation">Correlation id for tracing this rebuild.</param>
/// <param name="DeploymentId">The applied deployment whose artifact to materialise, or null for the latest-sealed fallback.</param>
/// <param name="Artifact">
/// The artifact bytes already in hand, used INSTEAD of loading them from the ConfigDb. This is
/// what makes boot-from-cache actually serve its address space: on a central-SQL outage the
/// host has the cached blob but <see cref="DeploymentId"/> alone would drive a ConfigDb read
/// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null
/// on the normal path, where loading from the ConfigDb by id is correct.
/// </param>
/// <param name="ReportOutcome">
/// Gitea #601 — when <see langword="true"/>, the actor replies to the sender with a
/// <see cref="RebuildCompleted"/> once the apply outcome is known (clean or degraded), on EVERY path
/// through the handler. <c>DriverHostActor</c> sets it on the deploy path and sends the deployment's
/// ack only after that reply, so a degraded apply can no longer seal a deployment green. Off for the
/// boot/restore paths, which re-ack nothing.
/// </param>
public sealed record RebuildAddressSpace(
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null,
bool ReportOutcome = false);
/// <summary>
/// Gitea #601 — the reply to a <see cref="RebuildAddressSpace"/> carrying
/// <see cref="RebuildAddressSpace.ReportOutcome"/>: whether the address-space apply was clean.
/// </summary>
/// <param name="Correlation">The rebuild's correlation id, echoed.</param>
/// <param name="DeploymentId">The rebuild's deployment id, echoed (null for a legacy caller).</param>
/// <param name="Degraded">
/// <see langword="true"/> when the apply swallowed per-node sink faults, its rebuild failed, the
/// pipeline threw, or there was no usable artifact — i.e. the served address space may be partial or
/// stale. An empty plan (nothing changed) is clean.
/// </param>
/// <param name="DegradedReason">A short operator-readable reason; null when clean.</param>
public sealed record RebuildCompleted(
CorrelationId Correlation, DeploymentId? DeploymentId, bool Degraded, string? DegradedReason);
public sealed record ServiceLevelChanged(byte ServiceLevel);
private readonly IOpcUaAddressSpaceSink _sink;
private readonly IServiceLevelPublisher _serviceLevel;
private readonly bool _subscribeRedundancyTopic;
private readonly NodeId? _localNode;
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
private readonly AddressSpaceApplier? _applier;
/// <summary>#520 stage 4 — receives the API keys + ACL grants carried by each applied artifact, so
/// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node
/// that is not serving an OPC UA data plane).</summary>
private readonly IDataPlaneAuthoritySink? _dataPlaneAuthority;
private readonly IActorRef? _dbHealthProbe;
/// <summary>Per-cluster mesh Phase 4: this is a driver-only node with NO ConfigDb (LocalDb is its
/// config store). There is no DB to probe, so the ServiceLevel DB-reachability input is retired —
/// <c>DbReachable</c> is fed constant <c>true</c> and only snapshot staleness can demote. See
/// <see cref="RecomputeServiceLevel"/>.</summary>
private readonly bool _dbLess;
private readonly TimeSpan _staleWindow;
private readonly TimeSpan _probeFreshnessWindow;
private readonly TimeSpan _healthTickInterval;
private readonly Akka.Cluster.Cluster _cluster = Akka.Cluster.Cluster.Get(Context.System);
private readonly ILoggingAdapter _log = Context.GetLogger();
private int _writes;
private byte _lastServiceLevel;
private bool _publishedAtLeastOnce;
/// <summary>True once a real composition has been applied, i.e. there is a served address space worth
/// protecting. Distinguishes "the artifact went missing under a running server" (issue #485 — warn, and
/// hold what is materialised) from "nothing has been deployed here yet" (a quiet no-op at boot).</summary>
private bool _hasAppliedComposition;
/// <summary>
/// Gitea #601 review — <see cref="_lastApplied"/> was advanced by a DEGRADED rebuild (a rebuild advances
/// even with swallowed failures, see <see cref="HandleRebuildCore"/>). A re-deploy of the same revision
/// then diffs to an EMPTY plan; without this flag that empty plan reported clean, so the "re-deploy
/// re-applies" repair path sealed green over an address space still known to be partial. While set, an
/// empty plan re-materialises the whole composition (idempotent ensures) and reports clean only when
/// that pass is itself clean.
/// </summary>
private bool _lastAppliedDegraded;
private DbHealthProbeActor.DbHealthStatus? _lastDbHealth;
private RedundancyStateChanged? _lastSnapshot;
private (bool Ok, DateTime At)? _probeAboutMe;
private AddressSpaceComposition _lastApplied = new(
Array.Empty<UnsAreaProjection>(),
Array.Empty<UnsLineProjection>(),
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
/// <summary>Gets the number of writes performed.</summary>
public int WriteCount => _writes;
/// <summary>Gets the last published service level.</summary>
public byte LastServiceLevel => _lastServiceLevel;
/// <summary>Production Props — pins the OPC UA dispatcher + subscribes to the
/// <c>redundancy-state</c> DPS topic so cluster transitions drive the local ServiceLevel
/// publish path. When <paramref name="dbFactory"/> + <paramref name="applier"/> are supplied,
/// <see cref="RebuildAddressSpace"/> reads the latest deployment artifact + drives the
/// applier through the sink. When <paramref name="dbHealthProbe"/> is supplied the local
/// ServiceLevel is computed via <see cref="ServiceLevelCalculator"/> from real DB-health +
/// staleness + role-leader inputs; otherwise the legacy role-only switch is used.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
/// <param name="serviceLevel">The service level publisher.</param>
/// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param>
/// <param name="dbHealthProbe">The optional <see cref="DbHealthProbeActor"/> ref; when null the
/// legacy role-only ServiceLevel seam is used until a <see cref="DbHealthProbeActor.DbHealthStatus"/> arrives.</param>
/// <param name="staleWindow">The window beyond which a DB-health sample or redundancy snapshot is
/// considered stale; defaults to 30 seconds.</param>
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
/// <returns>The configured <see cref="Props"/> for creating this actor, pinned to the
/// <see cref="DispatcherId"/> dispatcher.</returns>
public static Props Props(
IOpcUaAddressSpaceSink? sink = null,
IServiceLevelPublisher? serviceLevel = null,
NodeId? localNode = null,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
AddressSpaceApplier? applier = null,
IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
subscribeRedundancyTopic: true,
localNode,
dbFactory,
applier,
dbHealthProbe,
staleWindow,
probeFreshnessWindow,
healthTickInterval,
dbLess,
dataPlaneAuthority)).WithDispatcher(DispatcherId);
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
/// <param name="serviceLevel">The service level publisher.</param>
/// <param name="subscribeRedundancyTopic">Whether to subscribe to the redundancy topic.</param>
/// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param>
/// <param name="dbHealthProbe">The optional <see cref="DbHealthProbeActor"/> ref; when null the
/// legacy role-only ServiceLevel seam is used until a <see cref="DbHealthProbeActor.DbHealthStatus"/> arrives.</param>
/// <param name="staleWindow">The window beyond which a DB-health sample or redundancy snapshot is
/// considered stale; defaults to 30 seconds.</param>
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
/// <returns>The configured <see cref="Props"/> for creating this actor in tests, without dispatcher
/// pinning or the DPS subscribe.</returns>
public static Props PropsForTests(
IOpcUaAddressSpaceSink? sink = null,
IServiceLevelPublisher? serviceLevel = null,
bool subscribeRedundancyTopic = false,
NodeId? localNode = null,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
AddressSpaceApplier? applier = null,
IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
subscribeRedundancyTopic,
localNode,
dbFactory,
applier,
dbHealthProbe,
staleWindow,
probeFreshnessWindow,
healthTickInterval,
dbLess,
dataPlaneAuthority));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
/// <param name="serviceLevel">The service level publisher.</param>
/// <param name="subscribeRedundancyTopic">Whether to subscribe to the redundancy topic.</param>
/// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param>
/// <param name="dbHealthProbe">The optional <see cref="DbHealthProbeActor"/> ref; when null the
/// legacy role-only ServiceLevel seam is used until a <see cref="DbHealthProbeActor.DbHealthStatus"/> arrives.</param>
/// <param name="staleWindow">The window beyond which a DB-health sample or redundancy snapshot is
/// considered stale; defaults to 30 seconds.</param>
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
public OpcUaPublishActor(
IOpcUaAddressSpaceSink sink,
IServiceLevelPublisher serviceLevel,
bool subscribeRedundancyTopic,
NodeId? localNode,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
AddressSpaceApplier? applier = null,
IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null)
{
_sink = sink;
_dataPlaneAuthority = dataPlaneAuthority;
_serviceLevel = serviceLevel;
_subscribeRedundancyTopic = subscribeRedundancyTopic;
_localNode = localNode;
_dbFactory = dbFactory;
_applier = applier;
_dbHealthProbe = dbHealthProbe;
_dbLess = dbLess;
_staleWindow = staleWindow ?? TimeSpan.FromSeconds(30);
_probeFreshnessWindow = probeFreshnessWindow ?? TimeSpan.FromSeconds(30);
_healthTickInterval = healthTickInterval ?? TimeSpan.FromSeconds(5);
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
Receive<AttributeValueBatchUpdate>(HandleAttributeBatchUpdate);
Receive<AttributeStatusBatchUpdate>(HandleAttributeStatusBatchUpdate);
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
Receive<RebuildAddressSpace>(HandleRebuild);
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
Receive<DbHealthProbeActor.DbHealthStatus>(HandleDbHealthStatus);
Receive<HealthTick>(_ => OnHealthTick());
Receive<PeerOpcUaProbeActor.OpcUaProbeResult>(HandlePeerProbe);
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
}
/// <inheritdoc />
protected override void PreStart()
{
if (_subscribeRedundancyTopic)
{
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(RedundancyStateTopic, Self));
}
// Production delivery of DB-health: when a probe is wired, kick an immediate refresh + start the
// periodic tick so _lastDbHealth gets populated and kept fresh without any external pump. Gated
// ONLY on the probe being present (independent of the DPS subscribe) so the calculator path works
// in tests too. No probe → stay on the legacy role-only seam (no timer).
if (_dbHealthProbe is not null)
{
Self.Tell(HealthTick.Instance); // immediate first refresh
Timers.StartPeriodicTimer("db-health", HealthTick.Instance, _healthTickInterval);
}
}
private void HandleAttributeUpdate(AttributeValueUpdate msg)
{
try
{
_sink.WriteValue(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteValue threw for {Node}", msg.NodeId);
}
}
/// <summary>Mirrors <see cref="HandleAttributeUpdate"/> for a whole burst: ONE
/// <see cref="IOpcUaAddressSpaceSink.WriteValues"/> call, but the write tally and the
/// <c>otopcua.opcua.sink.write</c> meter still advance PER ITEM — batching is a transport change, so
/// dashboards must not drop by the batch factor.
/// <para>A <see cref="ValueWriteBatchException"/> is a PARTIAL failure: the sink applied every item it
/// could and is telling us which one it couldn't. So it is caught ahead of the general catch to (a) name
/// the offending node in the log — a bare "the batch threw" cannot be acted on — and (b) still count the
/// items that DID land, keeping the per-item counting invariant honest instead of silently under-reporting
/// a whole batch because one item was bad.</para></summary>
private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg)
{
try
{
_sink.WriteValues(msg.Items);
Interlocked.Add(ref _writes, msg.Items.Count);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(msg.Items.Count, new KeyValuePair<string, object?>("kind", "value"));
}
catch (ValueWriteBatchException ex)
{
_log.Warning(ex,
"OpcUaPublish: sink.WriteValues partially failed for a {Count}-item batch — {Failed} failed " +
"(first: {Node}), {Succeeded} applied",
msg.Items.Count, ex.FailedCount, ex.FirstFailedNodeId, ex.SucceededCount);
Interlocked.Add(ref _writes, ex.SucceededCount);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(ex.SucceededCount, new KeyValuePair<string, object?>("kind", "value"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteValues threw for a {Count}-item batch", msg.Items.Count);
}
}
/// <summary>Routes a status-only batch to <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> — the
/// comms-loss annotation (03/S15). Same shape as <see cref="HandleAttributeBatchUpdate"/>: metered per
/// ITEM (batching is a transport change, so dashboards must not drop by the batch factor), a
/// <see cref="ValueWriteBatchException"/> is a partial failure that still counts what landed and names
/// the offender, and any other throw is swallowed so a sink fault cannot restart the publish actor and
/// drop live traffic. Metered under <c>kind:"status"</c> so a status annotation is separable from a
/// value write on the same counter.</summary>
private void HandleAttributeStatusBatchUpdate(AttributeStatusBatchUpdate msg)
{
try
{
_sink.WriteStatuses(msg.Items);
Interlocked.Add(ref _writes, msg.Items.Count);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(msg.Items.Count, new KeyValuePair<string, object?>("kind", "status"));
}
catch (ValueWriteBatchException ex)
{
_log.Warning(ex,
"OpcUaPublish: sink.WriteStatuses partially failed for a {Count}-item batch — {Failed} failed " +
"(first: {Node}), {Succeeded} applied",
msg.Items.Count, ex.FailedCount, ex.FirstFailedNodeId, ex.SucceededCount);
Interlocked.Add(ref _writes, ex.SucceededCount);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(ex.SucceededCount, new KeyValuePair<string, object?>("kind", "status"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteStatuses threw for a {Count}-item batch", msg.Items.Count);
}
}
private void HandleAlarmUpdate(AlarmStateUpdate msg)
{
try
{
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmCondition threw for {Node}", msg.AlarmNodeId);
}
}
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
{
try
{
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.StatusCode, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm-quality"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmQuality threw for {Node}", msg.AlarmNodeId);
}
}
private void HandleRebuild(RebuildAddressSpace msg)
{
// Captured up front: the pipeline below is synchronous on this actor's thread today, but the reply
// must reach whoever asked even if a future edit introduces an await into it.
var requester = Sender;
(bool Degraded, string? Reason) outcome;
try
{
outcome = HandleRebuildCore(msg);
}
finally
{
// Gitea #641 — close the apply on EVERY exit, early returns and a throwing pass included: the node
// manager reports here the native conditions a rebuild carried over (after the notifier wiring the
// passes did) and discards any captured state no materialise claimed. Skipping it on a throw would
// leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is
// pending; never throws out of the handler.
try { _sink.CompleteAddressSpaceApply(); }
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
}
}
// Gitea #601 — the outcome goes out only after the apply (including #641's completion step) is done.
if (msg.ReportOutcome)
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
}
/// <summary>The rebuild pipeline. Returns whether the apply was degraded, and why (Gitea #601).</summary>
private (bool Degraded, string? Reason) HandleRebuildCore(RebuildAddressSpace msg)
{
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
// Two modes. With an applier wired, do a REAL diff-and-apply pass — driven either by the
// artifact bytes already in hand (a per-cluster-mesh driver-only / FetchAndCache node, whose
// ConfigDb is admin-only now so _dbFactory is null, hands its fetched/cached bytes in
// msg.Artifact) or, when no bytes are carried, by loading the artifact from the ConfigDb (the
// Direct path, which requires _dbFactory). Only a node with NO applier at all (the F10b/dev
// seam) falls back to the raw-sink rebuild — and that fallback WIPES the served address space
// without re-materialising, so it must NEVER be reached by a real driver node that has an
// applier but simply has no ConfigDb (that node diff-and-applies from its in-hand bytes below).
if (_applier is null)
{
try
{
_sink.RebuildAddressSpace();
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
}
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
msg.Correlation);
return (true, $"address-space rebuild threw: {ex.Message}");
}
return (false, null);
}
try
{
// An in-hand artifact (boot-from-cache / FetchAndCache) wins: the host already has the
// cached/fetched bytes, and the ConfigDb read the DeploymentId path would do is exactly what
// is unreachable during the outage this exists to survive — and is simply absent on a
// driver-only node with no ConfigDb. Otherwise, when a ConfigDb is present (Direct mode),
// prefer the artifact of the deployment the host just applied — at apply time it is not yet
// Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a stale
// composition (variables that don't match the SubscribeBulk refs); fall back to latest-sealed
// only for legacy callers that carry neither. With NO bytes AND no ConfigDb there is nothing
// to load, so yield Array.Empty<byte>() — the #485 guard below reads that as "no answer" and
// abandons the rebuild, holding last-known-good rather than wiping the address space.
var artifact = msg.Artifact
?? (_dbFactory is not null
? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact())
: Array.Empty<byte>());
// Issue #485 — an artifact we could not obtain is NOT an empty configuration. Parsing zero
// bytes yields an empty composition, which the planner diffs against the live one as a
// PureRemove and the applier then faithfully tears down: a transient ConfigDb blip empties the
// served address space. Nothing legitimate produces a zero-length blob (an operator who really
// deletes everything still deploys a JSON document with empty arrays), so the only honest
// reading of "no bytes" is "no answer" — hold the last-known-good address space and let the
// next deploy (or the next boot) supply a real one. Returning here also leaves _lastApplied
// intact, so the retry diffs against what is actually materialised.
if (artifact is null or { Length: 0 })
{
if (_hasAppliedComposition)
_log.Warning(
"OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down",
msg.DeploymentId, msg.Correlation);
else
_log.Debug(
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
msg.DeploymentId, msg.Correlation);
return (true, "no usable artifact reached the address-space apply; the previously served address space was kept");
}
// Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the
// composition) reads this one JsonDocument instead of rebuilding it per question — the rebuild
// used to pay a full parse-and-throw-away per static call. Disposed when the handler returns.
//
// Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an
// EMPTY result — and an empty composition is not a benign no-op here: the planner diffs it as a
// PureRemove and the applier tears the served address space down. So the throw is the honest
// outcome, and it lands in this method's catch below: logged at Error, _lastApplied untouched,
// last-known-good still served. Same "no answer ≠ empty configuration" rule as the #485 guard
// immediately above, applied to bytes that arrived unreadable rather than absent.
using var parsed = DeploymentArtifact.Open(artifact);
// #520 stage 4 — install this artifact's API keys + ACL grants BEFORE the address-space work
// below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a
// key or edits a grant without touching a single node produces an EMPTY address-space plan, so
// feeding this after that return would leave a revoked key authenticating until some unrelated
// structural change happened to redeploy. Authorization and topology change on their own
// schedules.
FeedDataPlaneAuthority(parsed, artifact);
var composition = _localNode is { } ln
? parsed.ParseComposition(ln.Value,
inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency))
: parsed.ParseComposition();
var plan = AddressSpacePlanner.Compute(_lastApplied, composition);
if (plan.IsEmpty)
{
if (_lastAppliedDegraded)
{
// #601 review — the composition is unchanged but the last apply of it was a degraded
// rebuild: re-ensure every node (each Ensure* no-ops on a node that stands) so the
// re-deploy actually repairs what that rebuild could not place.
var repairFailed = _applier.MaterialiseHierarchy(composition)
+ MaterialiseNodes(plan, composition, fullComposition: true);
if (repairFailed > 0)
{
OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair<string, object?>("kind", "nodes"));
_log.Error(
"OpcUaPublish: re-apply of a degraded composition is STILL degraded (correlation={Correlation}, failedNodes={FailedNodes})",
msg.Correlation, repairFailed);
return (true, $"re-apply of a previously degraded address space is still degraded (failedNodes={repairFailed})");
}
_lastAppliedDegraded = false;
_log.Info("OpcUaPublish: re-apply repaired the previously degraded address space (correlation={Correlation})",
msg.Correlation);
return (false, null);
}
_log.Debug("OpcUaPublish: rebuild requested but plan is empty (correlation={Correlation})",
msg.Correlation);
return (false, null);
}
var outcome = _applier.Apply(plan);
// Sum swallowed per-node materialise failures across every pass together with Apply's own
// removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
var failedNodes = outcome.FailedNodes;
// The UNS Area/Line/Equipment folder hierarchy is materialised in FULL on every apply, rebuild or
// not: it is O(areas + lines + equipment) — trivial next to the tag counts the delta passes below
// exist to bound — and every node the delta passes add needs its ancestor folders to already
// exist, so narrowing it would mean re-deriving each add's ancestor closure (strictly more work).
failedNodes += _applier.MaterialiseHierarchy(composition);
failedNodes += MaterialiseNodes(plan, composition, fullComposition: outcome.RebuildCalled);
// 2026-08-15 surgical-deploys review C1 — record the composition as applied ONLY when the apply was
// clean, or when a rebuild ran. Two reasons this sits HERE (after the materialise passes) rather
// than immediately after Apply:
//
// • A pass that THROWS outright leaves _lastApplied at the previous composition (the outer catch
// below takes over), so the next deploy re-diffs against what is actually materialised.
// • A DEGRADED non-rebuild apply (failedNodes > 0, no rebuild) must not advance either. Before the
// delta materialisation the full passes re-ensured every node on every apply, which silently
// repaired any prior swallowed sink fault — an accidental self-healing mechanism. The delta path
// removed it: a node whose EnsureVariable failed is inside _lastApplied, never appears in a diff
// set again, and EnsureVariable early-returns on the ones that DID land — so the failed node is
// lost until some unrelated change happens to force a rebuild. Holding the composition back is
// the replacement: the next deploy re-diffs the same composition and re-ensures what failed.
//
// What the re-diff costs, stated honestly: it also re-emits the previous apply's REMOVES. A
// re-remove of an already-removed node returns false from the surgical sink, which trips the
// applier's one-way ratchet into a full rebuild — the correct resync for an apply that has already
// been logged at Error as degraded, and a bounded one (it happens once, then the address space is
// known-good again).
//
// A rebuild DOES advance even with failures: it already re-materialised the whole composition, and
// the ratchet is the resync path for anything it could not place. Holding it back would make every
// subsequent deploy re-diff the entire address space as added.
if (outcome.RebuildCalled || failedNodes == 0)
{
_lastApplied = composition;
_hasAppliedComposition = true;
// #601 review — advanced despite failures only on a rebuild; remember it so an identical
// re-deploy repairs instead of short-circuiting clean on the empty plan.
_lastAppliedDegraded = outcome.RebuildFailed || failedNodes > 0;
}
else
{
_log.Error(
"OpcUaPublish: degraded non-rebuild apply (correlation={Correlation}, failedNodes={FailedNodes}) — NOT recording this composition as applied, so the next deploy re-diffs it and repairs the missing node(s)",
msg.Correlation, failedNodes);
}
// R2-07 T4b — on a NON-rebuild apply (PureAdd / AttributeOnly), the Materialise passes above
// just created exactly the added nodes WITHOUT tearing anything down, so announce them with a
// Part 3 GeneralModelChangeEvent(NodeAdded) per affected parent — model-aware clients then
// re-browse and pick up the new nodes while every existing MonitoredItem stays alive. Ordering
// is correct by construction: Apply returned before the passes ran, and this announce runs after
// them, so the nodes exist when clients re-browse. After a full rebuild the announcement is moot
// (subscriptions are dead anyway) — the guard skips it. AnnounceAddedNodes is Safe-wrapped
// internally, so a faulting announce can never break the deploy.
//
// The announce is realm-aware (Gitea #599): UNS parents in the Uns realm, and the parents of
// added RAW containers/tags in the Raw realm. It matters because of the delta work — before it, a
// plan carrying raw adds went through a full RebuildAddressSpace, so every client re-browsed
// anyway; now those adds are materialised surgically while existing subscriptions stay alive, and
// an unannounced add would stay invisible until a client happened to browse again.
if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
if (outcome.RebuildFailed || failedNodes > 0)
{
OtOpcUaTelemetry.OpcUaApplyFailed.Add(1,
new KeyValuePair<string, object?>("kind", outcome.RebuildFailed ? "rebuild" : "nodes"));
_log.Error(
"OpcUaPublish: address-space apply DEGRADED (correlation={Correlation}, rebuildFailed={RebuildFailed}, failedNodes={FailedNodes}, added={Added}, removed={Removed}, changed={Changed}) — the running address space may be stale or partial",
msg.Correlation, outcome.RebuildFailed, failedNodes,
outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes);
return (true, outcome.RebuildFailed
? $"address-space rebuild failed (failedNodes={failedNodes})"
: $"address-space apply degraded (failedNodes={failedNodes}); not recorded as applied, the next deploy of this revision re-diffs and repairs it");
}
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
return (false, null);
}
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", msg.Correlation);
return (true, $"address-space apply threw: {ex.Message}");
}
}
/// <summary>
/// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
/// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over
/// just this plan's delta (surgical-deploys Task 6). <c>MaterialiseHierarchy</c> is NOT one of them:
/// the caller always runs it in full, because parents must exist before any add.
/// </summary>
/// <remarks>
/// <para><b>Why the delta path exists.</b> The passes are idempotent, but idempotent is not free.
/// <c>EnsureFolder</c> / <c>EnsureVariable</c> early-return on a <c>ConcurrentDictionary</c> hit
/// BEFORE taking the node manager's lock, so an already-standing node costs a dictionary probe rather
/// than a lock acquisition — but <c>MaterialiseAlarmCondition</c> DOES take the lock every time, and
/// the probes are still O(address space) per apply plus the composition walk that produces them.
/// Running the passes over the whole composition made the cost of ANY apply proportional to the size
/// of the served address space rather than to the size of the change: adding one tag to a 50k-node
/// server was ~50k sink calls on the actor's pinned publish thread, with every client's publish
/// blocked behind it. The delta path makes it O(change).</para>
/// <para><b>What the full pass was silently doing, and its replacement.</b> Re-ensuring every node on
/// every apply also REPAIRED any node a previous apply had swallowed a sink fault on — accidental
/// self-healing nobody designed. The caller's degraded-apply no-advance rule (a non-rebuild apply
/// reporting <c>failedNodes &gt; 0</c> does not record its composition in <c>_lastApplied</c>, so the
/// next deploy re-diffs and re-ensures it) is the deliberate replacement for it.</para>
/// <para><b>Why <see cref="AddressSpaceApplyOutcome.RebuildCalled"/> is the branch key</b> rather than
/// the classifier's kind: a surgical apply that hits a <c>false</c>/throw from the surgical sink falls
/// back to a full <c>RebuildAddressSpace()</c> INSIDE <c>Apply</c>, which wipes every node. That plan
/// is still classified non-Rebuild, so branching on the kind would re-materialise only the delta onto
/// a wiped address space. The outcome flag reports what actually happened.</para>
/// <para><b>Why changed UNS references are in the subset.</b> <c>Apply</c> repairs a
/// <c>UnsReferenceDelta</c> by REMOVING the previous node and leaving the recreate to this pass, so
/// the subset must carry the <i>current</i> side of every changed reference as well as the added ones
/// — otherwise a re-pointed, renamed, or backing-edited UNS node is removed and never comes back.</para>
/// <para><b>Accepted narrowing.</b> The plan diffs the THIN <c>ScriptedAlarmPlans</c> projection, so
/// the added-alarm subset is the rich <c>EquipmentScriptedAlarms</c> rows whose id the plan reports as
/// added. An alarm already present in both compositions whose predicate SCRIPT row only now arrived
/// (making it materialisable for the first time) is not in that set — but neither is it in any diff
/// set at all, so on its own it already produced an empty plan and materialised nothing. The delta
/// path extends that pre-existing blindness to the case where it rides along with another change.</para>
/// </remarks>
/// <param name="plan">The plan just applied — the source of the delta subsets.</param>
/// <param name="composition">The full composition, always passed as the passes' lookup context.</param>
/// <param name="fullComposition">When true, materialise the whole composition (post-rebuild).</param>
/// <returns>The summed count of swallowed per-node sink failures across the passes that ran.</returns>
private int MaterialiseNodes(AddressSpacePlan plan, AddressSpaceComposition composition, bool fullComposition)
{
var applier = _applier!;
var failedNodes = 0;
// v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then
// tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw
// edge finds its raw target. The driver binds live values to these raw NodeIds.
if (fullComposition)
{
failedNodes += applier.MaterialiseRawSubtree(composition);
}
else if (plan.AddedRawContainers.Count > 0 || plan.AddedRawTags.Count > 0)
{
failedNodes += applier.MaterialiseRawSubtree(composition, plan.AddedRawContainers, plan.AddedRawTags);
}
// v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment
// folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw
// node. Runs AFTER both the equipment folders AND the raw subtree exist.
if (fullComposition)
{
failedNodes += applier.MaterialiseUnsReferences(composition);
}
else
{
var unsReferences = DeltaUnsReferences(plan);
if (unsReferences.Count > 0) failedNodes += applier.MaterialiseUnsReferences(composition, unsReferences);
}
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
// alarms are skipped.
if (fullComposition)
{
failedNodes += applier.MaterialiseScriptedAlarms(composition);
}
else
{
var addedAlarms = DeltaScriptedAlarms(plan, composition);
if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms);
}
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
// FolderPath sub-folder) exists under its already-materialised equipment folder so
// clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after
// each subscription cycle; variables show BadWaitingForInitialData only until the first
// publish interval fires.
if (fullComposition)
{
failedNodes += applier.MaterialiseEquipmentTags(composition);
}
else if (plan.AddedEquipmentTags.Count > 0)
{
failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags);
}
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
// equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live
// values once the first dependency update arrives; until then variables show BadWaitingForInitialData.
if (fullComposition)
{
failedNodes += applier.MaterialiseEquipmentVirtualTags(composition);
}
else if (plan.AddedEquipmentVirtualTags.Count > 0)
{
failedNodes += applier.MaterialiseEquipmentVirtualTags(composition, plan.AddedEquipmentVirtualTags);
}
return failedNodes;
}
/// <summary>The UNS reference variables a non-rebuild apply must materialise: the plan's ADDED references
/// plus the CURRENT side of every CHANGED one (whose previous node the applier already removed in place).
/// </summary>
/// <param name="plan">The plan just applied.</param>
/// <returns>The references to materialise (possibly empty).</returns>
private static IReadOnlyList<UnsReferenceVariable> DeltaUnsReferences(AddressSpacePlan plan)
{
if (plan.ChangedUnsReferenceVariables.Count == 0) return plan.AddedUnsReferenceVariables;
var list = new List<UnsReferenceVariable>(
plan.AddedUnsReferenceVariables.Count + plan.ChangedUnsReferenceVariables.Count);
list.AddRange(plan.AddedUnsReferenceVariables);
foreach (var d in plan.ChangedUnsReferenceVariables) list.Add(d.Current);
return list;
}
/// <summary>The scripted-alarm host plans a non-rebuild apply must materialise: the rich
/// <c>EquipmentScriptedAlarms</c> rows whose <c>ScriptedAlarmId</c> the plan reports as ADDED (the plan
/// diffs only the thin projection — see the narrowing note on <see cref="MaterialiseNodes"/>).</summary>
/// <param name="plan">The plan just applied.</param>
/// <param name="composition">The full composition carrying the rich alarm plans.</param>
/// <returns>The alarms to materialise (possibly empty).</returns>
private static IReadOnlyList<EquipmentScriptedAlarmPlan> DeltaScriptedAlarms(
AddressSpacePlan plan, AddressSpaceComposition composition)
{
if (plan.AddedAlarms.Count == 0 || composition.EquipmentScriptedAlarms.Count == 0)
return Array.Empty<EquipmentScriptedAlarmPlan>();
var addedIds = new HashSet<string>(plan.AddedAlarms.Select(a => a.ScriptedAlarmId), StringComparer.Ordinal);
return composition.EquipmentScriptedAlarms.Where(a => addedIds.Contains(a.ScriptedAlarmId)).ToList();
}
/// <summary>
/// Decode the artifact's data-plane authorization inputs (API keys + this cluster's ACL grants)
/// and install them for OPC UA session activation (#520 stage 4). No-op when nothing consumes
/// them.
/// </summary>
/// <remarks>
/// Wrapped so a decode failure can never break a deploy — the same discipline as the historised-ref
/// feed. The cost of that choice is explicit: the node keeps serving the PREVIOUS key set, which is
/// the safe direction for availability but means a revocation carried by an artifact this node
/// could not decode has not taken effect. It is logged at Error for exactly that reason.
/// </remarks>
/// <param name="parsed">The rebuild's single parse of the artifact, read for the keys + grants.</param>
/// <param name="artifactBytes">The RAW artifact bytes — hashed for the revision id, which must be
/// SHA-256 of the exact blob (see below); never re-parsed here.</param>
private void FeedDataPlaneAuthority(ParsedDeploymentArtifact parsed, byte[] artifactBytes)
{
if (_dataPlaneAuthority is null) return;
try
{
var nodeId = _localNode?.Value ?? string.Empty;
var projection = parsed.ParseDataPlaneAuthority(nodeId);
// The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses,
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
// Hashed off the RAW bytes for exactly that reason: a re-serialisation of the parsed document
// would be a different byte sequence and would not match the deployment's RevisionHash.
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12],
projection.Keys.Count, projection.Grants.Count, projection.ScopePaths.Count);
// Reported BY the trie build (#520 stage 5), not re-derived here. The previous version of this
// log counted unresolvable ScopeIds itself and so agreed with the builder on exactly one of its
// four drop reasons — a row with a blank ScopeId, a retired scope kind, or a scope path that
// does not start at a realm all vanished with the log still reading zero. A dropped row is an
// authored rule that can never match, and this is the operator's only warning of it.
if (snapshot.DroppedGrants.Count > 0)
{
_log.Warning(
"OpcUaPublish: {Dropped} ACL rule(s) in generation {Generation} were DROPPED and will never match — {Detail}",
snapshot.DroppedGrants.Count, snapshot.GenerationId,
string.Join("; ", snapshot.DroppedGrants
.Select(d => $"acl {d.NodeAclId} ({d.ScopeKind} '{d.ScopeId}') {d.Reason}")));
}
if (string.IsNullOrWhiteSpace(projection.ClusterId) && projection.Grants.Count > 0)
{
// No cluster identity means no trie was built at all, so nothing can be granted here. Say
// so explicitly: with enforcement on, this node denies every write, alarm command and
// HistoryRead, and the grant count logged above would otherwise suggest the opposite.
_log.Warning(
"OpcUaPublish: this node has no cluster identity, so NO permission trie was built for its {Grants} grant(s) — with Security:Acl:Enabled every scoped decision here will be denied",
projection.Grants.Count);
}
}
catch (Exception ex)
{
_log.Error(ex,
"OpcUaPublish: failed to install data-plane authority from the applied artifact — the node keeps the PREVIOUS keys and grants, so any revocation in this deployment has NOT taken effect");
}
}
/// <summary>Read a specific deployment's artifact blob from ConfigDb (the one just applied,
/// which may not be Sealed yet). Empty array on any failure — <see cref="HandleRebuild"/> treats that as
/// "no answer" and abandons the rebuild, leaving the served address space untouched (issue #485).</summary>
private byte[] LoadArtifact(DeploymentId deploymentId)
{
try
{
using var db = _dbFactory!.CreateDbContext();
return db.Deployments.AsNoTracking()
.Where(d => d.DeploymentId == deploymentId.Value)
.Select(d => d.ArtifactBlob)
.FirstOrDefault() ?? Array.Empty<byte>();
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; the rebuild is abandoned", deploymentId);
return Array.Empty<byte>();
}
}
/// <summary>Read the most recent <c>Sealed</c> deployment's artifact blob from ConfigDb.
/// Empty array on any failure — see <see cref="LoadArtifact"/> for how that is handled.</summary>
private byte[] LoadLatestArtifact()
{
try
{
using var db = _dbFactory!.CreateDbContext();
return db.Deployments.AsNoTracking()
.Where(d => d.Status == Configuration.Enums.DeploymentStatus.Sealed)
.OrderByDescending(d => d.SealedAtUtc)
.Select(d => d.ArtifactBlob)
.FirstOrDefault() ?? Array.Empty<byte>();
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; the rebuild is abandoned");
return Array.Empty<byte>();
}
}
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
{
// Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a
// node starting Detached/role-less (first level = 0) would be dedup'd away, leaving the SDK's
// built-in default (255 = full service) standing — a degraded node wrongly advertising 255.
if (_publishedAtLeastOnce && msg.ServiceLevel == _lastServiceLevel) return;
try
{
_serviceLevel.Publish(msg.ServiceLevel);
_lastServiceLevel = msg.ServiceLevel;
_publishedAtLeastOnce = true;
OtOpcUaTelemetry.ServiceLevelChange.Add(1,
new KeyValuePair<string, object?>("level", msg.ServiceLevel));
_log.Debug("OpcUaPublish: ServiceLevel={Level}", msg.ServiceLevel);
}
catch (Exception ex)
{
// A failed write may be partially applied; even a previous successful value must retry.
_publishedAtLeastOnce = false;
_log.Warning(ex, "OpcUaPublish: ServiceLevel publisher threw at level {Level}", msg.ServiceLevel);
}
}
/// <summary>Caches the latest redundancy snapshot and recomputes the local ServiceLevel.
/// The actual byte is produced by <see cref="RecomputeServiceLevel"/> — either via the
/// health-aware <see cref="ServiceLevelCalculator"/> (once a DB-health probe + sample are
/// wired) or via the legacy role-only seam (back-compat / bootstrap).</summary>
private void HandleRedundancyStateChanged(RedundancyStateChanged msg)
{
_lastSnapshot = msg;
RecomputeServiceLevel();
}
/// <summary>Caches the latest DB-health sample and recomputes the local ServiceLevel. The
/// probe pushes these (or the actor Asks for them); either way the freshest sample feeds the
/// calculator's <c>DbReachable</c>/<c>Stale</c> inputs.</summary>
private void HandleDbHealthStatus(DbHealthProbeActor.DbHealthStatus msg)
{
_lastDbHealth = msg;
RecomputeServiceLevel();
}
/// <summary>Periodic self-tick: Asks the local <see cref="DbHealthProbeActor"/> for its cached
/// status and pipes the reply back to <see cref="Self"/>, where <see cref="HandleDbHealthStatus"/>
/// caches it + recomputes. A hung/late probe (Ask timeout) pipes a <c>Reachable=false</c> status so
/// the node FAIL-SAFE-DEMOTES rather than freezing the last-known-good. The continuation only
/// CONSTRUCTS a record off the actor thread — the actual state mutation happens on the actor thread
/// when the piped <see cref="DbHealthProbeActor.DbHealthStatus"/> is received, so no actor field is
/// touched here.</summary>
private void OnHealthTick()
{
_dbHealthProbe!.Ask<DbHealthProbeActor.DbHealthStatus>(
DbHealthProbeActor.GetStatus.Instance, TimeSpan.FromSeconds(1))
.ContinueWith(t => t.IsCompletedSuccessfully
? t.Result
: new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "db-health ask timeout"))
.PipeTo(Self);
}
/// <summary>Records a peer's OPC UA probe verdict about THIS node and recomputes the local
/// ServiceLevel. The probe's <see cref="PeerOpcUaProbeActor.OpcUaProbeResult.NodeId"/> is the
/// target that was probed, so a result whose <c>NodeId</c> is not this node is about a peer and
/// is ignored. A matching result is stamped with the receive time so <see cref="OpcUaProbeOk"/>
/// can debounce stale verdicts.</summary>
private void HandlePeerProbe(PeerOpcUaProbeActor.OpcUaProbeResult r)
{
// The result targets the probed node. If it isn't me, it's about a peer — ignore it.
if (_localNode is null || r.NodeId != _localNode.Value) return;
_probeAboutMe = (r.Ok, DateTime.UtcNow);
RecomputeServiceLevel();
}
/// <summary>The OPC UA self-probe input for the calculator: "did a peer recently observe MY OPC UA
/// endpoint as reachable?" Returns <c>true</c> (benefit of the doubt) when no peer verdict has
/// arrived yet (single-node / no peer) or when the latest verdict is older than
/// <see cref="_probeFreshnessWindow"/> (the peer went away — don't penalise this node for that).
/// Only an actively-observed, RECENT <c>Ok==false</c> demotes.</summary>
private bool OpcUaProbeOk()
{
if (_probeAboutMe is not { } verdict) return true;
if (DateTime.UtcNow - verdict.At > _probeFreshnessWindow) return true;
return verdict.Ok;
}
/// <summary>
/// Computes the local OPC UA ServiceLevel and routes it through <see cref="ServiceLevelChanged"/>
/// (the dedup/publish/metric handler). The full <see cref="ServiceLevelCalculator"/> path is
/// used once a DB-health probe is wired AND a sample has arrived; until then (and when no probe
/// is supplied at all) a legacy role-only seam keeps the historical "primary-leader → 240,
/// secondary → 100, detached → 0" behaviour. The calculator does not model Detached, so a
/// detached local node is guarded to 0 before either path runs.
/// </summary>
private void RecomputeServiceLevel()
{
if (_localNode is null || _lastSnapshot is null) return;
var entry = _lastSnapshot.Nodes.FirstOrDefault(n => n.NodeId == _localNode.Value);
// The calculator does NOT model Detached — a healthy detached node would wrongly compute
// 240, so guard it (and the missing-entry case) to 0 here.
if (entry is null || entry.Role == RedundancyRole.Detached)
{
Self.Tell(new ServiceLevelChanged(0));
return;
}
// Per-cluster mesh Phase 4: on a driver-only (DB-less) node there is no ConfigDb to probe —
// LocalDb IS the config store and is in-process, so it can never be "unreachable". Retire the
// DB-reachability input by feeding DbReachable=true CONSTANT and deriving staleness from the
// snapshot age ALONE (no DB-health term). This is the survive-alone posture and it is
// client-visible: a healthy DB-less node must publish 240 (240+10=250 as Primary) even with
// central down, so clients keep steering to it — the old DbReachable=false would have computed
// the (false,true,false) → 0 arm and driven clients AWAY from a node serving perfectly.
// Staleness ("running behind on config") still demotes to 200. The Detached guard above still
// wins, so a DB-less Detached node stays 0.
if (_dbLess)
{
var utcNow = DateTime.UtcNow;
var dbLessInputs = new NodeHealthInputs(
MemberState: SafeSelfStatus(),
DbReachable: true,
OpcUaProbeOk: OpcUaProbeOk(),
Stale: (utcNow - entry.AsOfUtc) > _staleWindow,
IsDriverPrimary: entry.IsDriverPrimary);
Self.Tell(new ServiceLevelChanged(ServiceLevelCalculator.Compute(dbLessInputs)));
return;
}
// Legacy / back-compat seam: with no DB-health probe wired (or before the first sample
// arrives) fall back to the old role-only switch. This preserves historical behaviour and
// is the bootstrap value until the first DbHealthStatus lands.
if (_dbHealthProbe is null || _lastDbHealth is null)
{
Self.Tell(new ServiceLevelChanged(LegacyRoleOnly(entry)));
return;
}
var now = DateTime.UtcNow;
var inputs = new NodeHealthInputs(
MemberState: SafeSelfStatus(),
DbReachable: _lastDbHealth.Reachable,
OpcUaProbeOk: OpcUaProbeOk(),
Stale: !_lastDbHealth.Reachable
|| (now - _lastDbHealth.AsOfUtc) > _staleWindow
|| (now - entry.AsOfUtc) > _staleWindow,
IsDriverPrimary: entry.IsDriverPrimary);
Self.Tell(new ServiceLevelChanged(ServiceLevelCalculator.Compute(inputs)));
}
/// <summary>The legacy role-only ServiceLevel switch (primary-leader → 240, primary → 200,
/// secondary → 100, _ → 0). Preserved as the back-compat / bootstrap seam.</summary>
private static byte LegacyRoleOnly(NodeRedundancyState entry) => entry.Role switch
{
RedundancyRole.Primary when entry.IsDriverPrimary => 240,
RedundancyRole.Primary => 200,
RedundancyRole.Secondary => 100,
_ => 0,
};
/// <summary>Reads this node's cluster <see cref="MemberStatus"/>, returning
/// <see cref="MemberStatus.Removed"/> if the cluster is unavailable (so the calculator treats it
/// as untrusted → 0 rather than throwing).</summary>
private MemberStatus SafeSelfStatus()
{
try
{
return _cluster.SelfMember.Status;
}
catch (Exception ex)
{
_log.Debug(ex, "OpcUaPublish: SelfMember status unavailable; treating as Removed (ServiceLevel→0)");
return MemberStatus.Removed;
}
}
}