7029 lines
424 KiB
C#
7029 lines
424 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Globalization;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Opc.Ua;
|
||
using Opc.Ua.Server;
|
||
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.Security;
|
||
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
|
||
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
|
||
// the SDK result stays unqualified as the dominant name in the override; the source DTO is HistorianRead.
|
||
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||
|
||
/// <summary>
|
||
/// Custom OPC UA <see cref="CustomNodeManager2"/> that owns the writable address space for
|
||
/// the OtOpcUa server. Variable nodes are created lazily on first <see cref="WriteValue"/>
|
||
/// under the manager's namespace; subsequent writes update the existing node's Value +
|
||
/// StatusCode + SourceTimestamp and notify subscribed clients via the standard
|
||
/// <c>ClearChangeMasks</c> path.
|
||
///
|
||
/// This is the F10b production wiring behind the v2 <see cref="IOpcUaAddressSpaceSink"/>
|
||
/// seam — once a <see cref="SdkAddressSpaceSink"/> is bound, OpcUaPublishActor's writes
|
||
/// materialise as real OPC UA Variable updates that clients can browse + subscribe to.
|
||
///
|
||
/// Node-id encoding uses the manager's default namespace + the caller-supplied string id
|
||
/// as the identifier portion (e.g. <c>"ns=2;s=eq-1/temp"</c>). Beyond lazily-created flat
|
||
/// variables, the manager also materialises the full UNS Area/Line/Equipment folder hierarchy
|
||
/// (<see cref="EnsureFolder"/>), typed equipment-tag variables with the correct built-in
|
||
/// <c>DataType</c> / array shape / access levels (<see cref="EnsureVariable"/>), historized
|
||
/// nodes (the <c>HistoryRead</c> access bit + HistoryRead overrides), and real Part 9
|
||
/// <see cref="AlarmConditionState"/> nodes (<see cref="MaterialiseAlarmCondition"/>). The
|
||
/// <c>AddressSpaceApplier</c> drives these passes from the composed deployment artifact;
|
||
/// the legacy <c>EquipmentNodeWalker</c> server-side integration was retired in favour of the
|
||
/// (composer → applier → sink → node-manager) chain.
|
||
/// </summary>
|
||
public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||
{
|
||
/// <summary>v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are
|
||
/// registered with the SDK so nodes can be minted in either. <see cref="V3NodeIds.RawNamespaceUri"/> is
|
||
/// registered FIRST (namespace index <see cref="NamespaceIndexes"/>[0]) and
|
||
/// <see cref="V3NodeIds.UnsNamespaceUri"/> SECOND ([1]); <see cref="NamespaceIndexForRealm"/> maps a realm
|
||
/// to its index. Which realm a node belongs to travels with each sink call as an
|
||
/// <see cref="AddressSpaceRealm"/> — the namespace is never parsed back out of the id string.</summary>
|
||
private static readonly string[] RegisteredNamespaceUris = { V3NodeIds.RawNamespaceUri, V3NodeIds.UnsNamespaceUri };
|
||
|
||
/// <summary>Transitional alias kept so the pre-v3 single-namespace integration tests
|
||
/// (<c>SubscriptionSurvivalTests</c>) still resolve a namespace index. It points at
|
||
/// <see cref="V3NodeIds.UnsNamespaceUri"/> — the same realm as the transitional default of every sink
|
||
/// method — so those tests keep resolving the namespace the applier's default-realm nodes live in. The two
|
||
/// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly.</summary>
|
||
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
|
||
|
||
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _variables = new();
|
||
private readonly ConcurrentDictionary<RealmKey, FolderState> _folders = new();
|
||
private readonly ConcurrentDictionary<RealmKey, AlarmConditionState> _alarmConditions = new();
|
||
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> node ids materialised as NATIVE
|
||
/// (driver-fed, e.g. Galaxy equipment-tag alarms) rather than scripted. A later task routes a native
|
||
/// condition's inbound Acknowledge to the driver instead of the scripted engine, so the node manager
|
||
/// must know which conditions are native. Maintained in lock-step with <see cref="_alarmConditions"/>:
|
||
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
|
||
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
|
||
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
|
||
/// <summary>Gitea #641: every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
|
||
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
|
||
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
|
||
/// <see cref="CompleteAddressSpaceApply"/> runs is discarded. Guarded by <c>Lock</c>.</summary>
|
||
private readonly Dictionary<RealmKey, NativeConditionCarryOver> _rebuildCarryOver = new();
|
||
/// <summary>Gitea #641: conditions whose non-normal state a re-materialise restored, owed ONE condition
|
||
/// event at <see cref="CompleteAddressSpaceApply"/> (deferred until then so every notifier folder the
|
||
/// apply wires receives it). Each entry pins the instance restored, so a condition replaced in between is
|
||
/// not reported. Guarded by <c>Lock</c>.</summary>
|
||
private readonly List<(RealmKey Key, AlarmConditionState Alarm)> _restoredConditionReports = new();
|
||
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
|
||
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
|
||
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
|
||
/// <see cref="RebuildAddressSpace"/>.</summary>
|
||
private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new();
|
||
/// <summary>Gitea #610 review (R6 MINOR-1): the variables whose owning driver cannot write an array value to a
|
||
/// SCALAR tag (Modbus / S7 — <c>DriverArrayWriteSupport.RefusesArrayValueOnScalarTag</c>), so an array write to
|
||
/// one while it is scalar-shaped is refused inline. Every other scalar node keeps the pre-#610 behaviour (the
|
||
/// write is dispatched and the driver decides). Set by <see cref="EnsureVariable"/>; cleared alongside
|
||
/// <see cref="_variables"/>.</summary>
|
||
private readonly ConcurrentDictionary<RealmKey, byte> _arrayOnScalarRefused = new();
|
||
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
|
||
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
|
||
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
|
||
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
|
||
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
|
||
/// <summary>v3 Batch 4 (WP4 multi-notifier native alarms): tracks, per materialised native-alarm condition,
|
||
/// the equipment folders wired as EXTRA event-notifier roots for it (via <see cref="WireAlarmNotifiers"/>).
|
||
/// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
|
||
/// <c>ReportEvent</c> fans one event to every referencing equipment's UNS folder through the SDK notifier
|
||
/// list (never re-reported per root). Keyed by the condition's full (namespace-qualified) NodeId string
|
||
/// (matches <see cref="_alarmConditions"/>' keys). On rebuild / condition-removal / equipment-subtree-removal
|
||
/// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
|
||
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
|
||
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
|
||
private readonly Dictionary<RealmKey, AlarmNotifierWiring> _alarmNotifierWiring = new();
|
||
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
|
||
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>. The equipment-folder NodeId
|
||
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
|
||
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
|
||
/// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
|
||
/// and the <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
|
||
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
|
||
/// <see cref="RebuildAddressSpace"/>.</summary>
|
||
private readonly ConcurrentDictionary<RealmKey, string> _eventNotifierSources = new();
|
||
private FolderState? _root;
|
||
private readonly ILogger<OtOpcUaNodeManager> _logger;
|
||
|
||
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
|
||
/// <param name="server">The OPC UA server instance.</param>
|
||
/// <param name="configuration">The application configuration.</param>
|
||
/// <param name="logger">Logger for the manager's swallowed-fault and skipped-wiring diagnostics;
|
||
/// <c>null</c> (tests) falls back to <see cref="NullLogger{T}.Instance"/>.</param>
|
||
public OtOpcUaNodeManager(
|
||
IServerInternal server, ApplicationConfiguration configuration, ILogger<OtOpcUaNodeManager>? logger = null)
|
||
: base(server, configuration, RegisteredNamespaceUris)
|
||
{
|
||
_logger = logger ?? NullLogger<OtOpcUaNodeManager>.Instance;
|
||
// SystemContext is initialised by the base ctor; both namespace URIs are now registered and their
|
||
// indexes captured (in the order passed) in NamespaceIndexes.
|
||
}
|
||
|
||
/// <summary>Resolve a realm to the SDK namespace index its nodes are minted under. Raw is registered
|
||
/// first (<see cref="NamespaceIndexes"/>[0]), UNS second ([1]) — see <see cref="RegisteredNamespaceUris"/>.</summary>
|
||
/// <param name="realm">The address-space realm.</param>
|
||
/// <returns>The namespace index for the realm.</returns>
|
||
internal ushort NamespaceIndexForRealm(AddressSpaceRealm realm) => realm switch
|
||
{
|
||
AddressSpaceRealm.Raw => NamespaceIndexes[0],
|
||
AddressSpaceRealm.Uns => NamespaceIndexes[1],
|
||
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
|
||
};
|
||
|
||
/// <summary>Recover the realm of an already-resolved SDK <see cref="NodeId"/> from its namespace index —
|
||
/// used at the SDK-facing seams (inbound write, HistoryRead) where the request carries a full NodeId.
|
||
/// Any index that is not the Raw namespace is treated as UNS (the two registered indexes are the only
|
||
/// ones this manager mints into).</summary>
|
||
/// <param name="nodeId">The resolved SDK node id.</param>
|
||
/// <returns>The realm the node belongs to.</returns>
|
||
private AddressSpaceRealm RealmOf(NodeId nodeId) =>
|
||
nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
|
||
|
||
/// <summary>Zero-allocation realm-qualified map key. Replaces the formatted "ns=N;s=id" string key —
|
||
/// the (realm, id) pair is the same identity without the per-write NodeId + string allocations
|
||
/// (the id string is already in hand at every call site).</summary>
|
||
/// <param name="Realm">The realm the id lives in.</param>
|
||
/// <param name="Id">The bare <c>s=</c> identifier.</param>
|
||
private readonly record struct RealmKey(AddressSpaceRealm Realm, string Id);
|
||
|
||
/// <summary>Build the internal map key for a realm-qualified string id. The key pairs the realm with the
|
||
/// bare <c>s=</c> identifier so a Raw node and a UNS node that share the same identifier are distinct
|
||
/// keys — a bare id is not globally unique across the two namespaces. Matches
|
||
/// <see cref="MapKey(NodeId)"/> for the same node.</summary>
|
||
/// <param name="realm">The realm the id lives in.</param>
|
||
/// <param name="id">The bare <c>s=</c> identifier.</param>
|
||
/// <returns>The realm-qualified map key.</returns>
|
||
private static RealmKey MapKey(AddressSpaceRealm realm, string id) => new(realm, id);
|
||
|
||
/// <summary>Build the internal map key for an already-resolved SDK <see cref="NodeId"/> (the SDK-facing
|
||
/// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node. The SDK-facing
|
||
/// seams only ever pass nodes this manager minted (string <c>s=</c> identifiers); a non-string
|
||
/// identifier falls back to its string form rather than throwing — and says so loudly, because such a
|
||
/// key cannot match anything the manager stored, so the lookup would silently miss.</summary>
|
||
/// <param name="nodeId">The resolved SDK node id.</param>
|
||
/// <returns>The realm-qualified map key.</returns>
|
||
private RealmKey MapKey(NodeId nodeId)
|
||
{
|
||
if (nodeId.Identifier is string s) return new RealmKey(RealmOf(nodeId), s);
|
||
|
||
// Unreachable by construction — every call site passes a node this manager minted with a string
|
||
// `s=` identifier. If it ever fires, the derived key resolves to nothing and the caller sees an
|
||
// unexplained miss, so name the seam rather than collapsing quietly.
|
||
_logger.LogWarning(
|
||
"OtOpcUaNodeManager.MapKey: non-string NodeId identifier reached the map-key seam (node {NodeId}, identifier type {IdentifierType}) — the derived key will not resolve",
|
||
nodeId, nodeId.Identifier?.GetType().Name ?? "null");
|
||
return new RealmKey(RealmOf(nodeId), nodeId.Identifier?.ToString() ?? string.Empty);
|
||
}
|
||
|
||
/// <summary>Gets the count of variable nodes currently managed.</summary>
|
||
public int VariableCount => _variables.Count;
|
||
/// <summary>Gets the count of folder nodes currently managed.</summary>
|
||
public int FolderCount => _folders.Count;
|
||
/// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
|
||
public int AlarmConditionCount => _alarmConditions.Count;
|
||
|
||
/// <summary>
|
||
/// Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a
|
||
/// materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's
|
||
/// handler (wired in <see cref="MaterialiseAlarmCondition"/>) gates on the caller's
|
||
/// <c>AlarmAck</c> role and, when allowed, builds an <see cref="AlarmCommand"/> and invokes this
|
||
/// delegate. The host sets it at boot to a non-blocking <c>mediator.Tell</c> onto the
|
||
/// <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it.
|
||
/// <para>
|
||
/// This is the ONLY reverse coupling out of the node manager — by design it is a plain
|
||
/// <see cref="Action{AlarmCommand}"/> (no Akka / <c>IActorRef</c> / DI handle). The handler
|
||
/// delegates run under the manager's <c>Lock</c>; the invoked action MUST be non-blocking
|
||
/// (a fire-and-forget <c>Tell</c>) so there is no deadlock. Null (the default) makes every
|
||
/// handler a safe no-op — it still gates + returns, just routes nowhere.
|
||
/// </para>
|
||
/// </summary>
|
||
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
|
||
|
||
/// <summary>
|
||
/// H6c — reverse-path sink for an inbound OPC UA Acknowledge on a NATIVE (driver-fed, e.g. Galaxy)
|
||
/// Part 9 condition. The scripted-alarm engine does not own native conditions, so when a client
|
||
/// Acknowledges one, the condition's <c>OnAcknowledge</c> handler (wired in
|
||
/// <see cref="MaterialiseAlarmCondition"/>) branches on <see cref="IsNativeAlarmNode"/> and — after
|
||
/// the same <c>AlarmAck</c> role gate as <see cref="AlarmCommandRouter"/> — invokes THIS delegate
|
||
/// with a <see cref="NativeAlarmAck"/> instead of routing an <see cref="AlarmCommand"/> to the
|
||
/// scripted engine. The host sets it at boot to a non-blocking dispatch toward the backing driver
|
||
/// (a later task wires the driver linkage).
|
||
/// <para>
|
||
/// Like <see cref="AlarmCommandRouter"/>, the handler delegate runs under the manager's
|
||
/// <c>Lock</c>, so the invoked action MUST be non-blocking (fire-and-forget). Null (the default)
|
||
/// makes the native-ack handler a safe no-op — it still gates + returns, just routes nowhere.
|
||
/// Only the Acknowledge of a native condition uses this seam; Confirm/AddComment/Shelve on a
|
||
/// native condition stay on the scripted <see cref="AlarmCommandRouter"/> path (H6c scope is the
|
||
/// Acknowledge → driver path only).
|
||
/// </para>
|
||
/// </summary>
|
||
public Action<NativeAlarmAck>? NativeAlarmAckRouter { get; set; }
|
||
|
||
/// <summary>
|
||
/// Gitea #597 — whether this node may accept an OPC UA Acknowledge of a NATIVE (driver-fed) condition
|
||
/// right now: the redundancy data-plane verdict (<c>PrimaryGatePolicy.ShouldServiceAsPrimary</c>, as
|
||
/// published to <c>IDriverPrimaryRoleSource</c>) — the same verdict <c>DriverHostActor</c> gates the
|
||
/// upstream ack route and device writes on.
|
||
/// <para>
|
||
/// <c>false</c> ⇒ the Acknowledge is REFUSED with <c>BadInvalidState</c> before anything is routed or
|
||
/// applied. Without this the Secondary applied and reported the ack locally while
|
||
/// <c>DriverHostActor</c> dropped the route upstream, so the driver never acknowledged and its next
|
||
/// transition reverted the node — the client saw acked → unacked. Scripted conditions are not gated:
|
||
/// their engine runs on both nodes, so an ack against the Secondary is real.
|
||
/// </para>
|
||
/// <para>
|
||
/// Read under <c>Lock</c> on the SDK's Call thread, so it MUST be a cheap, non-blocking read (the
|
||
/// host passes a volatile-backed property). Null (the default) allows — the single-node posture, the
|
||
/// same default <c>UnmanagedDriverPrimaryRoleSource</c> answers with.
|
||
/// </para>
|
||
/// </summary>
|
||
public Func<bool>? NativeAlarmAckPrimaryGate { get; set; }
|
||
|
||
/// <summary>
|
||
/// Gitea #674 — whether this node may serve an OPC UA HistoryUpdate right now: the redundancy data-plane
|
||
/// verdict (<c>PrimaryGatePolicy.ShouldServiceAsPrimary</c>, as published to <c>IDriverPrimaryRoleSource</c>) —
|
||
/// the same source as <see cref="NativeAlarmAckPrimaryGate"/>.
|
||
/// <para>
|
||
/// <c>false</c> ⇒ every node of the request that passed authorization is refused <c>BadInvalidState</c>
|
||
/// and nothing reaches the updater. Rewriting history is a single-writer operation: the SQL backends'
|
||
/// prior-value capture (and QuestDB's per-process per-tag lock) assume one node serialises a tag's updates,
|
||
/// so both pair nodes accepting updates concurrently could record a wrong prior value in the modification
|
||
/// trail. HistoryRead is NOT gated — reads are served from any node.
|
||
/// </para>
|
||
/// <para>
|
||
/// Evaluated AFTER authorization (a caller who may not update learns nothing about the node's role). It must
|
||
/// be a cheap, non-blocking read (the host passes a volatile-backed property). Null (the default) allows — the
|
||
/// single-node posture, the same default <c>UnmanagedDriverPrimaryRoleSource</c> answers with.
|
||
/// </para>
|
||
/// </summary>
|
||
public Func<bool>? HistoryUpdatePrimaryGate { get; set; }
|
||
|
||
/// <summary>
|
||
/// Gitea #674 — how far beyond <see cref="Clock"/>'s now a HistoryUpdate (Insert / Replace / Update) item's
|
||
/// <c>SourceTimestamp</c> may lie. An item past <c>now + skew</c> is refused <c>BadOutOfRange</c> before it
|
||
/// reaches any backend: a far-future row (a 2062 typo) is never a legitimate history edit, and on a backend whose
|
||
/// retention is judged against the newest timestamp it could age out every older partition. Mirrors
|
||
/// <c>ServerHistorianOptions.HistoryUpdateMaxFutureSkew</c>; the Host sets it at <c>StartAsync</c>. Non-positive
|
||
/// disables the bound. Default 1 day. Live recorder writes are not bounded by this.
|
||
/// </summary>
|
||
public TimeSpan HistoryUpdateMaxFutureSkew { get; set; } = TimeSpan.FromDays(1);
|
||
|
||
/// <summary>
|
||
/// The clock the HistoryUpdate future-skew bound (<see cref="HistoryUpdateMaxFutureSkew"/>) reads. The Host
|
||
/// assigns its <see cref="TimeProvider"/> at <c>StartAsync</c>; tests inject a fixed clock. Defaults to
|
||
/// <see cref="TimeProvider.System"/>; assigning null restores it.
|
||
/// </summary>
|
||
public TimeProvider Clock
|
||
{
|
||
get => _clock;
|
||
set => _clock = value ?? TimeProvider.System;
|
||
}
|
||
|
||
private volatile TimeProvider _clock = TimeProvider.System;
|
||
|
||
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
|
||
|
||
/// <summary>
|
||
/// Reverse-path gateway for inbound OPC UA operator writes to a writable equipment-tag variable node.
|
||
/// When a client writes such a node, the node's <see cref="BaseDataVariableState.OnWriteValue"/>
|
||
/// handler (<see cref="OnEquipmentTagWrite"/>, attached by <see cref="EnsureVariable"/> when the
|
||
/// variable is writable) first gates on the caller's <see cref="OpcUaDataPlaneRoles.WriteOperate"/>
|
||
/// role and, when allowed, calls <see cref="IOpcUaNodeWriteGateway.WriteAsync"/> with the node's
|
||
/// string id + the written value to route the write to the backing driver.
|
||
/// <para>
|
||
/// This is the write-side twin of <see cref="AlarmCommandRouter"/>; the gateway abstraction keeps
|
||
/// this assembly Akka-free (the host wires an <c>ActorNodeWriteGateway</c> that Asks the local
|
||
/// <c>DriverHostActor</c>). The handler delegates run under the node-manager <c>Lock</c> (the OPC
|
||
/// UA SDK's <c>CustomNodeManager2.Write</c> holds <c>Lock</c> while invoking <c>OnWriteValue</c>),
|
||
/// so the dispatch is FIRE-AND-FORGET — the handler kicks off <c>WriteAsync</c> and returns
|
||
/// <c>Good</c> immediately so the SDK applies the client value optimistically; it MUST NOT block
|
||
/// on the device round-trip. When the asynchronous <see cref="NodeWriteOutcome"/> comes back
|
||
/// FAILED, an off-Lock continuation self-corrects: it re-takes <c>Lock</c> and reverts the node to
|
||
/// its real pre-write value — but only while the node still holds the optimistic value, so a fresh
|
||
/// driver poll that has already moved the node on is not clobbered (see
|
||
/// <see cref="ShouldRevert"/> / <see cref="RevertOptimisticWriteIfNeeded"/>).
|
||
/// </para>
|
||
/// <para>
|
||
/// Set by the host at <c>StartAsync</c>; the <see cref="NullOpcUaNodeWriteGateway"/> default
|
||
/// (assigning <c>null</c> restores it) makes every write resolve to a "writes unavailable"
|
||
/// failure. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make the
|
||
/// startup-write / SDK-thread-read explicit: the host assigns it once at boot on the start thread
|
||
/// and the SDK reads it on Write request threads.
|
||
/// </para>
|
||
/// </summary>
|
||
public IOpcUaNodeWriteGateway NodeWriteGateway
|
||
{
|
||
get => _nodeWriteGateway;
|
||
set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance;
|
||
}
|
||
|
||
private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;
|
||
|
||
/// <summary>
|
||
/// Server-side read backend for the OPC UA HistoryRead service over historized variable nodes.
|
||
/// When a client issues a HistoryRead (Raw / Processed / AtTime) against a node materialised
|
||
/// <c>Historizing</c> (a tag with <see cref="TryGetHistorizedTagname"/> registered), the
|
||
/// HistoryRead override resolves the node's NodeId to its historian tagname and dispatches to
|
||
/// this source — so a single registered historian (e.g. Wonderware) serves many drivers' nodes,
|
||
/// independent of any driver's lifecycle.
|
||
/// <para>
|
||
/// Set by the Host at <c>StartAsync</c>. The <see cref="NullHistorianDataSource"/>
|
||
/// default (assigning <c>null</c> restores it) means "no historian wired" → every read
|
||
/// returns empty, so a historized node's HistoryRead surfaces <c>GoodNoData</c> rather than
|
||
/// faulting. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make
|
||
/// the startup-write / SDK-read-thread handoff explicit: the Host assigns it once at boot on
|
||
/// the start thread and the SDK reads it on HistoryRead request threads. Unlike
|
||
/// <see cref="NodeWriteGateway"/>, the HistoryRead override does NOT run under the
|
||
/// node-manager <c>Lock</c>, so the override may block-bridge to this (async) source.
|
||
/// </para>
|
||
/// <para>
|
||
/// Gitea #682: assigning a source also re-syncs <c>HistoryServerCapabilities/AggregateFunctions</c> to its
|
||
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (see <see cref="SyncAdvertisedAggregates"/>).
|
||
/// </para>
|
||
/// </summary>
|
||
public IHistorianDataSource HistorianDataSource
|
||
{
|
||
get => _historianDataSource;
|
||
set
|
||
{
|
||
_historianDataSource = value ?? NullHistorianDataSource.Instance;
|
||
SyncAdvertisedAggregates();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #682 — trim <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) to the current source's
|
||
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (null ⇒ all, <see cref="AdvertisedAggregateFunctions.ServedBy"/>).
|
||
/// <c>ServerCapabilities/AggregateFunctions</c> (i=2997) keeps the SDK's full set — the server still computes every
|
||
/// one of them for live <c>AggregateFilter</c> subscriptions. Called on every <see cref="HistorianDataSource"/>
|
||
/// assignment and by <see cref="OtOpcUaSdkServer"/> right after the SDK registers its aggregate factories, so the
|
||
/// Null default is applied from boot. Only the folder references change — the AggregateManager's calculator
|
||
/// factories (live <c>AggregateFilter</c> subscriptions) are untouched. Never throws: a failure leaves the previous
|
||
/// advertisement in place and is logged, because HistoryRead's own up-front check still enforces the set.
|
||
/// </summary>
|
||
internal void SyncAdvertisedAggregates()
|
||
{
|
||
try
|
||
{
|
||
var source = _historianDataSource;
|
||
var advertised = AdvertisedAggregateFunctions.Apply(Server, AdvertisedAggregateFunctions.ServedBy(source));
|
||
if (advertised is not null)
|
||
{
|
||
_logger.LogInformation(
|
||
"OtOpcUaNodeManager: advertising {Count} history aggregate function(s) for historian {Source}: {Aggregates}",
|
||
advertised.Count, source.GetType().Name,
|
||
string.Join(", ", advertised.Select(id => AggregateFunctionMap.Map(id)?.ToString() ?? id.ToString())));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "OtOpcUaNodeManager: failed to sync the advertised aggregate functions");
|
||
}
|
||
}
|
||
|
||
private volatile IHistorianDataUpdater? _historianDataUpdater;
|
||
|
||
/// <summary>
|
||
/// Optional server-side backend for the OPC UA HistoryUpdate service (insert / replace / update /
|
||
/// 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"/>.
|
||
/// </para>
|
||
/// </summary>
|
||
public IHistorianDataUpdater? HistorianDataUpdater
|
||
{
|
||
get => _historianDataUpdater;
|
||
set => _historianDataUpdater = value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #520 stage 4 — the node's current data-plane authorization inputs: which cluster it serves,
|
||
/// the generation those were resolved at, and the ACL grants themselves.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// This closes the fourth #520 blocker: <c>NodeScope.ClusterId</c> is <c>required</c> and
|
||
/// <c>TriePermissionEvaluator</c> fails closed when it disagrees with the session's, but the
|
||
/// node manager had no idea what cluster it was serving — so no <c>NodeScope</c> it built
|
||
/// could ever have evaluated. It is fed here rather than at construction because the SDK
|
||
/// constructs this manager during <c>StartAsync</c>, before any artifact has been applied,
|
||
/// and the value changes on every deploy.
|
||
/// </para>
|
||
/// <para>
|
||
/// Stage 4 only supplies it. The enforcement seams that consume it — the write gate, the alarm
|
||
/// gates, HistoryRead, and (stage 6) read/browse/subscribe — land in stages 5 and 6, so
|
||
/// nothing reads this yet and authorization behaviour is unchanged.
|
||
/// </para>
|
||
/// <para>
|
||
/// Backed by a <c>volatile</c> field for the same reason as <see cref="HistorianDataSource"/>:
|
||
/// written on the deploy path, read on SDK request threads. Assigning <c>null</c> restores the
|
||
/// empty source, which authenticates and authorizes nothing.
|
||
/// </para>
|
||
/// </remarks>
|
||
public IDataPlaneAuthoritySource DataPlaneAuthority
|
||
{
|
||
get => Volatile.Read(ref _dataPlaneAuthority);
|
||
set
|
||
{
|
||
var next = value ?? EmptyDataPlaneAuthoritySource.Instance;
|
||
var previous = Interlocked.Exchange(ref _dataPlaneAuthority, next);
|
||
if (ReferenceEquals(previous, next)) return;
|
||
|
||
// ⚠️ The DETACH is load-bearing, for the same reason it is on IRediscoverable: this manager is
|
||
// rebuilt across the server's lifetime while the authority source is a process singleton that
|
||
// outlives it, so a missing -= leaks a dead node manager per rebuild AND has every stale one
|
||
// walking its own dead item map on each deploy.
|
||
previous.Changed -= OnAuthorityChanged;
|
||
next.Changed += OnAuthorityChanged;
|
||
}
|
||
}
|
||
|
||
// Not `volatile`: the setter swaps it with Interlocked.Exchange (which cannot take a volatile ref), and
|
||
// the getter reads it with Volatile.Read, so both accesses are ordered explicitly.
|
||
private IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
|
||
|
||
/// <summary>
|
||
/// Re-authorizes live subscriptions when a deployment installs a new authorization generation.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>There are TWO triggers</b> (arch-review 03/S19): this one, the deployment axis; and the
|
||
/// host's low-frequency <c>AclRevalidationTimer</c>, the clock axis — a key's
|
||
/// <c>ExpiresUtc</c> passes with the clock, not with a deployment, so a deploy-only trigger
|
||
/// left an expired key streaming to a live subscription indefinitely on a fleet that had been
|
||
/// deployed once and left running.
|
||
/// </para>
|
||
/// <para>
|
||
/// ⚠️ <b>The second trigger is safe only because the memo is pass-local.</b>
|
||
/// <see cref="IsSubscriptionStillPermitted"/> memoizes key revocation and expiry for the span
|
||
/// of one pass, which is sound because a pass is a momentary event whose verdicts cannot go
|
||
/// stale within it — its dictionary is constructed per call. Hoisting it across ticks, to save
|
||
/// the walk, would serve an expiry that landed between two ticks the earlier tick's allow, and
|
||
/// re-open exactly the gap the timer closes.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="snapshot">The newly published snapshot; unused — the current state is re-read per item.</param>
|
||
private void OnAuthorityChanged(DataPlaneAuthoritySnapshot snapshot) => RevalidateMonitoredItems();
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// Detaches from the authority source. The source is a process singleton that outlives this manager,
|
||
/// so without this a disposed manager stays subscribed and re-walks its dead item map on every
|
||
/// subsequent deploy — the same leak shape the setter's swap guards against.
|
||
/// </remarks>
|
||
protected override void Dispose(bool disposing)
|
||
{
|
||
if (disposing)
|
||
{
|
||
Volatile.Read(ref _dataPlaneAuthority).Changed -= OnAuthorityChanged;
|
||
_liveItems.Clear();
|
||
_eventItems.Clear();
|
||
_allEventsItems.Clear();
|
||
lock (Lock) _allEventsMonitoredConditions.Clear();
|
||
}
|
||
|
||
base.Dispose(disposing);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The node-scoped ACL gate (#520 stage 5), or <c>null</c> when none is wired. Consulted by the
|
||
/// write, alarm-command and HistoryRead seams <b>only while
|
||
/// <see cref="Security.DataPlaneAuthorizer.Enabled"/> is true</b>; otherwise each keeps its
|
||
/// server-wide role check and behaviour is identical to before this stage — with one exception: the
|
||
/// two role gates consult <see cref="Security.DataPlaneAuthorizer.AuthorizeKeyUsable"/> in either
|
||
/// mode, so an expired or revoked key loses its activation-time roles (Gitea #563).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Null means "not wired" and is treated as not-enabled, not as deny-all. That looks like the wrong
|
||
/// direction for a security control, but the alternative denies every write on any node that has
|
||
/// not been given an authorizer — including every existing test host and the admin-only nodes — to
|
||
/// protect a feature that is off by default. The real fail-closed behaviour lives one level in:
|
||
/// once an authorizer IS wired and enabled, every unresolvable case denies.
|
||
/// </remarks>
|
||
public DataPlaneAuthorizer? Authorizer
|
||
{
|
||
get => _authorizer;
|
||
set => _authorizer = value;
|
||
}
|
||
|
||
private volatile DataPlaneAuthorizer? _authorizer;
|
||
|
||
/// <summary>
|
||
/// True when a wired authorizer is switched on — i.e. when ACL grants, rather than the server-wide
|
||
/// role set, decide the gated operations.
|
||
/// </summary>
|
||
private bool AclEnforced => _authorizer is { Enabled: true };
|
||
|
||
/// <summary>
|
||
/// The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> uses to page WITHIN an
|
||
/// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
|
||
/// cap. When a resume read stalls on such a cluster (every row of its <c>N + skip + 1</c> read, past the
|
||
/// trimmed ties, still sits on the boundary timestamp — #692: only a genuine cluster larger than the page), the
|
||
/// paging over-fetches up to <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a
|
||
/// <c>start == end</c> read) and slices through them via <see cref="HistoryPaging.SliceTieCluster"/>.
|
||
/// A cluster strictly larger than this still fails that node <c>BadTooManyOperations</c> (the absurd-burst
|
||
/// backstop; <c>BadHistoryOperationUnsupported</c> before #686). Also the tie-cluster ceiling (not the segment size, #689) of a reverse read
|
||
/// walked back from forward reads (<see cref="ReverseRawReader"/>). Mirrors the configured
|
||
/// <c>ServerHistorianOptions.MaxTieClusterOverfetch</c>; the Host sets it at <c>StartAsync</c>. The
|
||
/// default (65536) survives when the historian section is absent.
|
||
/// </summary>
|
||
public int MaxTieClusterOverfetch { get; set; } = 65536;
|
||
|
||
/// <summary>
|
||
/// Gitea #691: whether HistoryRead-Raw honours <c>ReturnBounds</c> (Part 11 bounding values, <see cref="HistoryBounds"/>)
|
||
/// — <c>ServerHistorian:HonorReturnBounds</c>, default <c>true</c>. <c>false</c> ignores the flag, as before #691,
|
||
/// including the modified-read <c>Bad_InvalidArgument</c>.
|
||
/// </summary>
|
||
public bool HonorReturnBounds { get; set; } = true;
|
||
|
||
/// <summary>
|
||
/// Gitea #694: <c>ServerHistorian:EventReadHorizon</c> — HistoryReadEvents never reads events older than
|
||
/// <c>now − EventReadHorizon</c> (<see cref="EventHorizon"/>). <see cref="TimeSpan.Zero"/> (the default) = no horizon:
|
||
/// every read is exact down to its own window (a walked Gateway reverse read proves "nothing older" down to 1970).
|
||
/// </summary>
|
||
public TimeSpan EventReadHorizon { get; set; } = TimeSpan.Zero;
|
||
|
||
/// <summary>
|
||
/// Maximum historized nodes served concurrently within one HistoryRead batch (arch-review 03/S3).
|
||
/// Mirrors <c>ServerHistorianOptions.HistoryReadBatchParallelism</c>; the Host sets it at
|
||
/// <c>StartAsync</c>. <c>≤ 1</c> falls back to sequential serving. The default (4) survives when the
|
||
/// ServerHistorian section is absent.
|
||
/// </summary>
|
||
public int HistoryReadBatchParallelism { get; set; } = 4;
|
||
|
||
/// <summary>
|
||
/// Process-wide cap on concurrently-served HistoryRead batches (arch-review 03/S3). A batch that
|
||
/// cannot acquire a slot within its (bounded) wait budget fast-fails every handle with
|
||
/// <c>BadTooManyOperations</c>. Mirrors <c>ServerHistorianOptions.MaxConcurrentHistoryReadBatches</c>;
|
||
/// the Host sets it at <c>StartAsync</c>. The default (16) survives when the section is absent.
|
||
/// </summary>
|
||
public int MaxConcurrentHistoryReadBatches { get; set; } = 16;
|
||
|
||
/// <summary>
|
||
/// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in
|
||
/// flight at the deadline surfaces <c>BadTimeout</c>. Non-positive = unbounded. Mirrors
|
||
/// <c>ServerHistorianOptions.HistoryReadDeadline</c>; the Host sets it at <c>StartAsync</c>. The
|
||
/// default (60 s) survives when the section is absent.
|
||
/// </summary>
|
||
public TimeSpan HistoryReadDeadline { get; set; } = TimeSpan.FromSeconds(60);
|
||
|
||
/// <summary>Lazily-created process-wide HistoryRead-batch limiter (see <see cref="MaxConcurrentHistoryReadBatches"/>).</summary>
|
||
private volatile SemaphoreSlim? _historyReadBatchLimiter;
|
||
private readonly object _historyReadBatchLimiterLock = new();
|
||
|
||
private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore();
|
||
|
||
/// <summary>
|
||
/// The store that holds the server-side resume state behind an opaque HistoryRead continuation
|
||
/// point for the count-capped variable-history arms (Raw / Processed). The default
|
||
/// <see cref="SessionHistoryContinuationStore"/> binds points to the OPC UA session — so they are
|
||
/// capped (<c>ServerConfiguration.MaxHistoryContinuationPoints</c>, SDK default 100, oldest-evicted)
|
||
/// and disposed when the session closes. Exposed (internal) so the session-less in-process tests can
|
||
/// inject an <see cref="InMemoryHistoryContinuationStore"/> and exercise the full multi-page round
|
||
/// trip through the same dispatch path. Assigning <c>null</c> restores the session-backed default.
|
||
/// </summary>
|
||
internal IHistoryContinuationStore HistoryContinuationStore
|
||
{
|
||
get => _historyContinuationStore;
|
||
set => _historyContinuationStore = value ?? new SessionHistoryContinuationStore();
|
||
}
|
||
|
||
/// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
|
||
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
|
||
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
|
||
/// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
|
||
public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||
_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var condition) ? condition : null;
|
||
|
||
/// <summary>Phase C: look up the resolved historian tagname registered for a historized variable
|
||
/// node, or null when the node is not historized. The (later) HistoryRead override resolves an
|
||
/// inbound HistoryRead request's NodeId against this map. Exposed for tests + the override.</summary>
|
||
/// <param name="nodeId">The variable node identifier.</param>
|
||
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
|
||
/// <returns>True when the node is registered as historized; otherwise false.</returns>
|
||
public bool TryGetHistorizedTagname(string nodeId, out string? tagname, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||
{
|
||
if (_historizedTagnames.TryGetValue(MapKey(realm, nodeId), out var t)) { tagname = t; return true; }
|
||
tagname = null;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>NodeId-typed overload used by the HistoryRead override, which holds a request's full
|
||
/// (namespace-qualified) NodeId — resolves the historian tagname without recovering the realm first.
|
||
/// A historized UNS reference node and its backing raw node register the SAME tagname under distinct
|
||
/// NodeIds, so a read against either resolves the one series.</summary>
|
||
/// <param name="nodeId">The resolved SDK node id from the HistoryRead request.</param>
|
||
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
|
||
/// <returns>True when the node is registered as historized; otherwise false.</returns>
|
||
private bool TryGetHistorizedTagname(NodeId nodeId, out string? tagname)
|
||
{
|
||
if (_historizedTagnames.TryGetValue(MapKey(nodeId), out var t)) { tagname = t; return true; }
|
||
tagname = null;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>Look up a materialised variable node by its NodeId string, or null if not present.
|
||
/// Exposed for tests so they can assert the SDK node's Historizing / AccessLevel attributes.</summary>
|
||
/// <param name="nodeId">The variable node identifier.</param>
|
||
/// <returns>The cached <see cref="BaseDataVariableState"/>, or null when none is registered.</returns>
|
||
internal BaseDataVariableState? TryGetVariable(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||
_variables.TryGetValue(MapKey(realm, nodeId), out var variable) ? variable : null;
|
||
|
||
/// <summary>Look up a materialised folder node by its NodeId string, or null if not present.
|
||
/// Exposed for tests so they can resolve an equipment folder's NodeId (e.g. the event-notifier
|
||
/// node a HistoryReadEvents request targets).</summary>
|
||
/// <param name="nodeId">The folder node identifier.</param>
|
||
/// <returns>The cached <see cref="FolderState"/>, or null when none is registered.</returns>
|
||
internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||
_folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null;
|
||
|
||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
|
||
/// materialised alarm condition <paramref name="alarmNodeId"/> — for a native alarm this is the number of
|
||
/// referencing equipment folders wired via <see cref="WireAlarmNotifiers"/>. Zero when the condition is
|
||
/// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
|
||
/// across redeploys.</summary>
|
||
/// <param name="alarmNodeId">The alarm condition node identifier.</param>
|
||
/// <param name="realm">The realm the condition lives in (Raw for native alarms).</param>
|
||
/// <returns>The number of notifier entries on the condition.</returns>
|
||
internal int AlarmNotifierCount(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
|
||
{
|
||
lock (Lock)
|
||
{
|
||
if (!_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var alarm)) return 0;
|
||
var list = new List<NodeState.Notifier>();
|
||
alarm.GetNotifiers(SystemContext, list);
|
||
return list.Count;
|
||
}
|
||
}
|
||
|
||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
|
||
/// materialised folder <paramref name="folderNodeId"/> — for an equipment folder wired as an alarm notifier
|
||
/// this counts the inverse links back to its condition(s). Zero when the folder is absent.</summary>
|
||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||
/// <param name="realm">The realm the folder lives in (Uns for equipment folders).</param>
|
||
/// <returns>The number of notifier entries on the folder.</returns>
|
||
internal int FolderNotifierCount(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||
{
|
||
lock (Lock)
|
||
{
|
||
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return 0;
|
||
var list = new List<NodeState.Notifier>();
|
||
folder.GetNotifiers(SystemContext, list);
|
||
return list.Count;
|
||
}
|
||
}
|
||
|
||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): true when <paramref name="folderNodeId"/> is
|
||
/// registered as a root (Server-object) event notifier — i.e. it was promoted via
|
||
/// <see cref="EnsureFolderIsEventNotifier"/> and not yet torn down.</summary>
|
||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||
/// <param name="realm">The realm the folder lives in.</param>
|
||
/// <returns>True when the folder is a registered root notifier.</returns>
|
||
internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||
{
|
||
lock (Lock)
|
||
return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
|
||
&& _notifierFolders.ContainsKey(folder.NodeId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
||
/// variable node on first call; subsequent calls update Value + StatusCode +
|
||
/// SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
|
||
/// </summary>
|
||
/// <param name="nodeId">The node identifier of the variable.</param>
|
||
/// <param name="value">The new value to write.</param>
|
||
/// <param name="statusCode">The full OPC UA status code, sub-code included (#519 — passed to the SDK verbatim).</param>
|
||
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
|
||
/// <param name="realm">Which of the two v3 namespaces the <paramref name="nodeId"/> lives in; the
|
||
/// namespace index is resolved from this rather than parsed out of the id string.</param>
|
||
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(nodeId);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var ns = NamespaceIndexForRealm(realm);
|
||
var key = MapKey(realm, nodeId);
|
||
lock (Lock)
|
||
WriteValueLocked(nodeId, ns, key, value, statusCode, sourceTimestampUtc);
|
||
}
|
||
|
||
/// <summary>The most items <see cref="WriteValues"/> applies under a SINGLE <c>Lock</c> acquisition before
|
||
/// releasing and re-taking it.
|
||
/// <para>WHY a cap at all: the lock take is what batching amortises, and the amortisation curve is
|
||
/// brutally front-loaded — going from one take per value to one per 512 already removes >99.8% of the
|
||
/// takes, so an unbounded hold buys essentially nothing more. What it DOES buy is a stall: the manager-wide
|
||
/// <c>Lock</c> is the same lock every SDK service thread (Read / Browse / subscription publish /
|
||
/// ConditionRefresh) contends for, and the first poll cycle force-raises EVERY tag (the initial-data push),
|
||
/// so batch #1 is a whole device × its UNS fan-out — thousands of <c>CreateVariable</c> calls, each mutating
|
||
/// the SDK address space, under one acquisition. 512 is therefore "effectively unbounded for the win,
|
||
/// bounded for the stall": chunking hands the SDK a scheduling point between chunks.</para></summary>
|
||
private const int MaxWritesPerLockAcquisition = 512;
|
||
|
||
/// <summary>Batched <see cref="WriteValue"/>: one <c>Lock</c> acquisition per chunk of
|
||
/// <see cref="MaxWritesPerLockAcquisition"/> items instead of one per value — the publish path's per-value
|
||
/// lock take was the steady-state throughput ceiling. Per-item semantics are identical to
|
||
/// <see cref="WriteValue"/> (create-on-first-write, #519 verbatim status codes, ClearChangeMasks per node).
|
||
/// <para><b>Items are isolated.</b> A bad item (empty NodeId, or a throw out of the SDK) does not abandon
|
||
/// the rest of the batch: it is recorded and the loop continues, and the aggregate surfaces afterwards as
|
||
/// a <see cref="ValueWriteBatchException"/>. Dropping the tail would be silent and durable — the poll
|
||
/// engine updates its last-value cache BEFORE raising, so a dropped item is not re-raised until that tag's
|
||
/// value changes again, leaving the node stale indefinitely.</para></summary>
|
||
/// <param name="values">The value writes to apply, in order.</param>
|
||
/// <exception cref="ValueWriteBatchException">When one or more items failed. Every other item HAS been
|
||
/// applied; the exception carries the first failing NodeId plus the failed/succeeded split so the caller
|
||
/// can log and meter honestly.</exception>
|
||
public void WriteValues(IReadOnlyList<ValueWrite> values)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(values);
|
||
// Guard BEFORE the empty-batch early-return, so a pre-start call fails the same legible way every
|
||
// other mutator does regardless of what it happens to carry.
|
||
EnsureAddressSpaceCreated();
|
||
if (values.Count == 0) return;
|
||
|
||
// Pre-pass, OUTSIDE the lock: validate each item and resolve its namespace index + map key into a
|
||
// scratch array. Both jobs belong here — validating outside keeps a malformed item from ever reaching
|
||
// the SDK, and resolving outside moves the MapKey string allocation (a NodeId + ToString per item)
|
||
// off the critical section entirely.
|
||
var resolved = new ResolvedWrite[values.Count];
|
||
string? firstFailedNodeId = null;
|
||
Exception? firstFailure = null;
|
||
var failed = 0;
|
||
|
||
for (var i = 0; i < values.Count; i++)
|
||
{
|
||
var nodeId = values[i].NodeId;
|
||
if (string.IsNullOrEmpty(nodeId))
|
||
{
|
||
failed++;
|
||
firstFailedNodeId ??= nodeId ?? string.Empty;
|
||
firstFailure ??= new ArgumentException(
|
||
$"WriteValues item {i} has a null or empty NodeId.", nameof(values));
|
||
continue;
|
||
}
|
||
|
||
resolved[i] = new ResolvedWrite(
|
||
nodeId, NamespaceIndexForRealm(values[i].Realm), MapKey(values[i].Realm, nodeId), IsValid: true);
|
||
}
|
||
|
||
var succeeded = 0;
|
||
for (var start = 0; start < values.Count; start += MaxWritesPerLockAcquisition)
|
||
{
|
||
var end = Math.Min(start + MaxWritesPerLockAcquisition, values.Count);
|
||
lock (Lock)
|
||
{
|
||
for (var i = start; i < end; i++)
|
||
{
|
||
if (!resolved[i].IsValid) continue;
|
||
var v = values[i];
|
||
try
|
||
{
|
||
WriteValueLocked(resolved[i].NodeId, resolved[i].NamespaceIndex, resolved[i].Key,
|
||
v.Value, v.StatusCode, v.SourceTimestampUtc);
|
||
succeeded++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failed++;
|
||
firstFailedNodeId ??= v.NodeId;
|
||
firstFailure ??= ex;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (failed > 0)
|
||
{
|
||
throw new ValueWriteBatchException(
|
||
firstFailedNodeId ?? string.Empty, failed, succeeded, firstFailure!);
|
||
}
|
||
}
|
||
|
||
/// <summary>Apply a batch of <b>status-only</b> writes: each node keeps its CURRENT value and only its
|
||
/// StatusCode + Timestamp change (archreview 03/S15 — the comms-loss annotation for value variables).
|
||
/// Same shape as <see cref="WriteValues"/>: an outside-the-lock validate/resolve pre-pass, then
|
||
/// <see cref="MaxWritesPerLockAcquisition"/>-sized chunks under one <c>Lock</c> each, per-item isolation,
|
||
/// and a <see cref="ValueWriteBatchException"/> aggregate.
|
||
/// <para><b>It never creates a node.</b> A status carries nothing to create one from, and the caller's
|
||
/// per-driver ref index can legitimately name a node a surgical remove has just dropped — so an unknown
|
||
/// id is skipped, not materialised, and is not counted as a failure either (it is an expected race, not
|
||
/// a fault).</para></summary>
|
||
/// <param name="statuses">The status writes to apply, in order.</param>
|
||
/// <exception cref="ValueWriteBatchException">When one or more items failed validation or threw. Every
|
||
/// other item HAS been applied.</exception>
|
||
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(statuses);
|
||
EnsureAddressSpaceCreated();
|
||
if (statuses.Count == 0) return;
|
||
|
||
var resolved = new ResolvedWrite[statuses.Count];
|
||
string? firstFailedNodeId = null;
|
||
Exception? firstFailure = null;
|
||
var failed = 0;
|
||
|
||
for (var i = 0; i < statuses.Count; i++)
|
||
{
|
||
var nodeId = statuses[i].NodeId;
|
||
if (string.IsNullOrEmpty(nodeId))
|
||
{
|
||
failed++;
|
||
firstFailedNodeId ??= nodeId ?? string.Empty;
|
||
firstFailure ??= new ArgumentException(
|
||
$"WriteStatuses item {i} has a null or empty NodeId.", nameof(statuses));
|
||
continue;
|
||
}
|
||
|
||
resolved[i] = new ResolvedWrite(
|
||
nodeId, NamespaceIndexForRealm(statuses[i].Realm), MapKey(statuses[i].Realm, nodeId), IsValid: true);
|
||
}
|
||
|
||
var succeeded = 0;
|
||
var skipped = 0;
|
||
for (var start = 0; start < statuses.Count; start += MaxWritesPerLockAcquisition)
|
||
{
|
||
var end = Math.Min(start + MaxWritesPerLockAcquisition, statuses.Count);
|
||
lock (Lock)
|
||
{
|
||
for (var i = start; i < end; i++)
|
||
{
|
||
if (!resolved[i].IsValid) continue;
|
||
var s = statuses[i];
|
||
try
|
||
{
|
||
if (WriteStatusLocked(resolved[i].Key, s.StatusCode, s.SourceTimestampUtc)) succeeded++;
|
||
else skipped++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failed++;
|
||
firstFailedNodeId ??= s.NodeId;
|
||
firstFailure ??= ex;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (skipped > 0)
|
||
{
|
||
// Debug, not Warning: the caller's index racing a surgical remove is expected, and a driver
|
||
// whose nodes were all just removed would otherwise log once per ref.
|
||
_logger.LogDebug(
|
||
"OtOpcUaNodeManager: WriteStatuses skipped {Skipped} of {Total} item(s) — node not materialised.",
|
||
skipped,
|
||
statuses.Count);
|
||
}
|
||
|
||
if (failed > 0)
|
||
{
|
||
throw new ValueWriteBatchException(
|
||
firstFailedNodeId ?? string.Empty, failed, succeeded, firstFailure!);
|
||
}
|
||
}
|
||
|
||
/// <summary>The body of a single status-only write. <b>Caller MUST hold <c>Lock</c></b>.</summary>
|
||
/// <param name="key">The realm-qualified map key.</param>
|
||
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
|
||
/// <param name="sourceTimestampUtc">The timestamp of the annotation in UTC.</param>
|
||
/// <returns><see langword="true"/> when the node existed and was stamped; <see langword="false"/> when
|
||
/// no such node is materialised (skipped — deliberately not a create; see <see cref="WriteStatuses"/>).</returns>
|
||
private bool WriteStatusLocked(RealmKey key, uint statusCode, DateTime sourceTimestampUtc)
|
||
{
|
||
if (!_variables.TryGetValue(key, out var variable)) return false;
|
||
|
||
// Value deliberately untouched — that IS the feature: the node keeps its last known value under a
|
||
// Bad status (OPC UA Part 8's last-known-value convention).
|
||
variable.StatusCode = new StatusCode(statusCode);
|
||
variable.Timestamp = sourceTimestampUtc;
|
||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>One <see cref="WriteValues"/> item after the outside-the-lock pre-pass: its namespace index and
|
||
/// map key already resolved, or <paramref name="IsValid"/> false when the item failed validation.</summary>
|
||
/// <param name="NodeId">The item's bare <c>s=</c> identifier.</param>
|
||
/// <param name="NamespaceIndex">The namespace index resolved from the item's realm.</param>
|
||
/// <param name="Key">The realm-qualified map key.</param>
|
||
/// <param name="IsValid">False when the item failed the pre-pass and must be skipped.</param>
|
||
private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, RealmKey Key, bool IsValid);
|
||
|
||
/// <summary>The body of a single value write. <b>Caller MUST hold <c>Lock</c></b> — this is factored out
|
||
/// solely so <see cref="WriteValues"/> can apply a whole batch under one acquisition; it is byte-identical
|
||
/// to what <see cref="WriteValue"/> has always done inside its own <c>lock</c>.</summary>
|
||
/// <param name="nodeId">The bare <c>s=</c> identifier of the variable.</param>
|
||
/// <param name="ns">The namespace index resolved from the write's realm.</param>
|
||
/// <param name="key">The realm-qualified map key for <paramref name="nodeId"/>.</param>
|
||
/// <param name="value">The new value to write.</param>
|
||
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
|
||
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
|
||
private void WriteValueLocked(string nodeId, ushort ns, RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc)
|
||
{
|
||
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
|
||
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
|
||
if (!_variables.TryGetValue(key, out var variable))
|
||
{
|
||
variable = CreateVariable(nodeId, ns);
|
||
_variables[key] = variable;
|
||
}
|
||
|
||
variable.Value = value;
|
||
// #519 — the caller's status code reaches the SDK verbatim, sub-code included. This used to
|
||
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
|
||
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
|
||
variable.StatusCode = new StatusCode(statusCode);
|
||
variable.Timestamp = sourceTimestampUtc;
|
||
// Stays INSIDE the lock, per node: this is how the SDK notifies monitored items of the change.
|
||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply a full Part 9 alarm-condition write. When a real <see cref="AlarmConditionState"/> has
|
||
/// been materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
|
||
/// this projects the whole <paramref name="state"/> snapshot
|
||
/// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
|
||
/// node and recomputes Retain (T15 — richer state; <b>still no event firing</b>, that lands in T16).
|
||
/// Otherwise it falls back to the legacy two-element <c>[Active, Acknowledged]</c>
|
||
/// <see cref="BaseDataVariableState"/> placeholder so callers whose alarm node hasn't been
|
||
/// materialised (and the existing unit tests) keep working.
|
||
/// </summary>
|
||
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
||
/// <param name="state">The full condition state to project onto the node.</param>
|
||
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
|
||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||
ArgumentNullException.ThrowIfNull(state);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var ns = NamespaceIndexForRealm(realm);
|
||
var key = MapKey(realm, alarmNodeId);
|
||
// Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
|
||
// _alarmConditions / detach the condition node between the lookup and the Set* calls.
|
||
lock (Lock)
|
||
{
|
||
if (_alarmConditions.TryGetValue(key, out var condition))
|
||
{
|
||
// T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting
|
||
// the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
|
||
// snapshot to that current state. We gate against the NODE's state, NOT a "last written"
|
||
// cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
|
||
// SDK mutated AckedState, then WireMethodTransitionReporting reported the one event for it —
|
||
// Gitea #587) NEVER passed through this method, so a last-written cache would be stale and
|
||
// wrongly report a delta. By the time the engine re-projects that ack here, the node already
|
||
// holds the acked state → no delta → suppress.
|
||
var current = ReadConditionDelta(condition);
|
||
var incoming = ToConditionDelta(state, condition);
|
||
bool fire = ShouldFireConditionEvent(current, incoming);
|
||
|
||
// EnabledState / AckedState / ActiveState are mandatory children — always present after
|
||
// Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
|
||
// that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
|
||
// future SDK that builds a leaner child set) may leave them null. Null-guard each optional
|
||
// child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
|
||
// no-op rather than an NRE.
|
||
condition.SetEnableState(SystemContext, state.Enabled);
|
||
condition.SetActiveState(SystemContext, state.Active);
|
||
condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
|
||
if (condition.ConfirmedState is not null)
|
||
{
|
||
condition.SetConfirmedState(SystemContext, state.Confirmed);
|
||
}
|
||
if (condition.ShelvingState is not null)
|
||
{
|
||
// SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
|
||
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine; the time
|
||
// passed here is the engine's remaining time, so the node's UnshelveTime counts down to the
|
||
// real expiry (Gitea #597 — a 0 here reads as MaxTimeShelved, ~24.8 days). The SDK timer it
|
||
// arms does nothing (OnTimedUnshelve is a no-op): the engine's own expiry re-projects.
|
||
condition.SetShelvingState(
|
||
SystemContext,
|
||
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
|
||
oneShot: state.Shelving == AlarmShelvingKind.OneShot,
|
||
shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc) : 0);
|
||
}
|
||
condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity));
|
||
condition.Message.Value = new LocalizedText(state.Message);
|
||
|
||
// #477 — project the source-data quality. A Good→Bad bucket change is a genuine condition
|
||
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
|
||
// it never touches Active/Acked/Retain (annotation only). Quality is an optional Part 9 child —
|
||
// null-guard it like Confirmed/Shelving in case a leaner SDK child set omits it.
|
||
if (condition.Quality is not null)
|
||
{
|
||
condition.Quality.Value = new StatusCode(state.StatusCode);
|
||
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
|
||
}
|
||
|
||
// Part 9: retain the condition while it is active OR unacknowledged so a client's
|
||
// ConditionRefresh replays it. The event firing below also depends on this Retain being
|
||
// correct (a non-retained inactive+acked condition still fires its transition event, but
|
||
// won't be replayed on a later ConditionRefresh).
|
||
condition.Retain.Value = state.Active || !state.Acknowledged;
|
||
condition.Time.Value = sourceTimestampUtc;
|
||
condition.ReceiveTime.Value = sourceTimestampUtc;
|
||
|
||
// T20 — fire a real Part 9 condition event ONLY when this projection is a genuine state
|
||
// change (the delta-gate decided above, against the node's pre-projection state). A
|
||
// genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
|
||
// engine-side ack, etc.) differs from the node's current state → fire. The re-projection
|
||
// of a client method call already applied (and already reported, by the method path —
|
||
// WireMethodTransitionReporting) equals the node's current state → no delta → suppress, so
|
||
// the transition is reported exactly once. ReportConditionEvent
|
||
// stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
|
||
if (fire)
|
||
{
|
||
ReportConditionEvent(condition, sourceTimestampUtc);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
|
||
// un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates
|
||
// the SDK address space, so it MUST run under Lock (see WriteValue).
|
||
if (!_variables.TryGetValue(key, out var variable))
|
||
{
|
||
variable = CreateVariable(alarmNodeId, ns);
|
||
_variables[key] = variable;
|
||
}
|
||
|
||
variable.Value = new[] { state.Active, state.Acknowledged };
|
||
variable.StatusCode = StatusCodes.Good;
|
||
variable.Timestamp = sourceTimestampUtc;
|
||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #597 — the <c>shelvingTime</c> (ms) to hand <c>SetShelvingState</c> for a timed shelve expiring at
|
||
/// <paramref name="unshelveAtUtc"/>. <c>0</c> when the expiry is unknown (the SDK then uses
|
||
/// <c>MaxTimeShelved</c>, the pre-#597 behaviour); otherwise the remaining time, clamped to at least 1 ms
|
||
/// (an already-passed expiry must not read as "unknown" — the engine's own expiry is about to land) and to
|
||
/// at most <c>int.MaxValue</c> (the SDK timer's range).
|
||
/// </summary>
|
||
/// <param name="unshelveAtUtc">The engine's expiry instant, or null.</param>
|
||
/// <returns>The shelving time in milliseconds.</returns>
|
||
internal static double RemainingShelveMs(DateTime? unshelveAtUtc)
|
||
{
|
||
if (unshelveAtUtc is not { } at) return 0;
|
||
var remaining = (at - DateTime.UtcNow).TotalMilliseconds;
|
||
return Math.Clamp(Math.Ceiling(remaining), 1, int.MaxValue);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #477 Layer 2 — apply a source-data quality annotation to a materialised condition, OUT OF BAND
|
||
/// from any alarm transition. Used by the driver-connectivity path (comms lost → BadNoCommunication,
|
||
/// restored → Good) so a native condition whose device is unreachable stops reporting the
|
||
/// accidentally-Good default. This sets ONLY <see cref="ConditionState.Quality"/> — it never touches
|
||
/// Active / Acked / Severity / Retain (a comms-lost active alarm must stay active). A change in the
|
||
/// status code is a genuine Part 9 condition change, so it fires one condition event carrying the
|
||
/// new Quality; an unchanged code suppresses (no spurious event). Unknown / unmaterialised node ⇒
|
||
/// safe no-op (a mid-rebuild race must not fault a connectivity update), mirroring the other writes.
|
||
/// <para><b>#519 changed the fire test from bucket to full code.</b> While the annotation was a
|
||
/// 3-state enum, "the bucket moved" and "the value changed" were the same predicate. Now that the
|
||
/// sub-code survives, a move from one Bad reason to another Bad reason genuinely changes what a
|
||
/// client reads, so it fires — suppressing it would leave subscribers holding a stale reason.</para>
|
||
/// </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="sourceTimestampUtc">Timestamp of the connectivity transition in UTC.</param>
|
||
/// <param name="realm">The condition's address-space realm.</param>
|
||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var key = MapKey(realm, alarmNodeId);
|
||
lock (Lock)
|
||
{
|
||
// Only materialised conditions carry a Quality child; a bare value variable or a missing node is
|
||
// a no-op (the connectivity fan-out visits every condition the driver owns, some of which a
|
||
// concurrent rebuild may have just cleared).
|
||
if (!_alarmConditions.TryGetValue(key, out var condition) || condition.Quality is null)
|
||
return;
|
||
|
||
var newCode = new StatusCode(statusCode);
|
||
var changed = condition.Quality.Value.Code != newCode.Code;
|
||
|
||
condition.Quality.Value = newCode;
|
||
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
|
||
|
||
// Fire ONLY on a real change so a steady-state connectivity re-assert doesn't spam events.
|
||
if (changed)
|
||
{
|
||
condition.Time.Value = sourceTimestampUtc;
|
||
condition.ReceiveTime.Value = sourceTimestampUtc;
|
||
ReportConditionEvent(condition, sourceTimestampUtc);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
|
||
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
|
||
/// have applied the new state via the <c>Set*</c> projection — this stamps a fresh per-event
|
||
/// <c>EventId</c>, <c>ClearChangeMasks</c>, then <c>ReportEvent</c> with an
|
||
/// <see cref="InstanceStateSnapshot"/> (a frozen copy of the condition's children at fire time,
|
||
/// so a subscribing client sees the values at this instant even if the live node mutates after).
|
||
/// <para>
|
||
/// A fresh <c>EventId</c> per event is a Part 9 requirement: inbound Acknowledge / Confirm /
|
||
/// AddComment calls are correlated back to a specific event by this id (the SDK matches it via
|
||
/// <c>GetEventByEventId</c> / <c>GetBranch</c>), so T17's ack routing relies on it being unique
|
||
/// per emission. We use the main branch only (<c>BranchId == NodeId.Null</c>, set at
|
||
/// materialise) — no branch creation here.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Method-driven transitions (Gitea #587).</b> An inbound client Acknowledge / Confirm /
|
||
/// AddComment / shelve / Enable / Disable goes through the SDK's own handler, which (after T18's
|
||
/// gate returns Good) applies the new state to the node, BYPASSING <see cref="WriteAlarmCondition"/>.
|
||
/// The SDK reports <b>no</b> event for it — its <c>ReportStateChange</c> is gated on
|
||
/// <c>AutoReportStateChanges</c>, which stays false — so the method path calls this method itself,
|
||
/// once, after the handler returns (<see cref="WireMethodTransitionReporting"/>). The engine then
|
||
/// re-projects that same transition through <see cref="WriteAlarmCondition"/>, whose delta-gate
|
||
/// compares against the NODE's CURRENT state, finds no change, and suppresses a second event. (An
|
||
/// earlier comment here claimed the SDK "auto-fires its own event (E2)"; it never did, so a client
|
||
/// ack produced no event at all.) Genuine engine-driven transitions still differ from the node's
|
||
/// current state, so they fire here as before.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
|
||
/// <param name="ts">The source/receive timestamp (UTC) for this event.</param>
|
||
private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts)
|
||
{
|
||
// Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it).
|
||
alarm.EventId.Value = Guid.NewGuid().ToByteArray();
|
||
// Time/ReceiveTime were already set to sourceTimestampUtc by the WriteAlarmCondition projection
|
||
// immediately above; the assignment here is a locality repeat (same value, no behavioral change)
|
||
// so the restamp is co-located with the EventId and ClearChangeMasks in the same method.
|
||
alarm.Time.Value = ts;
|
||
alarm.ReceiveTime.Value = ts;
|
||
|
||
// Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
|
||
// the InstanceStateSnapshot captures the just-projected values.
|
||
alarm.ClearChangeMasks(SystemContext, includeChildren: true);
|
||
|
||
try
|
||
{
|
||
// InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire
|
||
// time. ReportEvent walks inverse notifier references up to the root-notifier folders (promoted
|
||
// in MaterialiseAlarmCondition), whose fan-outs deliver it to direct notifier subscriptions; the
|
||
// condition's own OnReportEvent hands the same snapshot to the Server-object (all-events)
|
||
// subscriptions, per item, through the ACL (Gitea #586 — see OnConditionReportEvent).
|
||
var snapshot = new InstanceStateSnapshot();
|
||
snapshot.Initialize(SystemContext, alarm);
|
||
alarm.ReportEvent(SystemContext, snapshot);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// A failed event report must NOT break the state projection or the calling actor: the node's
|
||
// state has already been applied + ClearChangeMasks'd, so attribute subscribers still see the
|
||
// change; only the event delivery is lost. Log through the injected ILogger instead of
|
||
// swallowing silently — a recurring failure here is then visible in the server log rather than
|
||
// invisible. T19's live Client.CLI run is the integration proof that the happy path delivers.
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {NodeId}", alarm.NodeId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
|
||
/// condition event AND that an <see cref="AlarmConditionSnapshot"/> can change. As a record, two
|
||
/// instances compare by value, so <see cref="ShouldFireConditionEvent"/> is a plain inequality.
|
||
/// <para>
|
||
/// <b>Severity</b> is stored as the value the node holds after <c>SetSeverity</c> — the exact
|
||
/// Part 9 severity clamped to 1..1000 (<see cref="ToPart9Severity"/>; it used to be one of five
|
||
/// <see cref="EventSeverity"/> buckets, see there) — so a severity change fires an event. The
|
||
/// <b>Shelving</b> kind is read back from / mapped to the SDK's shelving state machine so the
|
||
/// live node and the snapshot compare on the same 3-way (Unshelved/OneShot/Timed) axis.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Why <c>CommentAdded</c> is not a field here (intentional).</b>
|
||
/// <c>EmissionKind.CommentAdded</c> is produced only by
|
||
/// <c>Part9StateMachine.ApplyAddComment</c>, which is reached only via
|
||
/// <c>ScriptedAlarmEngine.AddCommentAsync</c>, which is called only from
|
||
/// <c>ScriptedAlarmHostActor</c>'s inbound <c>AlarmCommand</c> handler — meaning
|
||
/// <c>CommentAdded</c> ALWAYS originates from a client calling the condition's
|
||
/// <c>AddComment</c> method. On that path T18's <c>OnAddComment</c> delegate returns
|
||
/// <c>ServiceResult.Good</c>, the OPC UA SDK applies the comment to the node, and the method
|
||
/// path reports the one Part 9 comment event (<see cref="WireMethodTransitionReporting"/>,
|
||
/// Gitea #587) — BEFORE the engine re-projects via <see cref="WriteAlarmCondition"/>. When that
|
||
/// re-projection arrives here, the delta-gate sees no change in any compared field (the snapshot
|
||
/// carries no comments list) and correctly suppresses a second event. Force-firing for
|
||
/// <c>CommentAdded</c> would double-emit. There is no engine-internal or script-driven
|
||
/// comment path, so suppression never drops a needed event.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Why <c>Retain</c> is absent (intentional — safe today).</b>
|
||
/// <c>Retain</c> is projected as <c>state.Active || !state.Acknowledged</c> in
|
||
/// <see cref="WriteAlarmCondition"/>. Every path that flips <c>Retain</c> necessarily
|
||
/// changes <c>Active</c> or <c>Acknowledged</c> (both ARE compared fields), so a
|
||
/// <c>Retain</c> flip always rides along with a real delta and fires correctly. If a
|
||
/// future engine were to set <c>Retain</c> independently — without touching
|
||
/// <c>Active</c>/<c>Acknowledged</c> — it would need to be added here.
|
||
/// </para>
|
||
/// </summary>
|
||
internal readonly record struct AlarmConditionDelta(
|
||
bool Active,
|
||
bool Acknowledged,
|
||
bool Confirmed,
|
||
bool Enabled,
|
||
AlarmShelvingKind Shelving,
|
||
ushort MappedSeverity,
|
||
string Message,
|
||
// #477 — the source-data quality bucket. Included so a quality-only transition (e.g. a device
|
||
// going comms-lost: Good→Bad with no state change) is a genuine delta and fires a Part 9 event.
|
||
// StatusCode has value equality, so the record struct's == still holds.
|
||
StatusCode Quality);
|
||
|
||
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
|
||
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
|
||
/// incoming snapshot. Pure + value-based so it's unit-testable in isolation: returns <c>true</c> iff
|
||
/// any gate-relevant field differs. An inbound client ack the SDK already applied makes
|
||
/// <paramref name="current"/> == <paramref name="incoming"/> ⇒ <c>false</c> (suppress the re-projected
|
||
/// double-emit); a genuine engine-driven transition differs ⇒ <c>true</c> (fire).</summary>
|
||
/// <param name="current">The node's current (pre-projection) gate-relevant state.</param>
|
||
/// <param name="incoming">The incoming snapshot's gate-relevant state.</param>
|
||
/// <returns><c>true</c> to fire a condition event; <c>false</c> to suppress (no delta).</returns>
|
||
internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) =>
|
||
current != incoming;
|
||
|
||
/// <summary>Read the gate-relevant slice off the LIVE condition node. Mandatory children
|
||
/// (Active/Acked/Enabled) are always present; Confirmed/Shelving are optional and null-guarded
|
||
/// (a leaner child set ⇒ treat as the unset default). Severity is read as the already-clamped
|
||
/// value the node stores, and shelving is mapped from the shelving
|
||
/// state machine's CurrentState so it lines up with <see cref="ToConditionDelta"/>.</summary>
|
||
private static AlarmConditionDelta ReadConditionDelta(AlarmConditionState condition) => new(
|
||
Active: condition.ActiveState?.Id?.Value ?? false,
|
||
Acknowledged: condition.AckedState?.Id?.Value ?? true,
|
||
Confirmed: condition.ConfirmedState?.Id?.Value ?? true,
|
||
Enabled: condition.EnabledState?.Id?.Value ?? true,
|
||
Shelving: ReadShelvingKind(condition),
|
||
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
|
||
Message: condition.Message?.Value?.Text ?? string.Empty,
|
||
// Optional Part 9 child; a node without it reads as Good (matching the accidentally-Good default
|
||
// and ToConditionDelta's fold), so a snapshot Quality can't create a phantom delta against it.
|
||
Quality: condition.Quality?.Value ?? StatusCodes.Good);
|
||
|
||
/// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
|
||
/// the node stores in a derived form: Severity is run through <see cref="ToPart9Severity"/> so it matches
|
||
/// the value the node holds (the projection calls <c>SetSeverity(ToPart9Severity(...))</c>), and an
|
||
/// optional Confirmed/Shelving that the node can't actually hold (missing child) is folded to the
|
||
/// node's read-back default so it never spuriously registers as a delta.</summary>
|
||
private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new(
|
||
Active: state.Active,
|
||
Acknowledged: state.Acknowledged,
|
||
// If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's
|
||
// read-back default (true) so a snapshot Confirmed value can't create a phantom delta.
|
||
Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true,
|
||
Enabled: state.Enabled,
|
||
// Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the
|
||
// node's read-back default (Unshelved).
|
||
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
|
||
MappedSeverity: (ushort)ToPart9Severity(state.Severity),
|
||
Message: state.Message ?? string.Empty,
|
||
// If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
|
||
// node's read-back default (Good) so a snapshot Quality can't register a spurious delta.
|
||
Quality: condition.Quality is not null ? new StatusCode(state.StatusCode) : StatusCodes.Good);
|
||
|
||
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
|
||
/// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
|
||
/// a shelving sub-state machine (or an unrecognised/unset state) reads as
|
||
/// <see cref="AlarmShelvingKind.Unshelved"/> — the same value <see cref="ToConditionDelta"/> folds an
|
||
/// unsupported shelving snapshot to, so the two stay comparable.</summary>
|
||
private static AlarmShelvingKind ReadShelvingKind(AlarmConditionState condition)
|
||
{
|
||
var stateId = condition.ShelvingState?.CurrentState?.Id?.Value as NodeId;
|
||
if (stateId == ObjectIds.ShelvedStateMachineType_OneShotShelved) return AlarmShelvingKind.OneShot;
|
||
if (stateId == ObjectIds.ShelvedStateMachineType_TimedShelved) return AlarmShelvingKind.Timed;
|
||
return AlarmShelvingKind.Unshelved;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
|
||
/// folder so clients can browse it as a proper condition (and subscribe to its events). The node
|
||
/// id is the alarm node id (the ScriptedAlarmId) so subsequent
|
||
/// <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
|
||
/// <para>
|
||
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
|
||
/// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
|
||
/// <b>no</b> events itself.
|
||
/// </para>
|
||
/// Idempotent: a second call with the same <paramref name="alarmNodeId"/> tears down the prior
|
||
/// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
|
||
/// </summary>
|
||
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.</param>
|
||
/// <param name="equipmentNodeId">The equipment folder node id the condition parents under (null/unknown ⇒ root).</param>
|
||
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
|
||
/// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
|
||
/// <param name="severity">Domain severity (an OPC UA 1..1000 severity); written verbatim, clamped to 1..1000.</param>
|
||
/// <param name="isNative">True when the condition is a NATIVE (driver-fed, e.g. Galaxy) alarm rather than a scripted one.</param>
|
||
/// <remarks>
|
||
/// <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
|
||
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
|
||
/// map: <c>OffNormalAlarm</c> → <see cref="OffNormalAlarmState"/>, <c>DiscreteAlarm</c> →
|
||
/// <see cref="DiscreteAlarmState"/>, and everything else (including <c>AlarmCondition</c> and
|
||
/// <c>LimitAlarm</c>, which has no script-supplied limits) → the base
|
||
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
|
||
/// notes — a script alarm carries no High/Low limits to populate.</para>
|
||
/// </remarks>
|
||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var ns = NamespaceIndexForRealm(realm);
|
||
var conditionKey = MapKey(realm, alarmNodeId);
|
||
lock (Lock)
|
||
{
|
||
// R2-07 T4a: idempotent skip-if-present-and-same-kind. On a PURE-ADD apply,
|
||
// MaterialiseScriptedAlarms + the native-alarm materialise re-run over the FULL composition, so
|
||
// an existing enabled condition is re-materialised with the SAME id + kind. Skip it — KEEP the
|
||
// existing AlarmConditionState instance alive so every MonitoredItem on that condition node
|
||
// survives the deploy (the drop-and-recreate below would otherwise kill them). A genuine
|
||
// re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
|
||
// first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
|
||
// stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
|
||
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative)
|
||
return;
|
||
|
||
// Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
|
||
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
|
||
if (_alarmConditions.TryRemove(conditionKey, out var existing))
|
||
{
|
||
// Wave C review L1: unwire the OLD instance's notifier pairs BEFORE discarding it, so no
|
||
// referencing equipment folder keeps a dangling inverse-notifier entry to the dropped
|
||
// AlarmConditionState. Unreachable today (native=Raw/RawPath, scripted=Uns/ScriptedAlarmId — no
|
||
// same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown
|
||
// path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
|
||
UnwireAlarmNotifiers(conditionKey);
|
||
existing.Parent?.RemoveChild(existing);
|
||
ForgetPredefinedSubtree(existing);
|
||
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
|
||
// dropped instance's back here rather than leave it tracked until the next full reconcile.
|
||
if (_allEventsMonitoredConditions.Remove(existing))
|
||
existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
|
||
}
|
||
|
||
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
|
||
// kind, so always drop the stale native flag first and only re-add it below when isNative.
|
||
_nativeAlarmNodeIds.Remove(conditionKey);
|
||
|
||
var parent = ResolveParentFolder(equipmentNodeId, realm);
|
||
|
||
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
|
||
alarm.SymbolicName = displayName;
|
||
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
|
||
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
|
||
|
||
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
|
||
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
|
||
// type's embedded definition; we do not hand-build them.
|
||
alarm.Create(
|
||
SystemContext,
|
||
new NodeId(alarmNodeId, ns),
|
||
new QualifiedName(displayName, ns),
|
||
new LocalizedText(displayName),
|
||
assignNodeIds: true);
|
||
|
||
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
|
||
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
|
||
// Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
|
||
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
||
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
||
|
||
// #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
|
||
// the type's embedded definition but leaves them UNSET (the init string declares them mandatory
|
||
// with NO default), and nothing downstream fills them in: the auto-filling
|
||
// BaseEventState.Initialize(context, source, severity, message) overload is only used for
|
||
// transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
|
||
// here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
|
||
// SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
|
||
// alarm-bearing raw tag materialises ONLY this condition, with no sibling value
|
||
// variable (see AddressSpaceApplier's alarm branch), so there is no other node.
|
||
// SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
|
||
// Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
|
||
// is already carried by ConditionName below. See docs/AlarmTracking.md.
|
||
alarm.EventType.Value = alarm.TypeDefinitionId;
|
||
alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
|
||
alarm.SourceName.Value = alarmNodeId;
|
||
|
||
// #475 — the mandatory ConditionType classification fields, unset by Create() for the same reason as
|
||
// the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty
|
||
// text on the wire, which buckets every alarm as unclassified in a Part 9 HMI.
|
||
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
|
||
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
|
||
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
|
||
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
|
||
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
|
||
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
|
||
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
|
||
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
|
||
|
||
// #477 — ConditionType.Quality (the quality of the condition's source data). Create() leaves it
|
||
// UNSET, and default(StatusCode) == StatusCodes.Good (0x0), so an unassigned Quality reports Good
|
||
// unconditionally — a wrong VALUE (not a null), which hides a comms-lost source. A NATIVE condition
|
||
// has no data yet at materialise (its driver hasn't confirmed connectivity), so it starts
|
||
// BadWaitingForInitialData — the same "no driver data yet" convention value variables use — and is
|
||
// driven Good by the driver-connectivity path (DriverHostActor → ProjectQuality) once Connected. A
|
||
// SCRIPTED condition is script-computed and always live in v1, so it starts Good. Quality is a pure
|
||
// annotation: it NEVER alters Active/Acked/Retain (a comms-lost active alarm must stay active).
|
||
if (alarm.Quality is not null)
|
||
{
|
||
alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good;
|
||
if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow;
|
||
}
|
||
|
||
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
||
alarm.SetEnableState(SystemContext, true);
|
||
alarm.SetActiveState(SystemContext, false);
|
||
alarm.SetAcknowledgedState(SystemContext, true);
|
||
// A native condition is never Unconfirmed (Gitea #587 review): it has no confirm workflow — Confirm is
|
||
// refused and Acknowledge re-asserts Confirmed — so start it Confirmed, the value NativeAlarmProjector
|
||
// supplies on every transition, rather than the SDK's default false until the first projection.
|
||
if (isNative && alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, true);
|
||
alarm.SetSeverity(SystemContext, ToPart9Severity(severity));
|
||
// Gitea #587 (found by its TimedShelve test): Create() leaves MaxTimeShelved at 0, and the SDK's
|
||
// TimedShelve handler rejects any ShelvingTime above it — so every client TimedShelve failed with
|
||
// BadShelvingTimeOutOfRange. The bound becomes the longest shelve the SDK's own shelving timer can
|
||
// represent (int.MaxValue ms ≈ 24.8 days, the same ceiling SetShelvingState already saturated a 0 to).
|
||
if (alarm.MaxTimeShelved is { } maxTimeShelved && maxTimeShelved.Value <= 0)
|
||
maxTimeShelved.Value = int.MaxValue;
|
||
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
|
||
alarm.Message.Value = new LocalizedText(displayName);
|
||
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
|
||
|
||
// T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
|
||
// AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
|
||
// routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
|
||
// state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
|
||
// routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
|
||
// SDK applies its node state. The SDK does NOT report an event for it (its ReportStateChange is
|
||
// gated on AutoReportStateChanges, which stays false) — the ONE condition event per transition
|
||
// is ours, raised after the SDK handler returns by WireMethodTransitionReporting (Gitea #587).
|
||
// The engine's later re-projection of the same transition reaches WriteAlarmCondition's
|
||
// delta-gate, which finds the node already in that state and suppresses a second event.
|
||
// H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
|
||
// scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
|
||
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. AddComment stays on the
|
||
// scripted path even for native conditions (H6c scope is Acknowledge only).
|
||
// Gitea #587 review: a NATIVE condition supports Acknowledge and AddComment only. Confirm and the
|
||
// shelve methods return BadNotSupported (like Enable/Disable below) — there is no upstream path for
|
||
// them, no pair replication of a local state, and the driver projection (Confirmed, Unshelved) would
|
||
// silently undo one at the next transition.
|
||
alarm.OnAcknowledge = (context, condition, _, comment) =>
|
||
IsNativeAlarmNode(alarmNodeId, realm)
|
||
? HandleNativeAlarmAck(context, condition, comment)
|
||
: HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
|
||
alarm.OnConfirm = (context, condition, _, comment) =>
|
||
IsNativeAlarmNode(alarmNodeId, realm)
|
||
? new ServiceResult(StatusCodes.BadNotSupported)
|
||
: HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
||
alarm.OnAddComment = (context, condition, _, comment) =>
|
||
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
|
||
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
|
||
{
|
||
if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported);
|
||
|
||
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
|
||
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
|
||
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
|
||
// Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
|
||
// shelvingTime is an OPC UA Duration (milliseconds).
|
||
var (operation, unshelveAt) =
|
||
!shelving ? ("Unshelve", (DateTime?)null)
|
||
: oneShot ? ("OneShotShelve", null)
|
||
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
|
||
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
|
||
};
|
||
// The SDK's auto-unshelve timer (armed by SetShelvingState for the real remaining time since Gitea
|
||
// #597) fires with a session-less system context. It is deliberately a NO-OP: the engine owns
|
||
// timed-shelve expiry (ScriptedAlarmEngine's shelving check → Part9StateMachine.ApplyShelvingCheck,
|
||
// recorded as "AutoUnshelve" by "system" with its "Timed shelving expired" comment), and its
|
||
// re-projection is what unshelves this node. Routing an operator-style "Unshelve" from here used to
|
||
// pre-empt that check on every expiry — from BOTH pair nodes, rewriting the audit trail as a manual
|
||
// unshelve — and a timer callback already queued when a re-shelve disposed it could unshelve the
|
||
// fresh shelve. The SDK's own OnTimerExpired changes no shelving state itself (it only refreshes
|
||
// UnshelveTime after this callback), so returning without routing leaves the node shelved until
|
||
// the engine's expiry lands. Native conditions are never timed-shelved (BadNotSupported above).
|
||
alarm.OnTimedUnshelve = (_, _) => ServiceResult.Good;
|
||
|
||
// H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
|
||
// here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
|
||
// (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
|
||
// "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
|
||
// AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
|
||
// engine enable/disable surface — they short-circuit to BadNotSupported.
|
||
alarm.OnEnableDisable = (context, condition, enabling) =>
|
||
{
|
||
if (IsNativeAlarmNode(alarmNodeId, realm))
|
||
return new ServiceResult(StatusCodes.BadNotSupported);
|
||
return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
|
||
};
|
||
|
||
// Gitea #587: exactly one condition event per accepted method call, reported by us once the SDK's
|
||
// own handler has applied the new state.
|
||
WireMethodTransitionReporting(alarm, conditionKey);
|
||
|
||
// Gitea #586: the condition itself delivers its events to Server-object (all-events)
|
||
// subscriptions, item by item through the ACL. A condition is never an event notifier, so no
|
||
// MonitoredNode2 ever claims this single-slot delegate — every event the condition reports, ours
|
||
// or the SDK's own method-driven state changes, passes through it exactly once.
|
||
alarm.OnReportEvent = OnConditionReportEvent;
|
||
|
||
parent.AddChild(alarm);
|
||
|
||
// Promote the equipment folder to an event notifier + register it as a root notifier so
|
||
// T16's ReportEvent reaches the folder's subscribers. Guard so repeated
|
||
// materialise under the same folder doesn't double-add the root notifier.
|
||
EnsureFolderIsEventNotifier(parent);
|
||
|
||
AddPredefinedNode(SystemContext, alarm);
|
||
_alarmConditions[conditionKey] = alarm;
|
||
// Only the new condition can be missing its unit here, so raise just that one — a full reconcile
|
||
// walks every condition, which made a deploy of N alarms O(N²) under Lock whenever any Server-object
|
||
// item was live (always, in production). Removal, rebuild and (un)subscribe keep the full reconcile.
|
||
RaiseAllEventsMonitoring(alarm);
|
||
// H6a: record native (driver-fed) conditions so a later task can route their inbound
|
||
// Acknowledge to the driver rather than the scripted engine.
|
||
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
|
||
|
||
// Gitea #641: a native condition the rebuild in this apply discarded gets its live state back. Removed
|
||
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
|
||
// for a later re-add to resurrect.
|
||
if (_rebuildCarryOver.Remove(conditionKey, out var carried) && isNative)
|
||
RestoreCarryOver(alarm, conditionKey, carried);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #641 — one native condition's live Part 9 state, carried across a full
|
||
/// <see cref="RebuildAddressSpace"/>.
|
||
/// </summary>
|
||
/// <param name="State">The gate-relevant fields, read off the node as <see cref="ReadConditionDelta"/> does.</param>
|
||
/// <param name="Time">The condition's <c>Time</c> — the instant its current state came about.</param>
|
||
/// <param name="ActiveTransitionTime">When it last changed Active, if the SDK recorded it.</param>
|
||
/// <param name="QualityTimestamp">The source timestamp of its Quality annotation, if present.</param>
|
||
/// <param name="Comment">The condition's <c>Comment</c> (a client's ack / AddComment text), if any.</param>
|
||
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
|
||
private sealed record NativeConditionCarryOver(
|
||
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
|
||
LocalizedText? Comment = null, string? ClientUserId = null);
|
||
|
||
/// <summary>
|
||
/// Gitea #641 — read what a native condition node holds right now, so the rebuild can put it back.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Why the NODE, not a host-side cache.</b> The node is the only place that knows the true state:
|
||
/// a client's Acknowledge (and its comment) is applied to the node by the SDK and never passes through
|
||
/// the driver or <c>NativeAlarmProjector</c>. Replaying from any host-side record would un-acknowledge it.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Why it is needed at all.</b> A rebuild recreates every condition inactive + acknowledged. When the
|
||
/// alarm-tag set is unchanged — the common case: adding a <c>UnsTagReference</c> to an alarm-bearing raw
|
||
/// tag is enough to force one — the host does not re-subscribe the driver's alarms, and even a
|
||
/// re-subscribe would not help a transition-only source (Galaxy, OpcUaClient) with nothing to replay.
|
||
/// Without this, every active alarm read normal until its next device transition.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="condition">The live native condition, about to be discarded.</param>
|
||
/// <returns>Its carried state.</returns>
|
||
private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new(
|
||
ReadConditionDelta(condition),
|
||
condition.Time?.Value ?? DateTime.UtcNow,
|
||
condition.ActiveState?.TransitionTime?.Value,
|
||
condition.Quality?.SourceTimestamp?.Value,
|
||
condition.Comment?.Value,
|
||
condition.ClientUserId?.Value);
|
||
|
||
/// <summary>
|
||
/// Gitea #641 — put a carried state back onto the freshly materialised native condition. MUST be called
|
||
/// under <c>Lock</c>, after the condition is registered.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Quality always; the rest only off-normal.</b> Source quality is annotated only on a driver's
|
||
/// connectivity edge, so a rebuild otherwise left every native condition — normal ones included —
|
||
/// reading <c>BadWaitingForInitialData</c> until the driver next flapped; it is restored silently. A
|
||
/// condition in the no-event position (inactive + acknowledged) otherwise keeps exactly what the
|
||
/// materialise built, message and authored severity included, and reports nothing — the same rule the
|
||
/// scripted re-assert follows (#487).
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Reported later, once.</b> The state is applied now, so a Read or ConditionRefresh in the window
|
||
/// already sees it, but the one condition event waits for <see cref="CompleteAddressSpaceApply"/>: the
|
||
/// equipment folders the condition reports through are wired AFTER this call
|
||
/// (<see cref="WireAlarmNotifiers"/>), and reporting now would miss them. Native conditions refuse
|
||
/// Confirm, shelving and Enable/Disable (#587), so Enabled and Shelving are not carried.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="alarm">The new condition instance.</param>
|
||
/// <param name="conditionKey">Its key.</param>
|
||
/// <param name="carried">What the discarded instance held.</param>
|
||
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
|
||
{
|
||
var state = carried.State;
|
||
if (alarm.Quality is not null)
|
||
{
|
||
alarm.Quality.Value = state.Quality;
|
||
if (alarm.Quality.SourceTimestamp is not null && carried.QualityTimestamp is { } qualityAt)
|
||
alarm.Quality.SourceTimestamp.Value = qualityAt;
|
||
}
|
||
|
||
var retained = state.Active || !state.Acknowledged;
|
||
if (!retained) return;
|
||
|
||
alarm.SetActiveState(SystemContext, state.Active);
|
||
if (carried.ActiveTransitionTime is { } activeAt && alarm.ActiveState?.TransitionTime is { } transitionTime)
|
||
transitionTime.Value = activeAt;
|
||
alarm.SetAcknowledgedState(SystemContext, state.Acknowledged);
|
||
if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed);
|
||
alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity);
|
||
alarm.Message.Value = new LocalizedText(state.Message);
|
||
alarm.Retain.Value = true;
|
||
if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment;
|
||
if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId;
|
||
alarm.Time.Value = carried.Time;
|
||
alarm.ReceiveTime.Value = carried.Time;
|
||
|
||
_restoredConditionReports.Add((conditionKey, alarm));
|
||
}
|
||
|
||
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
|
||
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
|
||
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
|
||
/// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
|
||
/// the instance changes).</summary>
|
||
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);
|
||
|
||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.WireAlarmNotifiers"/>
|
||
/// <remarks>
|
||
/// Wave C review M3 — this is a RECONCILE, not additive-only: it wires the supplied set AND unwires any
|
||
/// previously-tracked folder whose id is no longer in <paramref name="notifierFolderNodeIds"/>
|
||
/// (bidirectional), so a de-referenced equipment stops receiving the alarm. Today the applier always
|
||
/// passes the COMPLETE referencing-equipment set for the condition, and a de-reference on an
|
||
/// ALARM-BEARING raw tag still arrives as a full rebuild (which clears the wiring), so the unwire leg is
|
||
/// a no-op on the current paths — but it makes this method correct for a FUTURE surgical path that
|
||
/// reaches an alarm-bearing tag. The 2026-08-15 surgical-deploys work narrowed the classifier's blanket
|
||
/// <c>ChangedRawTags → Rebuild</c> branch to <em>alarm-bearing / identity-changing</em> raw-tag deltas
|
||
/// only (see <c>AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible</c>, which carries the
|
||
/// matching binding guard): a NON-alarm raw tag carries no notifier wiring, so its
|
||
/// <c>ReferencingEquipmentPaths</c> edit is node-inert and routes surgically.
|
||
/// </remarks>
|
||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
|
||
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||
ArgumentNullException.ThrowIfNull(notifierFolderNodeIds);
|
||
if (notifierFolderNodeIds.Count == 0) return;
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var alarmKey = MapKey(alarmRealm, alarmNodeId);
|
||
lock (Lock)
|
||
{
|
||
// The condition must already be materialised (MaterialiseAlarmCondition ran first in the same
|
||
// apply pass). A miss ⇒ a mid-rebuild race cleared it: no-op (logged), never throw — a deploy must
|
||
// not fault on the notifier wiring.
|
||
if (!_alarmConditions.TryGetValue(alarmKey, out var alarm))
|
||
{
|
||
// Interpolate the key's PARTS, not the RealmKey struct — a struct dump would render a shape
|
||
// no log-grep pattern in this file matches.
|
||
_logger.LogInformation("OtOpcUaNodeManager.WireAlarmNotifiers: condition {ConditionId} (realm {Realm}) not materialised — skipping notifier wiring",
|
||
alarmKey.Id, alarmKey.Realm);
|
||
return;
|
||
}
|
||
|
||
// Get-or-create the wiring entry; a rebuild recreated the AlarmConditionState instance, so reset
|
||
// the entry (the old folder list referenced the discarded instance) when the instance differs.
|
||
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm))
|
||
{
|
||
wiring = new AlarmNotifierWiring(alarm, new List<FolderState>());
|
||
_alarmNotifierWiring[alarmKey] = wiring;
|
||
}
|
||
|
||
// The DESIRED notifier-folder keys (ns-qualified) for this condition — the full set the caller
|
||
// supplied, regardless of whether each is currently materialised. Reconcile against it below.
|
||
var desiredKeys = notifierFolderNodeIds
|
||
.Select(id => MapKey(notifierFolderRealm, id))
|
||
.ToHashSet();
|
||
|
||
// Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a
|
||
// genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the
|
||
// folder's ns-qualified NodeId string so a transiently-unmaterialised-but-still-referenced folder
|
||
// (id still present) is NOT unwired. Bidirectional so the folder's inverse entry is removed too.
|
||
foreach (var wired in wiring.Folders.Where(w => !desiredKeys.Contains(MapKey(w.NodeId))).ToList())
|
||
{
|
||
wiring.Alarm.RemoveNotifier(SystemContext, wired, bidirectional: true);
|
||
wiring.Folders.Remove(wired);
|
||
}
|
||
|
||
foreach (var folderNodeId in notifierFolderNodeIds)
|
||
{
|
||
if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder))
|
||
{
|
||
// Referencing-equipment folder not (yet) materialised — skip this one (logged, never thrown).
|
||
_logger.LogInformation("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {FolderId} for condition {ConditionId} (realm {Realm}) not present — skipped",
|
||
folderNodeId, alarmKey.Id, alarmKey.Realm);
|
||
continue;
|
||
}
|
||
|
||
// Normative SDK pattern (design §"OPC UA address space + runtime binding"): a single
|
||
// ReportEvent on the condition bubbles through its HasComponent parent chain PLUS every inverse
|
||
// entry in its notifier list, so one event fans to every wired root WITHOUT re-reporting per
|
||
// root (distinct EventIds would break Server-object dedup + Part 9 ack correlation).
|
||
// alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder
|
||
// folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link
|
||
// EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent)
|
||
// AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply)
|
||
// updates rather than duplicates.
|
||
alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
|
||
folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
|
||
EnsureFolderIsEventNotifier(folder);
|
||
|
||
if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
|
||
/// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
|
||
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
|
||
private void UnwireAlarmNotifiers(RealmKey alarmKey)
|
||
{
|
||
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
|
||
foreach (var folder in wiring.Folders)
|
||
{
|
||
// bidirectional:true also removes the inverse entry the folder holds back to the condition.
|
||
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
|
||
}
|
||
_alarmNotifierWiring.Remove(alarmKey);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Wraps each Part 9 method the SDK wired on <paramref name="alarm"/> (Acknowledge, Confirm, AddComment,
|
||
/// Enable, Disable, OneShotShelve, TimedShelve, Unshelve) so an accepted call reports exactly one
|
||
/// condition event carrying the post-call state (Gitea #587).
|
||
/// </summary>
|
||
/// <param name="alarm">The freshly created condition; its method children carry the SDK's handlers.</param>
|
||
/// <param name="conditionKey">Its key in <c>_alarmConditions</c>.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Why not <c>AutoReportStateChanges</c>.</b> The SDK's own report from a method handler
|
||
/// (<c>ConditionState.ReportStateChange</c>) is gated on <c>AutoReportStateChanges</c> — which this
|
||
/// repo had never set, so a client Acknowledge produced no condition event at all, and the engine's
|
||
/// re-projection found the node already acked and suppressed its own — and then on
|
||
/// <c>EventsMonitored()</c>, the <c>AreEventsMonitored</c> counter. That counter is imprecise in the
|
||
/// SDK: a folder subscriber's unit never reaches a condition materialised under the folder later, yet
|
||
/// the folder's unsubscribe still lowers it, which can take away the Server-object unit while an
|
||
/// <c>i=2253</c> item lives (#586 review). And the SDK's shelve handlers report <i>before</i> any
|
||
/// shelving state is applied (they only call <c>OnShelve</c>), so their event would carry the old
|
||
/// state and the engine's re-projection would then report a second one. Reporting ourselves, after
|
||
/// the handler returns, depends on none of that: <see cref="ReportConditionEvent"/> walks the notifier
|
||
/// graph unconditionally, and Server-object delivery goes through <see cref="OnConditionReportEvent"/>
|
||
/// and the ACL like every other condition event.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Shelving is applied here.</b> The SDK's shelve methods never change <c>ShelvingState</c>
|
||
/// themselves, so an accepted shelve call applies it — with the same <c>(shelved, oneShot)</c> mapping
|
||
/// <see cref="WriteAlarmCondition"/> projects, and a TimedShelve with its requested duration so
|
||
/// <c>UnshelveTime</c> is real (Gitea #597) — before reporting. The engine's re-projection of the shelve
|
||
/// then finds no delta. Every other method's state the SDK has already applied.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Exactly once.</b> The whole call runs under <c>Lock</c>. The method handlers hand the command
|
||
/// to the engine (a fire-and-forget Tell) before the SDK applies the state, so without the lock a fast
|
||
/// re-projection could take <c>Lock</c> first, see a delta, and report, followed by this report — two
|
||
/// events. Holding it makes the re-projection wait until the node holds the new state.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Native conditions support Acknowledge and AddComment only</b> (Confirm and the shelve methods
|
||
/// are refused with <c>BadNotSupported</c> before they get here). Their Confirmed and Shelving stay
|
||
/// owned by the driver projection (<c>NativeAlarmProjector</c>: always Confirmed, Unshelved). The SDK's
|
||
/// Acknowledge sets <c>Confirmed = false</c> because the condition type supports Confirm, which would
|
||
/// leave every native alarm Unconfirmed after its first OPC UA ack — ScadaBridge maps
|
||
/// <c>ConfirmedState</c> straight into its alarm state — so a native ack re-asserts Confirmed and the
|
||
/// projection's Retain rule (<c>Active || !Acked</c>) before the one report.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Only a real change is reported.</b> The condition's gate-relevant state is read before and after
|
||
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
|
||
/// acknowledged condition and sets <c>Confirmed = false</c> again; a re-ack therefore also restores the
|
||
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores an
|
||
/// uncommented re-ack) and the call is silent — unless it carried a comment, which the SDK writes and
|
||
/// which is then reported once like AddComment (Gitea #597). The scripted engine records that comment
|
||
/// too (<c>Part9StateMachine.ApplyAcknowledge</c> treats a commented re-ack as AddComment), so the
|
||
/// reported comment is persisted and replicated, not node-local. AddComment always reports: adding a comment is itself
|
||
/// the change. A NATIVE re-ack is still routed to the driver: the route is fire-and-forget, so a node
|
||
/// showing Acked does not prove the upstream system acknowledged, and re-acking is the documented retry.
|
||
/// </para>
|
||
/// <para>
|
||
/// A rejected call (bad <see cref="ServiceResult"/>, e.g. a denied gate, or a refused method on a native
|
||
/// condition) reports nothing: there was no transition.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void WireMethodTransitionReporting(AlarmConditionState alarm, RealmKey conditionKey)
|
||
{
|
||
alarm.AutoReportStateChanges = false; // the SDK's own report stays off; ours replaces it (see remarks)
|
||
|
||
if (alarm.Acknowledge?.OnCall is { } acknowledge)
|
||
{
|
||
// A commented acknowledge always reports (Gitea #597): the SDK writes the comment even on a re-ack,
|
||
// and a new comment is a Part 9 change in its own right — exactly as AddComment always reports. An
|
||
// uncommented re-ack still changes nothing and stays silent.
|
||
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
|
||
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true,
|
||
alwaysReport: !LocalizedText.IsNullOrEmpty(comment));
|
||
}
|
||
|
||
WrapComment(alarm.Confirm, alwaysReport: false);
|
||
WrapComment(alarm.AddComment, alwaysReport: true);
|
||
WrapGeneric(alarm.Enable, apply: null);
|
||
WrapGeneric(alarm.Disable, apply: null);
|
||
|
||
if (alarm.ShelvingState is { } shelving)
|
||
{
|
||
WrapGeneric(shelving.OneShotShelve, () => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: true, shelvingTime: 0));
|
||
WrapGeneric(shelving.Unshelve, () => alarm.SetShelvingState(SystemContext, shelved: false, oneShot: false, shelvingTime: 0));
|
||
if (shelving.TimedShelve is { OnCall: { } timed } timedShelve)
|
||
{
|
||
// The requested duration, so UnshelveTime counts down to the real expiry (Gitea #597 — 0 reads as
|
||
// MaxTimeShelved, ~24.8 days). The scripted engine still owns the expiry and re-projects Unshelved
|
||
// when it passes (with its own remaining time meanwhile — WriteAlarmCondition); the SDK timer this
|
||
// arms does nothing (OnTimedUnshelve is a no-op — the engine's AutoUnshelve owns the expiry).
|
||
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
|
||
() => timed(context, method, objectId, shelvingTime),
|
||
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: shelvingTime));
|
||
}
|
||
}
|
||
|
||
void WrapComment(AddCommentMethodState? method, bool alwaysReport)
|
||
{
|
||
if (method?.OnCall is not { } sdk) return;
|
||
method.OnCall = (context, m, objectId, eventId, comment) =>
|
||
ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null, alwaysReport: alwaysReport);
|
||
}
|
||
|
||
void WrapGeneric(MethodState? method, Action? apply)
|
||
{
|
||
if (method?.OnCallMethod is not { } sdk) return;
|
||
method.OnCallMethod = (context, m, inputs, outputs) =>
|
||
ReportAfterMethod(() => sdk(context, m, inputs, outputs), apply);
|
||
}
|
||
|
||
ServiceResult ReportAfterMethod(
|
||
Func<ServiceResult> sdkHandler, Action? apply, bool isAcknowledge = false, bool alwaysReport = false)
|
||
{
|
||
lock (Lock)
|
||
{
|
||
var before = ReadConditionDelta(alarm);
|
||
var retainBefore = alarm.Retain.Value;
|
||
|
||
var result = sdkHandler();
|
||
// A rebuild may have discarded this instance mid-call; its replacement is not what was called.
|
||
if (!ServiceResult.IsGood(result)
|
||
|| !_alarmConditions.TryGetValue(conditionKey, out var live)
|
||
|| !ReferenceEquals(live, alarm))
|
||
{
|
||
return result;
|
||
}
|
||
|
||
apply?.Invoke();
|
||
|
||
if (isAcknowledge && alarm.ConfirmedState is not null)
|
||
{
|
||
if (_nativeAlarmNodeIds.Contains(conditionKey))
|
||
{
|
||
// Native: no confirm workflow — keep what the driver projection says (see remarks).
|
||
alarm.SetConfirmedState(SystemContext, true);
|
||
alarm.Retain.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true);
|
||
}
|
||
else if (before.Acknowledged)
|
||
{
|
||
// Re-ack: the SDK flipped Confirmed though nothing was acknowledged; put it back.
|
||
alarm.SetConfirmedState(SystemContext, before.Confirmed);
|
||
alarm.Retain.Value = retainBefore;
|
||
}
|
||
}
|
||
|
||
if (alwaysReport || ReadConditionDelta(alarm) != before)
|
||
ReportConditionEvent(alarm, DateTime.UtcNow);
|
||
|
||
return result;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> is a NATIVE
|
||
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
|
||
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
|
||
/// <param name="alarmNodeId">The alarm condition node id.</param>
|
||
/// <returns>True when the condition is native (driver-fed); false when it is scripted or not found.</returns>
|
||
internal bool IsNativeAlarmNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||
{
|
||
// _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition /
|
||
// RebuildAddressSpace), so guard the read with the same Lock rather than risk a torn concurrent read.
|
||
lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
|
||
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
|
||
/// (<b>fails closed</b>: a missing identity or a missing role is denied), and on success builds a
|
||
/// mapped <see cref="AlarmCommand"/> and routes it through <see cref="AlarmCommandRouter"/>.
|
||
/// </summary>
|
||
/// <param name="context">The SDK context the handler delegate was invoked with — a
|
||
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session
|
||
/// identity. T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>.</param>
|
||
/// <param name="condition">The condition the method targets; its <c>NodeId</c> identifier is the
|
||
/// ScriptedAlarmId (T14 aligned them), which becomes <see cref="AlarmCommand.AlarmId"/>.</param>
|
||
/// <param name="operation">The Part 9 operation name (e.g. <c>Acknowledge</c>, <c>TimedShelve</c>).</param>
|
||
/// <param name="comment">The call's comment text, or <c>null</c> when none was supplied.</param>
|
||
/// <param name="unshelveAt">For <c>TimedShelve</c>, the computed UTC expiry; otherwise <c>null</c>.</param>
|
||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state, and the method path
|
||
/// reports the one condition event — Gitea #587);<c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||
private ServiceResult HandleAlarmCommand(
|
||
ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
|
||
{
|
||
// Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
|
||
// role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
|
||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||
var gate = EvaluateAlarmCommandGate(identity, condition, operation);
|
||
if (gate is not null) return gate;
|
||
|
||
var cmd = new AlarmCommand(
|
||
AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
||
Operation: operation,
|
||
User: AlarmOperatorName(identity!),
|
||
Comment: comment?.Text,
|
||
UnshelveAtUtc: unshelveAt);
|
||
|
||
// Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
|
||
AlarmCommandRouter?.Invoke(cmd);
|
||
|
||
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
|
||
// condition event (the SDK reports none of its own — Gitea #587).
|
||
return ServiceResult.Good;
|
||
}
|
||
|
||
/// <summary>
|
||
/// H6c — handler body for an inbound OPC UA Acknowledge on a NATIVE (driver-fed) condition. The
|
||
/// scripted engine does not own native conditions, so this routes a <see cref="NativeAlarmAck"/> to
|
||
/// <see cref="NativeAlarmAckRouter"/> (the driver-bound seam) rather than a scripted
|
||
/// <see cref="AlarmCommand"/> to <see cref="AlarmCommandRouter"/>. The calling principal is resolved
|
||
/// and the <c>AlarmAck</c> role gate applied EXACTLY as in <see cref="HandleAlarmCommand"/>
|
||
/// (<b>fails closed</b>: a missing identity or missing role is denied — no route, no state mutation).
|
||
/// </summary>
|
||
/// <param name="context">The SDK context the handler delegate was invoked with — a
|
||
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session identity
|
||
/// (T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>).</param>
|
||
/// <param name="condition">The condition the Acknowledge targets; its <c>NodeId</c> identifier is the
|
||
/// folder-scoped condition node id the driver-bound router resolves back to a driver ref.</param>
|
||
/// <param name="comment">The acknowledge comment text, or <c>null</c> when none was supplied.</param>
|
||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state; the method path reports the event);
|
||
/// <c>BadUserAccessDenied</c> when the gate vetoes, <c>BadInvalidState</c> when this node is not the redundancy
|
||
/// Primary (<see cref="NativeAlarmAckPrimaryGate"/>, Gitea #597) — in both cases no route, no state mutation.</returns>
|
||
private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
|
||
{
|
||
// Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
|
||
// contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route).
|
||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||
var gate = EvaluateAlarmCommandGate(identity, condition, "Acknowledge");
|
||
if (gate is not null) return gate;
|
||
|
||
// Gitea #597 — PRIMARY GATE, after authorization (a caller who may not ack learns nothing about the
|
||
// node's role). Only the Primary routes a native ack upstream (DriverHostActor applies the same verdict
|
||
// to the route), so on any other node the ack is refused outright rather than applied locally and then
|
||
// reverted by the driver's next transition. Nothing is routed, applied or reported.
|
||
if (NativeAlarmAckPrimaryGate is { } primary && !primary())
|
||
{
|
||
return new ServiceResult(StatusCodes.BadInvalidState,
|
||
new LocalizedText("This server is not the redundancy Primary; acknowledge the alarm on the Primary (the server with the higher ServiceLevel)."));
|
||
}
|
||
|
||
// Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
|
||
// for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
|
||
// (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref).
|
||
// Non-blocking by contract (host wires a fire-and-forget dispatch); safe to call under Lock.
|
||
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
|
||
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
||
Comment: comment?.Text,
|
||
OperatorUser: AlarmOperatorName(identity!)));
|
||
|
||
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
|
||
// condition event (the SDK reports none of its own — Gitea #587).
|
||
return ServiceResult.Good;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The one authorization gate both alarm-command paths share — scripted
|
||
/// (<see cref="HandleAlarmCommand"/>) and native (<see cref="HandleNativeAlarmAck"/>).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Extracted in #520 stage 5 because the two had a copy of the same role check and were about
|
||
/// to acquire a copy of the same ACL check. They must not drift: an ack that the scripted path
|
||
/// refuses and the native path allows is a hole whose shape depends on which kind of alarm the
|
||
/// operator happened to click.
|
||
/// </para>
|
||
/// <para>
|
||
/// With ACLs enforced, the Part 9 <c>Acknowledge</c> / <c>Confirm</c> / shelve calls map onto
|
||
/// their own <see cref="OpcUaOperation"/> members, so a grant CAN distinguish them — unlike the
|
||
/// role gate this replaces, where one <c>AlarmAck</c> role covered all three. Anything not
|
||
/// recognised as confirm or shelve is treated as an acknowledge, which is the most restrictive
|
||
/// reading available: <c>AlarmAcknowledge</c> is the permission an operator authoring "can this
|
||
/// principal act on alarms at all" grants first.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
|
||
/// <param name="condition">The condition the method targets.</param>
|
||
/// <param name="operation">The Part 9 operation name.</param>
|
||
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
||
private ServiceResult? EvaluateAlarmCommandGate(
|
||
RoleCarryingUserIdentity? identity, ConditionState condition, string operation)
|
||
{
|
||
if (AclEnforced)
|
||
{
|
||
return _authorizer!.Authorize(
|
||
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId));
|
||
}
|
||
|
||
// Gitea #563: the activation-time AlarmAck role is honoured only while the key that earned it is
|
||
// still usable — an expired or deployment-disabled key loses it on its open session.
|
||
var keyVeto = _authorizer?.AuthorizeKeyUsable(
|
||
identity, AlarmOperationFor(operation), condition.NodeId);
|
||
if (keyVeto is not null) return keyVeto;
|
||
|
||
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
|
||
// state change and surfaces the status to the client; we never route or mutate.
|
||
return identity is null
|
||
|| !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase)
|
||
? new ServiceResult(StatusCodes.BadUserAccessDenied)
|
||
: null;
|
||
}
|
||
|
||
/// <summary>Maps a Part 9 method name onto the permission it requires.</summary>
|
||
/// <param name="operation">The Part 9 operation name.</param>
|
||
/// <returns>The operation to authorize.</returns>
|
||
internal static OpcUaOperation AlarmOperationFor(string operation) => operation switch
|
||
{
|
||
"Confirm" => OpcUaOperation.AlarmConfirm,
|
||
"OneShotShelve" or "TimedShelve" or "Unshelve" => OpcUaOperation.AlarmShelve,
|
||
_ => OpcUaOperation.AlarmAcknowledge,
|
||
};
|
||
|
||
/// <summary>
|
||
/// The operator name recorded on an alarm command. Prefers the ACL principal over the SDK display
|
||
/// name, which for a key-bearing client is the static username every key presents.
|
||
/// </summary>
|
||
/// <param name="identity">The authenticated identity.</param>
|
||
/// <returns>The name to record.</returns>
|
||
private static string AlarmOperatorName(RoleCarryingUserIdentity identity) =>
|
||
identity.Principal.Length > 0 ? identity.Principal : identity.DisplayName ?? string.Empty;
|
||
|
||
/// <summary>
|
||
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
|
||
/// <see cref="EnsureVariable"/>. The OPC UA SDK invokes it when a client writes the
|
||
/// node's Value. It resolves the calling principal off the SDK <paramref name="context"/> the
|
||
/// SAME way <see cref="HandleAlarmCommand"/> does, gates on the
|
||
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role + the gateway being wired
|
||
/// (<b>fails closed</b>: a missing identity / missing role ⇒ <c>BadUserAccessDenied</c>; no gateway ⇒
|
||
/// <c>BadNotWritable</c>) via the pure <see cref="EvaluateEquipmentWriteGate"/>, and on pass dispatches
|
||
/// the value through <see cref="NodeWriteGateway"/>.
|
||
/// <para>
|
||
/// The dispatch is FIRE-AND-FORGET: the SDK's <c>CustomNodeManager2.Write</c> holds the node
|
||
/// manager <c>Lock</c> while invoking this handler, so a blocking driver round-trip here would
|
||
/// freeze every address-space operation (reads, subscription notifications, the publish path) for
|
||
/// the duration. The gateway only kicks off the asynchronous route. Returning
|
||
/// <see cref="ServiceResult.Good"/> lets the SDK apply the written value optimistically.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Item A — synchronous structural fail-fast.</b> After the authz gate passes but BEFORE the
|
||
/// optimistic dispatch, a pure <see cref="EvaluateEquipmentWriteStructure"/> pre-check rejects a
|
||
/// structurally-invalid write (e.g. a <c>null</c> payload, or a confidently-detected built-in-type
|
||
/// mismatch) INLINE — returning Bad synchronously so the SDK never applies it, avoiding the
|
||
/// optimistic-Good-then-revert round-trip + a pointless device dispatch.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Write-outcome self-correction.</b> Before returning Good (which makes the SDK overwrite the
|
||
/// node with <paramref name="value"/>) we capture both the optimistic value AND the node's REAL
|
||
/// prior value/status — at handler entry the node still holds the prior value — plus the writing
|
||
/// principal's user-id (threaded to the audit event). An off-Lock continuation on the
|
||
/// <see cref="NodeWriteOutcome"/> then, on a FAILED outcome and ONLY while the node still holds the
|
||
/// optimistic value (so a fresh driver poll that already republished the confirmed register value is
|
||
/// not clobbered): surfaces a transient <b>Bad-quality blip</b> (Item B), reverts the node to its
|
||
/// prior value/status, and raises a Part 8 <b>AuditWriteUpdateEvent</b> (Item C) recording the
|
||
/// rejected write (<see cref="RevertOptimisticWriteIfNeeded"/> / <see cref="ShouldRevert"/>). On
|
||
/// success the optimistic value stands and the next poll re-confirms it via the normal
|
||
/// <see cref="WriteValue"/> path.
|
||
/// </para>
|
||
/// </summary>
|
||
private ServiceResult OnEquipmentTagWrite(
|
||
ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
|
||
ref object value, ref StatusCode statusCode, ref DateTime timestamp)
|
||
{
|
||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||
var gateway = _nodeWriteGateway;
|
||
var gatewayWired = gateway is not NullOpcUaNodeWriteGateway;
|
||
|
||
// #520 stage 5. With ACLs enforced the scoped decision REPLACES the server-wide WriteOperate role
|
||
// check — see AclOptions for why chaining them would leave every narrow grant inert. The
|
||
// gateway-wired check applies either way: it is an availability fact, not an authorization one.
|
||
// With the flag off, Gitea #563: the activation-time WriteOperate role is honoured only while the
|
||
// session's key is still usable — checked first, so an expired key reads as denied rather than as
|
||
// "writes unavailable" on a node with no gateway.
|
||
var gate = AclEnforced
|
||
? _authorizer!.Authorize(identity, OpcUaOperation.WriteOperate, node.NodeId, RealmOf(node.NodeId))
|
||
?? (gatewayWired ? null : new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable"))
|
||
: _authorizer?.AuthorizeKeyUsable(identity, OpcUaOperation.WriteOperate, node.NodeId)
|
||
?? EvaluateEquipmentWriteGate(identity, gatewayWired);
|
||
if (gate is not null) return gate;
|
||
|
||
// Item A (synchronous structural fail-fast): reject a structurally-invalid write INLINE — return Bad
|
||
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
|
||
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
|
||
// caller) but BEFORE the optimistic dispatch below.
|
||
// A numeric array of another element type is converted (checked) to the node's element type here and
|
||
// the converted array replaces `value`, so the SDK stores — and the driver receives — the node's own type.
|
||
var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId));
|
||
var range = EvaluateEquipmentWriteRange(indexRange);
|
||
if (range is not null) return range;
|
||
var structure = EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar, out var coerced);
|
||
if (structure is not null) return structure;
|
||
value = coerced!;
|
||
|
||
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
|
||
// (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
|
||
var optimisticValue = value;
|
||
// v3 dual-namespace: route by the FULL (namespace-qualified) NodeId string so a Raw write and a UNS
|
||
// write to nodes that share a bare id are distinguishable downstream — WP3's write gateway keys its
|
||
// (DriverInstanceId, RawPath) resolution on this ns-qualified id. Revert keys off the bare id + realm.
|
||
var nodeKey = node.NodeId.ToString();
|
||
var nodeRealm = RealmOf(node.NodeId);
|
||
var nodeBareId = node.NodeId.Identifier?.ToString() ?? string.Empty;
|
||
object? priorValue = null;
|
||
StatusCode priorStatus = StatusCodes.Good;
|
||
if (node is BaseDataVariableState variable)
|
||
{
|
||
priorValue = variable.Value;
|
||
priorStatus = variable.StatusCode;
|
||
}
|
||
// Item C: thread the writing principal's user-id string into the failure continuation so the audit
|
||
// event can populate ClientUserId. Resolved here off the same identity the gate used (null when the
|
||
// session is anonymous / carries no role-carrying identity — the gate would already have vetoed, so in
|
||
// practice non-null on this path, but kept defensive).
|
||
// Prefer the ACL principal: for a key-bearing client DisplayName is the static username every key
|
||
// presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart.
|
||
var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName;
|
||
|
||
// Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock
|
||
// continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never
|
||
// sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the
|
||
// revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed
|
||
// task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never
|
||
// re-enters lock (Lock) while CustomNodeManager2.Write still holds it.
|
||
_ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None)
|
||
.ContinueWith(
|
||
t =>
|
||
{
|
||
var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted");
|
||
RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm);
|
||
},
|
||
CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
|
||
|
||
return ServiceResult.Good;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pure role + availability gate for an inbound equipment-tag write, extracted off
|
||
/// <see cref="OnEquipmentTagWrite"/> so it is unit-testable without booting an SDK server. Fails closed:
|
||
/// a null identity or an identity missing the <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role ⇒
|
||
/// <c>BadUserAccessDenied</c>. When the gate passes but no real gateway is wired
|
||
/// (<paramref name="gatewayWired"/> is false) ⇒ <c>BadNotWritable</c> ("writes unavailable"). A
|
||
/// <c>null</c> return means "proceed" (the caller dispatches + returns Good). Role comparison is
|
||
/// case-insensitive (the role set is built with <see cref="StringComparer.OrdinalIgnoreCase"/>),
|
||
/// matching the alarm gate.
|
||
/// </summary>
|
||
/// <param name="identity">The role-carrying identity extracted off the SDK context, or null when the
|
||
/// session is anonymous / carries no role-carrying identity.</param>
|
||
/// <param name="gatewayWired">True when a non-Null <see cref="IOpcUaNodeWriteGateway"/> is wired; false
|
||
/// for the Null default (no route — e.g. admin-only nodes / pre-boot).</param>
|
||
/// <returns><c>null</c> to proceed (gate passed); otherwise the veto <see cref="ServiceResult"/>
|
||
/// (<c>BadUserAccessDenied</c> on a failed role gate, <c>BadNotWritable</c> when no gateway is wired).</returns>
|
||
internal static ServiceResult? EvaluateEquipmentWriteGate(RoleCarryingUserIdentity? identity, bool gatewayWired)
|
||
{
|
||
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.WriteOperate, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
|
||
// write and surfaces the status to the client; we never route.
|
||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||
}
|
||
|
||
if (!gatewayWired)
|
||
{
|
||
// Gate passed but no gateway wired (admin-only nodes / pre-boot) ⇒ writes unavailable.
|
||
return new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable");
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// <b>Item A — synchronous structural fail-fast.</b> Pure structural pre-check for an inbound
|
||
/// equipment-tag write, run AFTER <see cref="EvaluateEquipmentWriteGate"/> passes but BEFORE the
|
||
/// optimistic device dispatch in <see cref="OnEquipmentTagWrite"/>. A structurally-invalid write is
|
||
/// rejected INLINE (a Bad <see cref="ServiceResult"/> is returned synchronously, so the SDK never
|
||
/// applies the value) instead of being optimistically applied and later reverted — saving both the
|
||
/// phantom value-blip and a pointless device round-trip.
|
||
/// <para>
|
||
/// <b>Interpretation / tradeoff (the item was deliberately under-specified).</b> The minimum
|
||
/// sensible structural check is a <c>null</c> value write to a value variable ⇒
|
||
/// <c>BadTypeMismatch</c> (a value node always holds a typed scalar/array; a null payload is never a
|
||
/// valid value write here). On top of that, when the node is a <see cref="BaseDataVariableState"/>
|
||
/// whose <see cref="BaseVariableState.DataType"/> resolves to a concrete built-in type AND the
|
||
/// written value's runtime built-in type is also resolvable, a CHEAP built-in-type compatibility
|
||
/// check is applied: a clear mismatch ⇒ <c>BadTypeMismatch</c>. The check is intentionally
|
||
/// conservative — it only rejects when BOTH the expected and actual built-in types are confidently
|
||
/// resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
|
||
/// allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
|
||
/// <see cref="Variant"/> payload whose type isn't cheaply known) is allowed through.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>⚠️ This is the ONLY type check a writable equipment node gets</b> (Gitea #610 review). The SDK's
|
||
/// <c>BaseVariableState.WriteValueAttribute</c> runs <c>TypeInfo.IsInstanceOfDataType</c> only when
|
||
/// the node has NO <c>OnWriteValue</c> handler; with one (every writable equipment node) it checks the
|
||
/// access level, calls the handler and stores the value as given. So: an ARRAY write is matched
|
||
/// by shape and declared length, and its element type must either match the node's DataType or be
|
||
/// CONVERTIBLE to it: a numeric array of another numeric element type is converted element-wise, CHECKED,
|
||
/// into the node's element type (<see cref="TryConvertNumericArray"/>) — refused whole if any element is
|
||
/// out of range, fractional for an integer node, or NaN/±Infinity for an integer node — and every other
|
||
/// mismatch is refused. An array value to a scalar node is refused only when
|
||
/// <paramref name="refuseArrayOnScalar"/> says the owning driver cannot take one. A SCALAR write keeps
|
||
/// the looser numeric rule above on purpose (see <see cref="IsBuiltInTypeCompatible"/>).
|
||
/// </para>
|
||
/// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
|
||
/// <c>DataType</c>, so it is unit-testable in isolation.
|
||
/// </summary>
|
||
/// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param>
|
||
/// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param>
|
||
/// <param name="refuseArrayOnScalar">True when the node's owning driver cannot write an array value to a
|
||
/// scalar tag (Modbus / S7): an array value to a scalar node is then refused. False keeps the pre-#610
|
||
/// behaviour for that case (dispatch; the driver — e.g. OpcUaClient's pass-through — decides).</param>
|
||
/// <param name="coerced">On a <c>null</c> return, the value to store and dispatch: <paramref name="value"/>
|
||
/// itself, or for a numeric array of another element type, the checked conversion to the node's element type.
|
||
/// <c>null</c> when the write is refused.</param>
|
||
/// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/>
|
||
/// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns>
|
||
internal static ServiceResult? EvaluateEquipmentWriteStructure(
|
||
object? value, NodeState node, bool refuseArrayOnScalar, out object? coerced)
|
||
{
|
||
coerced = null;
|
||
var result = EvaluateEquipmentWriteStructureCore(value, node, refuseArrayOnScalar, ref coerced);
|
||
if (result is not null) coerced = null;
|
||
else coerced ??= value;
|
||
return result;
|
||
}
|
||
|
||
/// <summary>The verdict half of <see cref="EvaluateEquipmentWriteStructure(object?, NodeState, bool, out object?)"/>
|
||
/// for callers that only need the decision; the array-on-scalar refusal is applied as for a Modbus / S7 node.
|
||
/// Production goes through the four-argument overload, which also returns the converted value.</summary>
|
||
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node) =>
|
||
EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar: true, out _);
|
||
|
||
private static ServiceResult? EvaluateEquipmentWriteStructureCore(
|
||
object? value, NodeState node, bool refuseArrayOnScalar, ref object? coerced)
|
||
{
|
||
// Minimum sensible check: a null payload is never a valid value write to a value variable.
|
||
if (value is null)
|
||
{
|
||
return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
|
||
}
|
||
|
||
// Gitea #610 — an array node (writable only when its driver encodes whole-array writes) takes a whole
|
||
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
|
||
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
|
||
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
|
||
var isArrayNode = false;
|
||
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
|
||
{
|
||
isArrayNode = true;
|
||
if (value is not Array { Rank: 1 } array)
|
||
{
|
||
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
|
||
}
|
||
var declared = arrayNode.ArrayDimensions is { Count: > 0 } dims ? dims[0] : 0u;
|
||
if (declared > 0 && array.Length != declared)
|
||
{
|
||
return new ServiceResult(
|
||
StatusCodes.BadTypeMismatch,
|
||
$"array write of {array.Length} element(s) to a {declared}-element array node");
|
||
}
|
||
}
|
||
|
||
// Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
|
||
// with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
|
||
// resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
|
||
if (node is not BaseDataVariableState variable) return null;
|
||
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
|
||
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
|
||
|
||
// Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused
|
||
// up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted — but
|
||
// ONLY for a driver whose scalar encoder cannot take one (R6 MINOR-1). A browse-committed array attribute
|
||
// lands as a SCALAR tag (the /raw commit never sets isArray) whose driver publishes an array into it, and
|
||
// writing that array back worked before #610 on the pass-through drivers (OpcUaClient forwards the
|
||
// Variant; Galaxy encodes it), so those keep dispatching and the driver decides. A byte[] is exempt: on
|
||
// the wire it is a ByteString, which is a scalar (the type check below still decides whether the node
|
||
// takes one). An unresolved-DataType node deferred above, as it always has.
|
||
if (refuseArrayOnScalar && variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
|
||
{
|
||
return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
|
||
}
|
||
|
||
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
|
||
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
|
||
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
|
||
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
|
||
if (!IsCheckableExpectedType(expected)) return null;
|
||
|
||
// TypeInfo.Construct(object) classifies the runtime payload (an array by its ELEMENT type); an
|
||
// unclassifiable value ⇒ Unknown (Null).
|
||
var actual = TypeInfo.Construct(value).BuiltInType;
|
||
|
||
// Gitea #610 review (MAJOR-1, R6) — an ARRAY write's element type is checked against the DataType here,
|
||
// because the SDK runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue
|
||
// handler, and every writable equipment node has one: nothing else stands between a mistyped array and the
|
||
// driver. Unchecked, a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array
|
||
// covariance and wrote 4,000,000,000 bit-reinterpreted with a Good result, and a Modbus/S7 node kept
|
||
// serving an int[] under an Int16 DataType.
|
||
// • A matching element type (or the Byte-array/ByteString equivalence — a Byte[] decodes as byte[], which
|
||
// classifies as ByteString) proceeds unchanged.
|
||
// • A NUMERIC array of another numeric element type is CONVERTED, checked, into the node's element type,
|
||
// and the converted array is what the SDK stores and the driver receives. This is not optional: the
|
||
// consumer #610 exists for, ScadaBridge, writes the CLR list it decoded from a List attribute, and its
|
||
// element types are only Boolean/Int32/Float/Double/String/DateTime — an exact-match rule left every
|
||
// Int16/UInt16/Byte/UInt32/Int64/UInt64 array node (Modbus's default Int16 included) and a Double list
|
||
// to a Float array permanently unwritable from it. Any element out of the target's range, fractional
|
||
// for an integer target, or NaN/±Infinity for an integer target refuses the WHOLE write — so the
|
||
// UInt32 → Int32 reinterpretation stays closed (4,000,000,000 overflows; in-range values convert).
|
||
// • Anything else — a non-numeric mismatch, or an unclassifiable element type (Variant[] / object[]) — is
|
||
// refused rather than deferred.
|
||
if (isArrayNode)
|
||
{
|
||
if (actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)) return null;
|
||
if (IsNumeric(expected) && IsNumeric(actual) && value is Array source)
|
||
{
|
||
if (TryConvertNumericArray(source, expected, out var converted))
|
||
{
|
||
coerced = converted;
|
||
return null;
|
||
}
|
||
return new ServiceResult(
|
||
StatusCodes.BadTypeMismatch,
|
||
$"array element of type {actual} is not exactly representable as node data type {expected}");
|
||
}
|
||
return new ServiceResult(
|
||
StatusCodes.BadTypeMismatch,
|
||
$"array element type {actual} does not match node data type {expected}");
|
||
}
|
||
|
||
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
|
||
|
||
if (!IsBuiltInTypeCompatible(expected, actual))
|
||
{
|
||
return new ServiceResult(
|
||
StatusCodes.BadTypeMismatch,
|
||
$"value built-in type {actual} is not compatible with node data type {expected}");
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #610 — an equipment-tag write must be a WHOLE-value write. A partial (IndexRange) write to an
|
||
/// array node is refused: every array-capable driver writes the whole block, so honouring a range would
|
||
/// mean a read-modify-write of the device array the server does not hold authoritatively (the node value
|
||
/// can be stale or optimistic), and the SDK would apply the range to the node before the device answered.
|
||
/// A range on a scalar node is meaningless and refused the same way. Pure + static.
|
||
/// </summary>
|
||
/// <param name="indexRange">The write's parsed IndexRange (<see cref="NumericRange.Empty"/> when absent).</param>
|
||
/// <returns><c>null</c> to proceed; otherwise <c>BadWriteNotSupported</c>.</returns>
|
||
internal static ServiceResult? EvaluateEquipmentWriteRange(NumericRange indexRange) =>
|
||
indexRange == NumericRange.Empty
|
||
? null
|
||
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
|
||
|
||
/// <summary>
|
||
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>'s
|
||
/// SCALAR path (array writes are matched exactly — see there). Returns true (compatible — allow through)
|
||
/// unless there is a confident mismatch. Exact matches pass; numeric-to-numeric is treated as compatible —
|
||
/// deliberately looser than the SDK, which would refuse it: consumers (ScadaBridge) write CLR-typed values
|
||
/// such as a Double to a Float node, and every driver's SCALAR encoder converts with a range check
|
||
/// (<c>Convert.ToInt16</c>, MxValueEncoder's <c>uint ≤ int.MaxValue</c> arm, …), so an out-of-range value
|
||
/// fails the device write and is reverted rather than silently reinterpreted; a
|
||
/// <see cref="BuiltInType.ByteString"/>↔<see cref="BuiltInType.Byte"/> array nuance and any
|
||
/// <see cref="BuiltInType.Variant"/> on either side are treated as compatible. Only a clear cross-family
|
||
/// mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
|
||
/// </summary>
|
||
private static bool IsBuiltInTypeCompatible(BuiltInType expected, BuiltInType actual)
|
||
{
|
||
if (expected == actual) return true;
|
||
// Any side a wildcard/unclassified ⇒ defer (compatible) — the caller already filtered most of these.
|
||
if (expected is BuiltInType.Variant or BuiltInType.Null) return true;
|
||
if (actual is BuiltInType.Variant or BuiltInType.Null) return true;
|
||
// Numeric family widening/narrowing is the SDK's job; treat numeric↔numeric as compatible.
|
||
if (IsNumeric(expected) && IsNumeric(actual)) return true;
|
||
// ByteString and Byte are routinely interchangeable on the wire; don't reject that pairing.
|
||
if ((expected, actual) is (BuiltInType.ByteString, BuiltInType.Byte) or (BuiltInType.Byte, BuiltInType.ByteString)) return true;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>True for the OPC UA numeric built-in types (the integer + floating families).</summary>
|
||
private static bool IsNumeric(BuiltInType t) => t is
|
||
BuiltInType.SByte or BuiltInType.Byte or BuiltInType.Int16 or BuiltInType.UInt16 or
|
||
BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
|
||
BuiltInType.Float or BuiltInType.Double;
|
||
|
||
/// <summary>
|
||
/// #610 review R6 — CHECKED element-wise conversion of a numeric array to the node's numeric element type.
|
||
/// Fails (nothing converted) when ANY element is out of the target's range, is fractional or NaN/±Infinity
|
||
/// for an integer target, or is finite yet overflows to ±Infinity for a <see cref="BuiltInType.Float"/>
|
||
/// target. Precision loss inside the target's range (a Double to a Float, a large Int64 to a Double) is
|
||
/// accepted, as the scalar path's drivers accept it. Pure + static.
|
||
/// </summary>
|
||
/// <param name="source">The 1-D numeric array the client wrote.</param>
|
||
/// <param name="target">The node's numeric element type.</param>
|
||
/// <param name="converted">The converted array (element CLR type of <paramref name="target"/>), or null.</param>
|
||
/// <returns>True when every element converted exactly enough to be written.</returns>
|
||
internal static bool TryConvertNumericArray(Array source, BuiltInType target, out Array? converted)
|
||
{
|
||
converted = null;
|
||
var elementType = target switch
|
||
{
|
||
BuiltInType.SByte => typeof(sbyte), BuiltInType.Byte => typeof(byte),
|
||
BuiltInType.Int16 => typeof(short), BuiltInType.UInt16 => typeof(ushort),
|
||
BuiltInType.Int32 => typeof(int), BuiltInType.UInt32 => typeof(uint),
|
||
BuiltInType.Int64 => typeof(long), BuiltInType.UInt64 => typeof(ulong),
|
||
BuiltInType.Float => typeof(float), BuiltInType.Double => typeof(double),
|
||
_ => null,
|
||
};
|
||
if (elementType is null) return false;
|
||
var result = Array.CreateInstance(elementType, source.Length);
|
||
for (var i = 0; i < source.Length; i++)
|
||
{
|
||
if (!TryConvertNumericElement(source.GetValue(i), target, out var element)) return false;
|
||
result.SetValue(element, i);
|
||
}
|
||
converted = result;
|
||
return true;
|
||
}
|
||
|
||
private static bool TryConvertNumericElement(object? element, BuiltInType target, out object? result)
|
||
{
|
||
result = null;
|
||
if (element is null) return false;
|
||
var integerTarget = target is not (BuiltInType.Float or BuiltInType.Double);
|
||
if (element is float or double)
|
||
{
|
||
var d = Convert.ToDouble(element, CultureInfo.InvariantCulture);
|
||
// Convert.ToInt16(1.5) ROUNDS (banker's) rather than failing, so a fractional value is refused here.
|
||
if (integerTarget && (!double.IsFinite(d) || Math.Truncate(d) != d)) return false;
|
||
if (target == BuiltInType.Float && double.IsFinite(d) && float.IsInfinity((float)d)) return false;
|
||
}
|
||
try
|
||
{
|
||
result = target switch
|
||
{
|
||
BuiltInType.SByte => Convert.ToSByte(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.Byte => Convert.ToByte(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.Int16 => Convert.ToInt16(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.UInt16 => Convert.ToUInt16(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.Int32 => Convert.ToInt32(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.UInt32 => Convert.ToUInt32(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.Int64 => Convert.ToInt64(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.UInt64 => Convert.ToUInt64(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.Float => Convert.ToSingle(element, CultureInfo.InvariantCulture),
|
||
BuiltInType.Double => Convert.ToDouble(element, CultureInfo.InvariantCulture),
|
||
_ => null,
|
||
};
|
||
return result is not null;
|
||
}
|
||
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
|
||
/// against — exactly the types <see cref="ResolveBuiltInDataType"/> can emit for a writable equipment node
|
||
/// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
|
||
/// deferred to the SDK so a coercible write (Int32→Enumeration, etc.) is never false-rejected.</summary>
|
||
private static bool IsCheckableExpectedType(BuiltInType t) =>
|
||
IsNumeric(t) || t is BuiltInType.Boolean or BuiltInType.String or BuiltInType.DateTime or BuiltInType.ByteString;
|
||
|
||
/// <summary>
|
||
/// Pure decision for the write-outcome self-correction: revert the node to its pre-write value ONLY on
|
||
/// a FAILED outcome AND only while the node still holds the optimistic value. The
|
||
/// still-holds-the-optimistic-value check is what stops a revert from clobbering a fresh driver poll
|
||
/// that already republished the confirmed register value over the optimistic write. Pure (value
|
||
/// comparison via <see cref="object.Equals(object?, object?)"/>) so it is unit-testable without an SDK
|
||
/// server.
|
||
/// </summary>
|
||
/// <param name="outcome">The device-write outcome routed back by the gateway.</param>
|
||
/// <param name="currentNodeValue">The node's current Value at revert time.</param>
|
||
/// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
|
||
/// <returns><c>true</c> to revert (failed outcome and node unchanged since the optimistic write);
|
||
/// <c>false</c> on success, or when a poll has already moved the node off the optimistic value.</returns>
|
||
internal static bool ShouldRevert(NodeWriteOutcome outcome, object? currentNodeValue, object? optimisticValue) =>
|
||
!outcome.Success && Equals(currentNodeValue, optimisticValue);
|
||
|
||
/// <summary>
|
||
/// Off-Lock continuation body for the write-outcome self-correction: re-takes <c>Lock</c> and, when
|
||
/// <see cref="ShouldRevert"/> says so, surfaces the device-write rejection to subscribed clients in three
|
||
/// ways, then leaves the node holding its captured pre-write value/status (same node-update shape as
|
||
/// <see cref="WriteValue"/>). A no-op when the node was rebuilt/removed in the interim, when the outcome
|
||
/// succeeded, or when a fresh poll already moved the node off the optimistic value.
|
||
/// <list type="number">
|
||
/// <item>
|
||
/// <b>Item B — Bad-quality blip.</b> Before restoring the prior value, the node is published
|
||
/// once holding the (still-applied) optimistic value but with StatusCode
|
||
/// <c>BadDeviceFailure</c>, then published again with the prior value/status restored. A
|
||
/// value-subscribed client therefore sees the rejection (a Bad quality) rather than the value
|
||
/// silently snapping back. <b>Caveat:</b> the two <c>ClearChangeMasks</c> calls happen
|
||
/// back-to-back within one server publishing interval, so the SDK's monitored-item queue may
|
||
/// COALESCE them — a slow / queue-size-1 subscriber can see only the final restored value and
|
||
/// miss the transient Bad blip. The blip is a best-effort live signal; the
|
||
/// <see cref="AuditWriteUpdateEventState"/> raised below (Item C) is the reliable, durable record
|
||
/// of the rejected write.
|
||
/// </item>
|
||
/// <item>
|
||
/// <b>Item C — AuditWriteUpdateEvent.</b> A Part 8 <see cref="AuditWriteUpdateEventState"/> is
|
||
/// raised through the SDK <see cref="CustomNodeManager2.Server"/> so an auditing client gets a
|
||
/// durable record of the rejected write (OldValue = prior, NewValue = the attempted optimistic
|
||
/// value, the boolean AuditEvent Status = false ⇒ failed, and the device's
|
||
/// <paramref name="outcome"/>.Reason carried in the event Message). It is reported
|
||
/// OUTSIDE <c>Lock</c> (the event-state is built under Lock, then reported after release) to keep
|
||
/// the lock hold short and avoid any re-entrancy risk via the server's event path. Auditing being
|
||
/// disabled / no subscribers is handled gracefully — <c>ReportEvent</c> simply reaches no monitored
|
||
/// items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it.
|
||
/// </item>
|
||
/// </list>
|
||
/// Silent value-wise — this node manager carries no logger; the gateway logs the underlying write failure
|
||
/// and the SDK trace captures any audit-report failure.
|
||
/// <para><c>internal</c> (not private) so the behavioural test drives the blip-then-settle continuation
|
||
/// against a booted node manager — see <c>NodeManagerWriteRevertTests</c>.</para>
|
||
/// </summary>
|
||
/// <param name="nodeId">The string id of the written variable node.</param>
|
||
/// <param name="outcome">The device-write outcome routed back by the gateway.</param>
|
||
/// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
|
||
/// <param name="priorValue">The node's real value captured before the optimistic write.</param>
|
||
/// <param name="priorStatus">The node's real status captured before the optimistic write.</param>
|
||
/// <param name="clientUserId">The writing principal's user-id string (the identity's DisplayName), threaded
|
||
/// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
|
||
internal void RevertOptimisticWriteIfNeeded(
|
||
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
|
||
string? clientUserId, AddressSpaceRealm realm)
|
||
{
|
||
// Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
|
||
AuditWriteUpdateEventState? auditEvent = null;
|
||
|
||
var key = MapKey(realm, nodeId);
|
||
lock (Lock)
|
||
{
|
||
if (!_variables.TryGetValue(key, out var variable)) return; // rebuilt/removed ⇒ no-op
|
||
if (!ShouldRevert(outcome, variable.Value, optimisticValue)) return; // success, or poll moved it on
|
||
|
||
// Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore
|
||
// the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's
|
||
// node-update shape). See the method remarks for the queue-coalescing caveat.
|
||
variable.StatusCode = StatusCodes.BadDeviceFailure;
|
||
variable.Timestamp = DateTime.UtcNow;
|
||
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip
|
||
|
||
variable.Value = priorValue;
|
||
variable.StatusCode = priorStatus;
|
||
variable.Timestamp = DateTime.UtcNow;
|
||
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior
|
||
|
||
// Item C: build the audit event-state while we hold the node reference + Lock, but DON'T report yet.
|
||
// Guarded like ReportAuditEvent: the revert above has ALREADY happened, so a surprise from the SDK
|
||
// child-population path (e.g. a SetChildValue on a null OldValue/NewValue) must be swallowed + logged,
|
||
// never thrown out of this fire-and-forget continuation.
|
||
try
|
||
{
|
||
auditEvent = BuildWriteFailureAuditEvent(variable, outcome, optimisticValue, priorValue, clientUserId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: failed to build AuditWriteUpdateEvent for {NodeId}", nodeId);
|
||
auditEvent = null;
|
||
}
|
||
}
|
||
|
||
// Report OUTSIDE Lock — keeps the hold short and sidesteps any re-entrancy through the server event path.
|
||
if (auditEvent is not null) ReportAuditEvent(auditEvent);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Item C — build (but do not report) a Part 8 <see cref="AuditWriteUpdateEventState"/> recording a
|
||
/// rejected device write on <paramref name="variable"/>. The caller holds <c>Lock</c> (the node is read
|
||
/// here); reporting happens after Lock release. The standard AuditEvent envelope (EventId, EventType,
|
||
/// Time/ReceiveTime, ServerId, Severity, Message, SourceNode/SourceName, Status, ActionTimeStamp) is
|
||
/// stamped by the SDK's <see cref="AuditEventState.Initialize(ISystemContext, NodeState, EventSeverity,
|
||
/// LocalizedText, bool, DateTime)"/> helper; this method then fills the write-specific fields
|
||
/// (AttributeId = Value, IndexRange = empty, OldValue = prior, NewValue = attempted) plus ClientUserId.
|
||
/// </summary>
|
||
/// <param name="variable">The written node (the audit event's SourceNode).</param>
|
||
/// <param name="outcome">The failed device-write outcome (its Reason goes into the event Message + Status).</param>
|
||
/// <param name="optimisticValue">The value the client attempted (the audit NewValue).</param>
|
||
/// <param name="priorValue">The node's real pre-write value (the audit OldValue).</param>
|
||
/// <param name="clientUserId">The writing principal's user-id string; null when unknown.</param>
|
||
/// <returns>A populated, unreported <see cref="AuditWriteUpdateEventState"/>.</returns>
|
||
/// <remarks><c>internal</c> (not private) so <c>NodeManagerWriteRevertTests</c> can assert the populated
|
||
/// audit fields at the nearest deterministic seam (the end-to-end <c>Server.ReportEvent</c> dispatch would
|
||
/// need a subscribed event monitored-item to observe).</remarks>
|
||
internal AuditWriteUpdateEventState BuildWriteFailureAuditEvent(
|
||
BaseDataVariableState variable, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue,
|
||
string? clientUserId)
|
||
{
|
||
var reason = string.IsNullOrEmpty(outcome.Reason) ? "device write rejected" : outcome.Reason!;
|
||
|
||
var audit = new AuditWriteUpdateEventState(null);
|
||
// Initialize stamps EventId (fresh GUID bytes), EventType, Time/ReceiveTime, ServerId, Severity, Message,
|
||
// SourceNode/SourceName (from `variable`), Status and ActionTimeStamp. The AuditEvent `Status` field is a
|
||
// PropertyState<bool> (true=succeeded / false=failed), so the failed device write is recorded as `false`;
|
||
// the device's textual Reason is carried in the Message (the StatusCode itself has no audit-field home).
|
||
audit.Initialize(
|
||
SystemContext,
|
||
source: variable,
|
||
severity: EventSeverity.Medium,
|
||
message: new LocalizedText($"Inbound write rejected by device: {reason}"),
|
||
status: false,
|
||
actionTimestamp: DateTime.UtcNow);
|
||
|
||
// Write-specific fields (AuditWriteUpdateEventType). AttributeId = Value; IndexRange empty (full write);
|
||
// OldValue = the real pre-write value; NewValue = the value the client attempted. The AuditWriteUpdate
|
||
// child PropertyStates are NOT instantiated by Initialize — SetChildValue lazily creates + sets them
|
||
// (verified against the 1.5.378 SDK; it tolerates a null value, creating the child with Value=null).
|
||
audit.SetChildValue(SystemContext, BrowseNames.AttributeId, (uint)Attributes.Value, false);
|
||
audit.SetChildValue(SystemContext, BrowseNames.IndexRange, string.Empty, false);
|
||
audit.SetChildValue(SystemContext, BrowseNames.OldValue, priorValue!, false);
|
||
audit.SetChildValue(SystemContext, BrowseNames.NewValue, optimisticValue!, false);
|
||
|
||
// Standard AuditEvent client-identity fields. ClientUserId is the writing principal (threaded from the
|
||
// handler); ClientAuditEntryId carries the SDK context's audit entry id when present.
|
||
if (clientUserId is not null) audit.SetChildValue(SystemContext, BrowseNames.ClientUserId, clientUserId, false);
|
||
var auditEntryId = SystemContext.AuditEntryId;
|
||
if (!string.IsNullOrEmpty(auditEntryId))
|
||
audit.SetChildValue(SystemContext, BrowseNames.ClientAuditEntryId, auditEntryId, false);
|
||
|
||
return audit;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Item C — report a built audit event through the SDK server, guarding against auditing being disabled
|
||
/// / no subscribers / a transient server-event-path failure. A failure here MUST NOT break the revert
|
||
/// that already happened, so it is swallowed and logged to the SDK trace (this node manager has no
|
||
/// <c>ILogger</c>) rather than propagated. Reported with the node manager's <c>SystemContext</c>, which
|
||
/// is what the alarm event path uses too.
|
||
/// </summary>
|
||
/// <param name="auditEvent">The populated audit event-state to report.</param>
|
||
private void ReportAuditEvent(AuditWriteUpdateEventState auditEvent)
|
||
{
|
||
try
|
||
{
|
||
ReportToServerObject(auditEvent);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Auditing disabled / no monitored items / server shutting down ⇒ ReportEvent may no-op or throw;
|
||
// either way the revert already stands. Surface a recurring failure in the SDK trace, don't rethrow.
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report AuditWriteUpdateEvent for {NodeId}", auditEvent.SourceNode?.Value);
|
||
}
|
||
}
|
||
|
||
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
|
||
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
|
||
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
|
||
private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
|
||
{
|
||
"OffNormalAlarm" => new OffNormalAlarmState(parent),
|
||
"DiscreteAlarm" => new DiscreteAlarmState(parent),
|
||
// "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
|
||
// to populate, so the limit subtypes would carry unset High/Low children.
|
||
_ => new AlarmConditionState(parent),
|
||
};
|
||
|
||
/// <summary>
|
||
/// Gitea #629 — the <c>AlarmConditionType_ShelvingState_*</c> method instance declarations a client calls
|
||
/// the shelve methods by. <see cref="New"/> records them as the methods' <c>MethodDeclarationId</c>
|
||
/// (the SDK's own is the <c>ShelvedStateMachineType</c>'s), so calls by these ids keep resolving after
|
||
/// the methods move to instance-scoped NodeIds. Calls by the <c>ShelvedStateMachineType_*</c> ids are
|
||
/// not supported (pinned by <c>ConditionChildNodeIdTests</c>).
|
||
/// </summary>
|
||
private static readonly HashSet<NodeId> ShelvingMethodDeclarationIds =
|
||
[
|
||
MethodIds.AlarmConditionType_ShelvingState_TimedShelve,
|
||
MethodIds.AlarmConditionType_ShelvingState_OneShotShelve,
|
||
MethodIds.AlarmConditionType_ShelvingState_Unshelve,
|
||
];
|
||
|
||
/// <summary>
|
||
/// Gitea #629 — the <see cref="INodeIdFactory"/> hook <c>NodeState.Create(…, assignNodeIds: true)</c> calls
|
||
/// for the node being created and then, top-down, for every child it built from the type's definition.
|
||
/// A node that already carries an id in one of our namespaces (the condition root, whose id
|
||
/// <see cref="MaterialiseAlarmCondition"/> passes to <c>Create</c>) keeps it; a child is given
|
||
/// <c>{parent id}/{BrowseName}</c> in its parent's namespace.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The SDK default (<c>CustomNodeManager2.New</c>) returns the node's CURRENT id, which for a child is
|
||
/// the condition type's instance-declaration id (<c>i=9189</c> for <c>ShelvingState/UnshelveTime</c>) or
|
||
/// an SDK-generated ns=0 number. Every condition's children therefore sat in ns=0: a
|
||
/// TranslateBrowsePathsToNodeIds of any child path returned <c>BadNoMatch</c>, and a Read of a browsed
|
||
/// child id either hit <c>BadNodeIdUnknown</c> or was served by the core node manager from the TYPE
|
||
/// (UnshelveTime read <c>0</c> on a condition timed-shelved for a minute). Event delivery and the Part 9
|
||
/// methods were unaffected — events copy the instance's fields and the SDK resolves a method by its
|
||
/// declaration id.
|
||
/// </para>
|
||
/// <para>
|
||
/// The derived id is deterministic and unique per condition: the parent part is the condition's own
|
||
/// id (a RawPath in the raw namespace, a ScriptedAlarmId in the UNS namespace) and sibling BrowseNames
|
||
/// are unique. It cannot collide with a raw node — a condition's RawPath is a tag, a leaf, so nothing
|
||
/// else is ever authored beneath it — and the namespace keeps the two realms apart. It is also
|
||
/// slash-joined on purpose: <c>DataPlaneAuthorizer.BuildScope</c> splits the identifier on <c>/</c>,
|
||
/// so a child's scope is its condition's scope plus the browse path, and the permission trie's
|
||
/// downward inheritance governs a child exactly as it governs its condition.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="context">The system context of the create.</param>
|
||
/// <param name="node">The node being assigned an id.</param>
|
||
/// <returns>The id the node is to carry.</returns>
|
||
public override NodeId New(ISystemContext context, NodeState node)
|
||
{
|
||
if (OwnsNamespace(node.NodeId)) return node.NodeId;
|
||
if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId }
|
||
&& OwnsNamespace(parentId)
|
||
&& !QualifiedName.IsNull(node.BrowseName))
|
||
{
|
||
// A client calls a Part 9 method by its TYPE's method id (ObjectId = the condition, MethodId =
|
||
// e.g. AlarmConditionType_ShelvingState_OneShotShelve, i=9212), and the SDK resolves it by NodeId OR
|
||
// MethodDeclarationId. The condition's own methods (Acknowledge, …) carry the right declaration id
|
||
// already, over an SDK-generated ns=0 NodeId. The ShelvingState methods do NOT: their declaration id
|
||
// is the ShelvedStateMachineType's (i=2948), and they were found only because their NodeId WAS the
|
||
// AlarmConditionType instance declaration every client uses. Adopt that id as the declaration before
|
||
// it is replaced, or every shelve/unshelve call fails BadMethodInvalid. A fixed set, deliberately: a
|
||
// lookup in the standard address space would take other node managers' locks while we hold Lock.
|
||
if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId))
|
||
method.MethodDeclarationId = method.NodeId;
|
||
|
||
return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex);
|
||
}
|
||
return node.NodeId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #629 — drops a removed condition AND every descendant from <c>PredefinedNodes</c>.
|
||
/// <c>AddPredefinedNode</c> indexes the whole subtree, so removing only the root would leave the
|
||
/// condition's (now instance-scoped, hence reachable) children answering a direct Read with the dead
|
||
/// condition's last value. MUST be called under <c>Lock</c>.
|
||
/// </summary>
|
||
/// <param name="node">The node whose subtree is forgotten.</param>
|
||
private void ForgetPredefinedSubtree(NodeState node)
|
||
{
|
||
PredefinedNodes?.Remove(node.NodeId);
|
||
var children = new List<BaseInstanceState>();
|
||
node.GetChildren(SystemContext, children);
|
||
foreach (var child in children) ForgetPredefinedSubtree(child);
|
||
}
|
||
|
||
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
|
||
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
|
||
/// folder is a subscribable event source linked under the Server object (Server-object delivery itself is
|
||
/// the condition's own — see OnConditionReportEvent, Gitea #586).</summary>
|
||
/// <remarks>
|
||
/// Phase C: when a real historian is wired at promotion time (the source is NOT the
|
||
/// <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
|
||
/// <see cref="_notifierFolders"/> block so the idempotency guard covers them too.
|
||
/// </remarks>
|
||
private void EnsureFolderIsEventNotifier(FolderState folder)
|
||
{
|
||
if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
|
||
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||
if (_historianDataSource is not NullHistorianDataSource)
|
||
{
|
||
// A historian is wired: advertise event history on this notifier and register it as a source.
|
||
// The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName.
|
||
folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead);
|
||
// Key by the realm-qualified RealmKey built from the NodeId so a Raw and a UNS folder that share
|
||
// the same bare id are distinct sources; the VALUE stays the bare id — that is the historian
|
||
// ReadEventsAsync sourceName (== the equipment id).
|
||
var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty;
|
||
_eventNotifierSources[MapKey(folder.NodeId)] = sourceName;
|
||
}
|
||
AddRootNotifier(folder);
|
||
folder.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The Part 9 <c>Severity</c> (1..1000) a condition carries for a domain severity, which is already on
|
||
/// that scale: the value itself, clamped. <c>SetSeverity</c> takes an <see cref="EventSeverity"/>, but
|
||
/// that enum merely names five points on the 1..1000 scale — any value in range is a valid Severity.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// This used to re-bucket onto those five points (<c>< 800</c> ⇒ 700, else 900), so the condition
|
||
/// node was the one seam that disagreed with every other: an MTConnect Fault (#626's exact 800) read and
|
||
/// evented as 900, a scripted alarm the engine graded 1000 published 900 on the node while its
|
||
/// <c>/alerts</c> row and historian event said 1000, and a bucket-only native driver's Low (200) became 300.
|
||
/// </remarks>
|
||
/// <param name="severity">The domain severity.</param>
|
||
/// <returns>The severity the node carries.</returns>
|
||
private static EventSeverity ToPart9Severity(int severity) => (EventSeverity)Math.Clamp(severity, 1, 1000);
|
||
|
||
/// <summary>
|
||
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
|
||
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
|
||
/// Used by <see cref="AddressSpaceApplier"/> to materialise the UNS Area/Line/Equipment
|
||
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
|
||
/// folder so adding child variables under it still works.
|
||
/// </summary>
|
||
/// <param name="folderNodeId">The node identifier of the folder.</param>
|
||
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
|
||
/// <param name="displayName">The display name of the folder.</param>
|
||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
|
||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var ns = NamespaceIndexForRealm(realm);
|
||
var key = MapKey(realm, folderNodeId);
|
||
if (_folders.ContainsKey(key)) return;
|
||
|
||
lock (Lock)
|
||
{
|
||
if (_folders.ContainsKey(key)) return;
|
||
|
||
var parent = ResolveParentFolder(parentNodeId, realm);
|
||
var folder = new FolderState(parent)
|
||
{
|
||
NodeId = new NodeId(folderNodeId, ns),
|
||
BrowseName = new QualifiedName(folderNodeId, ns),
|
||
DisplayName = displayName,
|
||
EventNotifier = EventNotifiers.None,
|
||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||
};
|
||
parent.AddChild(folder);
|
||
AddPredefinedNode(SystemContext, folder);
|
||
_folders[key] = folder;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Update an EXISTING folder node's <see cref="FolderState.DisplayName"/> in place and notify
|
||
/// subscribers, WITHOUT a rebuild — the in-place counterpart of <see cref="EnsureFolder"/> for a
|
||
/// UNS Area / Line rename. <see cref="EnsureFolder"/> early-returns for an
|
||
/// already-present folder id and never touches an existing folder's display name, so a
|
||
/// rename-only deploy would otherwise be invisible until a full <see cref="RebuildAddressSpace"/>
|
||
/// cleared <c>_folders</c>. The NodeId + BrowseName are unchanged (a rename only touches the
|
||
/// friendly display name, not the logical id, so browse-path resolution + ACLs are unaffected).
|
||
/// <c>ClearChangeMasks</c> is called so subscribed clients see the new DisplayName immediately,
|
||
/// mirroring the <see cref="UpdateTagAttributes"/> surgical path. Returns false when the folder id
|
||
/// is unknown (caller falls back to a full rebuild).
|
||
/// </summary>
|
||
/// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
|
||
/// <param name="displayName">The new display name to apply.</param>
|
||
/// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
|
||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
|
||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||
lock (Lock)
|
||
{
|
||
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return false;
|
||
folder.DisplayName = displayName;
|
||
folder.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
|
||
/// <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
|
||
/// timestamp=epoch — <see cref="WriteValue"/> fills these in once driver data flows.
|
||
/// Idempotent. Materialises equipment-namespace tags so they're browseable before drivers
|
||
/// issue SubscribeBulk. Note: because of the early <c>_variables.ContainsKey</c> return, a
|
||
/// re-apply of an EXISTING node with a changed historize intent (e.g. non-historized →
|
||
/// historized) is silently ignored — a historize-intent change only takes effect after a
|
||
/// <see cref="RebuildAddressSpace"/> (which the planner triggers on an equipment-tag delta).
|
||
/// </summary>
|
||
/// <param name="variableNodeId">The node identifier of the variable.</param>
|
||
/// <param name="parentFolderNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
|
||
/// <param name="displayName">The display name of the variable.</param>
|
||
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
|
||
/// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
|
||
/// ReadWrite equipment tag) and the inbound-write handler <see cref="OnEquipmentTagWrite"/> is attached
|
||
/// to its <c>OnWriteValue</c> so a client write gates on the <c>WriteOperate</c> role + routes
|
||
/// to the backing driver; when false it stays <c>CurrentRead</c> (read-only) with no write handler.</param>
|
||
/// <param name="historianTagname">Phase C: null ⇒ the node is NOT historized (Historizing=false, no
|
||
/// HistoryRead bit, not registered). Non-null ⇒ the node is created <c>Historizing</c> with the
|
||
/// <c>HistoryRead</c> access bit OR-ed into both <c>AccessLevel</c> and <c>UserAccessLevel</c>, and the
|
||
/// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override
|
||
/// resolves against.</param>
|
||
/// <param name="isArray">Phase 4c: when true the node is materialised as a 1-D array
|
||
/// (<c>ValueRank=OneDimension</c> with <c>ArrayDimensions=[arrayLength]</c>); when false (default) it
|
||
/// stays scalar (<c>ValueRank=Scalar</c>). Array elements share the scalar's base
|
||
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
|
||
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
|
||
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
|
||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
|
||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var ns = NamespaceIndexForRealm(realm);
|
||
var key = MapKey(realm, variableNodeId);
|
||
// If already present, leave it alone (idempotent re-applies).
|
||
if (_variables.ContainsKey(key)) return;
|
||
|
||
lock (Lock)
|
||
{
|
||
if (_variables.ContainsKey(key)) return;
|
||
|
||
var parent = ResolveParentFolder(parentFolderNodeId, realm);
|
||
// Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead
|
||
// access bit (on top of the writable composite) so clients can browse + HistoryRead it.
|
||
var historized = historianTagname is not null;
|
||
// The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
|
||
// CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized.
|
||
var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null);
|
||
var variable = new BaseDataVariableState(parent)
|
||
{
|
||
NodeId = new NodeId(variableNodeId, ns),
|
||
BrowseName = new QualifiedName(variableNodeId, ns),
|
||
DisplayName = displayName,
|
||
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||
DataType = ResolveBuiltInDataType(dataType),
|
||
ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
||
ArrayDimensions = isArray ? new ReadOnlyList<uint>(new UInt32Collection(new[] { arrayLength ?? 0u })) : null,
|
||
AccessLevel = access,
|
||
UserAccessLevel = access,
|
||
Historizing = historized,
|
||
Value = null,
|
||
StatusCode = StatusCodes.BadWaitingForInitialData,
|
||
Timestamp = DateTime.MinValue,
|
||
};
|
||
// A writable equipment tag owns an inbound-write handler. The SDK invokes
|
||
// OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
|
||
// driver via NodeWriteGateway. Read-only nodes leave the handler unattached so a write is
|
||
// rejected by the SDK's own AccessLevel check before it ever reaches a handler.
|
||
if (writable)
|
||
{
|
||
variable.OnWriteValue = OnEquipmentTagWrite;
|
||
}
|
||
parent.AddChild(variable);
|
||
AddPredefinedNode(SystemContext, variable);
|
||
_variables[key] = variable;
|
||
// Phase C: register the resolved historian tagname so the HistoryRead override can map this
|
||
// NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
|
||
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
|
||
if (historized) _historizedTagnames[key] = historianTagname!;
|
||
if (refuseArrayOnScalar) _arrayOnScalarRefused[key] = 0;
|
||
}
|
||
}
|
||
|
||
/// <summary>Compose the AccessLevel/UserAccessLevel byte for a tag variable from its Writable +
|
||
/// Historizing flags — the single source of truth shared by <see cref="EnsureVariable"/> (node
|
||
/// creation) and <see cref="UpdateTagAttributes"/> (F10b surgical in-place update). The SDK exposes
|
||
/// the flags separately (no CurrentReadWrite composite): ReadWrite is CurrentRead | CurrentWrite,
|
||
/// with the HistoryRead bit OR-ed in when historized, and the HistoryWrite bit OR-ed in when historized AND
|
||
/// <paramref name="historyWritable"/> (a <see cref="HistorianDataUpdater"/> is wired). The HistoryWrite bit is
|
||
/// what admits a node to the SDK base's HistoryUpdate dispatch at all. OR-ing byte constants promotes to int,
|
||
/// so cast back.</summary>
|
||
/// <param name="writable">The node accepts client value writes.</param>
|
||
/// <param name="historized">The node is historized.</param>
|
||
/// <param name="historyWritable">A HistoryUpdate backend is wired.</param>
|
||
/// <returns>The access-level byte.</returns>
|
||
internal static byte ComposeAccessLevel(bool writable, bool historized, bool historyWritable)
|
||
{
|
||
byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead;
|
||
if (historized) access = (byte)(access | AccessLevels.HistoryRead);
|
||
if (historized && historyWritable) access = (byte)(access | AccessLevels.HistoryWrite);
|
||
return access;
|
||
}
|
||
|
||
/// <summary>F10b surgical counterpart of <see cref="EnsureVariable"/>: update an EXISTING tag variable's
|
||
/// Writable (AccessLevel + <see cref="OnEquipmentTagWrite"/> handler), Historizing (+ historian-tagname
|
||
/// binding), and presentation shape (<paramref name="dataType"/> → <c>DataType</c>;
|
||
/// <paramref name="isArray"/>/<paramref name="arrayLength"/> → <c>ValueRank</c> + <c>ArrayDimensions</c>)
|
||
/// in place and notify subscribers, WITHOUT a rebuild — so client MonitoredItems on the node survive.
|
||
/// Returns false when the node id is unknown (caller falls back to a full rebuild).
|
||
/// <para>
|
||
/// When the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
|
||
/// happen. (1) The node's value is reset to <c>BadWaitingForInitialData</c> (Value=null) so no value
|
||
/// still typed to the OLD DataType is exposed between this update and the driver's next publish — the
|
||
/// same fresh-node state <see cref="EnsureVariable"/> creates, which closes the "brief value-type
|
||
/// mismatch" window. (2) A Part 3 <c>GeneralModelChangeEvent</c> (verb=<c>DataTypeChanged</c>) is
|
||
/// raised from the Server object so model-aware clients re-read the node definition. A Writable /
|
||
/// Historizing-only change (DataType + array-ness unchanged) skips both — its value stays valid and
|
||
/// there is no model change to report (preserves the original surgical behaviour exactly).
|
||
/// </para>
|
||
/// <para>
|
||
/// The model-change event is built under <c>Lock</c> but reported AFTER the lock is released —
|
||
/// mirroring <see cref="RevertOptimisticWriteIfNeeded"/>: <c>Server.ReportEvent</c> re-enters the
|
||
/// server's own subscription/event path, so holding the node <c>Lock</c> across it risks a
|
||
/// lock-order inversion.
|
||
/// </para></summary>
|
||
/// <param name="variableNodeId">The folder-scoped node id of the variable to update in place.</param>
|
||
/// <param name="writable">When true the node becomes read/write with the inbound-write handler; otherwise read-only.</param>
|
||
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding.</param>
|
||
/// <param name="dataType">The OPC UA built-in data type name to apply (e.g. "Boolean", "Int32", "Float").</param>
|
||
/// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param>
|
||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
|
||
/// <returns>True when the in-place update was applied; false when the node id is unknown.</returns>
|
||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
|
||
ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType)
|
||
var key = MapKey(realm, variableNodeId);
|
||
BaseDataVariableState? shapeChangedNode = null;
|
||
lock (Lock)
|
||
{
|
||
if (!_variables.TryGetValue(key, out var v)) return false;
|
||
var historized = historianTagname is not null;
|
||
var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null);
|
||
v.AccessLevel = access;
|
||
v.UserAccessLevel = access;
|
||
v.Historizing = historized;
|
||
v.OnWriteValue = writable ? OnEquipmentTagWrite : null;
|
||
if (historized) _historizedTagnames[key] = historianTagname!;
|
||
else _historizedTagnames.TryRemove(key, out _);
|
||
|
||
// Swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ
|
||
// from the live node — a Writable/Historizing-only change leaves the shape untouched (no value
|
||
// reset, no model-change event), preserving the original surgical behaviour byte-for-byte.
|
||
var newDataType = ResolveBuiltInDataType(dataType);
|
||
var newValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar;
|
||
if (v.DataType != newDataType || v.ValueRank != newValueRank || ArrayLengthDiffers(v, isArray, arrayLength))
|
||
{
|
||
v.DataType = newDataType;
|
||
v.ValueRank = newValueRank;
|
||
v.ArrayDimensions = isArray
|
||
? new ReadOnlyList<uint>(new UInt32Collection(new[] { arrayLength ?? 0u }))
|
||
: null;
|
||
// Drop the stale (old-typed) value — mirrors EnsureVariable's fresh-node state so a client
|
||
// never reads a value whose runtime type contradicts the just-changed DataType.
|
||
v.Value = null;
|
||
v.StatusCode = StatusCodes.BadWaitingForInitialData;
|
||
v.Timestamp = DateTime.MinValue;
|
||
shapeChangedNode = v;
|
||
}
|
||
|
||
v.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
}
|
||
// Report OUTSIDE Lock (see the method remarks) — only when the shape actually changed.
|
||
if (shapeChangedNode is not null) ReportNodeShapeChangedEvent(shapeChangedNode);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>True when an array target's requested 1-D length differs from the node's current one. This is
|
||
/// the array-to-array length-edit case: the caller's <c>ValueRank != newValueRank</c> check already catches
|
||
/// any scalar↔array transition, so this runs to decide whether an already-array node's dimension changed
|
||
/// (and treats absent/empty current dimensions as "differs", forcing the in-place update).</summary>
|
||
private static bool ArrayLengthDiffers(BaseDataVariableState v, bool isArray, uint? arrayLength)
|
||
{
|
||
if (!isArray) return false; // scalar target ⇒ ValueRank check owns the diff
|
||
if (v.ArrayDimensions is not { Count: > 0 }) return true; // no current dimension ⇒ shape differs
|
||
return v.ArrayDimensions[0] != (arrayLength ?? 0u);
|
||
}
|
||
|
||
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that
|
||
/// <paramref name="variable"/>'s definition changed (DataType / ValueRank / ArrayDimensions). Verb =
|
||
/// <c>DataTypeChanged</c> — the model-change verb the SDK exposes for an attribute-shape change (there is
|
||
/// no separate ValueRank verb). <c>internal</c> (not private) so a node-manager test can assert the
|
||
/// populated Changes structure at the nearest deterministic seam (the end-to-end <c>Server.ReportEvent</c>
|
||
/// dispatch would need a subscribed event monitored-item to observe).</summary>
|
||
/// <param name="variable">The variable whose shape changed.</param>
|
||
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
|
||
internal GeneralModelChangeEventState BuildNodeShapeChangedEvent(BaseDataVariableState variable)
|
||
{
|
||
var e = new GeneralModelChangeEventState(null);
|
||
e.Initialize(
|
||
SystemContext,
|
||
source: null,
|
||
severity: EventSeverity.Medium,
|
||
message: new LocalizedText($"Node {variable.NodeId} definition changed (DataType/ValueRank)"));
|
||
// Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName
|
||
// to Server explicitly (we report with source:null since this manager has no Server NodeState handle),
|
||
// so conformant clients that filter events by SourceNode still match this one.
|
||
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
|
||
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
|
||
var change = new ModelChangeStructureDataType
|
||
{
|
||
Affected = variable.NodeId,
|
||
AffectedType = variable.TypeDefinitionId,
|
||
Verb = (byte)ModelChangeStructureVerbMask.DataTypeChanged,
|
||
};
|
||
// SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder
|
||
// relies on for its child PropertyStates).
|
||
e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
|
||
return e;
|
||
}
|
||
|
||
/// <summary>Report the built <c>GeneralModelChangeEvent</c> through the SDK server, guarding against the
|
||
/// event path being disabled / having no subscribers / a transient failure — a surprise here MUST NOT
|
||
/// break the in-place update that already happened (mirrors <see cref="ReportAuditEvent"/>).</summary>
|
||
/// <param name="variable">The variable whose shape changed.</param>
|
||
private void ReportNodeShapeChangedEvent(BaseDataVariableState variable)
|
||
{
|
||
try
|
||
{
|
||
ReportToServerObject(BuildNodeShapeChangedEvent(variable));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may
|
||
// no-op or throw; either way the in-place update already stands. Log to the SDK trace, don't rethrow.
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {NodeId}", variable.NodeId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Emit a Part 3 <c>GeneralModelChangeEvent</c> (verb <c>NodeAdded</c>) announcing that one or more
|
||
/// nodes were added UNDER <paramref name="affectedNodeId"/> at runtime — so already-connected,
|
||
/// model-aware OPC UA clients re-browse the affected node and discover the new children. This is the
|
||
/// runtime-add counterpart of the shape-changed reporter (<see cref="ReportNodeShapeChangedEvent"/>):
|
||
/// when a driver discovers FixedTree nodes AFTER the server is up and they are materialised into the
|
||
/// served Equipment address space (Tasks 4/5), an attribute notification alone is invisible to a
|
||
/// subscribed client — only a model-change event tells it the address space grew.
|
||
/// <para>
|
||
/// The event is built under <c>Lock</c> but reported AFTER the lock is released, mirroring
|
||
/// <see cref="ReportNodeShapeChangedEvent"/> / <see cref="RevertOptimisticWriteIfNeeded"/>:
|
||
/// <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding the node
|
||
/// <c>Lock</c> across it risks a lock-order inversion with a client that has event subscriptions.
|
||
/// The report is wrapped in try/catch so it is tolerant when eventing is disabled / there are no
|
||
/// monitored items / the server is shutting down — the same swallow-and-log tolerance as the
|
||
/// write-revert path (<see cref="ReportAuditEvent"/>). The nodes have already been materialised, so
|
||
/// a surprise from the event path MUST NOT propagate out of this announcement.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
|
||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(affectedNodeId);
|
||
GeneralModelChangeEventState e;
|
||
lock (Lock)
|
||
{
|
||
e = BuildNodesAddedModelChange(affectedNodeId, realm);
|
||
}
|
||
// Report OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path; holding
|
||
// Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent).
|
||
try
|
||
{
|
||
ReportToServerObject(e);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may
|
||
// no-op or throw; either way the node add already stands. Log to the SDK trace, don't rethrow.
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {NodeId}", affectedNodeId);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.AddReference" />
|
||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
|
||
string targetNodeId, AddressSpaceRealm targetRealm,
|
||
string referenceType = "Organizes")
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(sourceNodeId);
|
||
ArgumentException.ThrowIfNullOrEmpty(targetNodeId);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var refType = ResolveReferenceType(referenceType);
|
||
var sourceKey = MapKey(sourceRealm, sourceNodeId);
|
||
var targetKey = MapKey(targetRealm, targetNodeId);
|
||
lock (Lock)
|
||
{
|
||
var source = ResolveNodeState(sourceKey);
|
||
var target = ResolveNodeState(targetKey);
|
||
// A missing endpoint is a no-op (logged) — the applier may drive the cross-tree link while a
|
||
// concurrent rebuild has torn one side down; never throw out of a deploy-time reference wire.
|
||
if (source is null || target is null)
|
||
{
|
||
_logger.LogWarning(
|
||
"OtOpcUaNodeManager: AddReference({RefType}) skipped — {Side} node missing (source '{SourceKey}' present={SourcePresent}, target '{TargetKey}' present={TargetPresent}).",
|
||
refType, source is null ? "source" : "target", sourceKey, source is not null, targetKey, target is not null);
|
||
return;
|
||
}
|
||
|
||
// Idempotent: a re-apply must not duplicate the edge. Organizes is hierarchical, so wire it
|
||
// bidirectionally — forward on the source, inverse (OrganizedBy) on the target — so browse
|
||
// resolves both ways. Only ClearChangeMasks a side we actually mutated.
|
||
var sourceChanged = false;
|
||
var targetChanged = false;
|
||
if (!source.ReferenceExists(refType, isInverse: false, target.NodeId))
|
||
{
|
||
source.AddReference(refType, isInverse: false, target.NodeId);
|
||
sourceChanged = true;
|
||
}
|
||
if (!target.ReferenceExists(refType, isInverse: true, source.NodeId))
|
||
{
|
||
target.AddReference(refType, isInverse: true, source.NodeId);
|
||
targetChanged = true;
|
||
}
|
||
if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
}
|
||
}
|
||
|
||
/// <summary>Resolve a materialised node (variable, folder, or alarm condition) by its full
|
||
/// (namespace-qualified) map key, or null when no node is registered under that key. Used by
|
||
/// <see cref="AddReference"/> to link two already-materialised nodes regardless of their kind.</summary>
|
||
/// <param name="key">The namespace-qualified map key (see <see cref="MapKey(AddressSpaceRealm, string)"/>).</param>
|
||
/// <returns>The node state, or null when unknown.</returns>
|
||
private NodeState? ResolveNodeState(RealmKey key)
|
||
{
|
||
if (_variables.TryGetValue(key, out var variable)) return variable;
|
||
if (_folders.TryGetValue(key, out var folder)) return folder;
|
||
if (_alarmConditions.TryGetValue(key, out var condition)) return condition;
|
||
return null;
|
||
}
|
||
|
||
/// <summary>Test/diagnostic accessor: snapshot the references on a materialised node (by realm-qualified
|
||
/// id) using the manager's own <c>SystemContext</c>, or an empty list when the node is unknown. Exposed
|
||
/// so <c>SdkAddressSpaceSinkTests</c> can assert the cross-tree <c>Organizes</c> edge
|
||
/// <see cref="AddReference"/> wires without needing the (protected) SDK system context.</summary>
|
||
/// <param name="nodeId">The node's <c>s=</c> id.</param>
|
||
/// <param name="realm">The realm the node lives in.</param>
|
||
/// <returns>The node's references, or an empty list when the node is not registered.</returns>
|
||
internal IReadOnlyList<IReference> GetNodeReferences(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||
{
|
||
lock (Lock)
|
||
{
|
||
var node = ResolveNodeState(MapKey(realm, nodeId));
|
||
if (node is null) return Array.Empty<IReference>();
|
||
var list = new List<IReference>();
|
||
node.GetReferences(SystemContext, list);
|
||
return list;
|
||
}
|
||
}
|
||
|
||
/// <summary>Map a hierarchical reference-type name to its SDK <see cref="NodeId"/>. Unknown names fall
|
||
/// back to <see cref="ReferenceTypeIds.Organizes"/> (the UNS→Raw cross-tree link).</summary>
|
||
/// <param name="referenceType">The reference-type name (e.g. "Organizes").</param>
|
||
/// <returns>The reference-type NodeId.</returns>
|
||
private static NodeId ResolveReferenceType(string referenceType) => referenceType switch
|
||
{
|
||
"HasComponent" => ReferenceTypeIds.HasComponent,
|
||
"HasProperty" => ReferenceTypeIds.HasProperty,
|
||
_ => ReferenceTypeIds.Organizes,
|
||
};
|
||
|
||
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that nodes were
|
||
/// added under <paramref name="affectedNodeId"/>. MIRRORS <see cref="BuildNodeShapeChangedEvent"/> exactly —
|
||
/// the only differences are <c>Verb = NodeAdded</c> (vs <c>DataTypeChanged</c>) and <c>Affected</c> = the
|
||
/// passed parent node id (vs the variable's own NodeId). <c>AffectedType</c> carries the affected node's
|
||
/// TypeDefinition resolved from the live node maps (same semantics the shape-changed builder gets from the
|
||
/// variable), defaulting to <see cref="NodeId.Null"/> when the id is not (yet) materialised. <c>internal</c>
|
||
/// (not private) so a node-manager test can assert the populated Changes structure at the nearest
|
||
/// deterministic seam (the end-to-end <c>Server.ReportEvent</c> dispatch would need a subscribed event
|
||
/// monitored-item to observe).</summary>
|
||
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
|
||
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
|
||
internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||
{
|
||
var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm));
|
||
var e = new GeneralModelChangeEventState(null);
|
||
e.Initialize(
|
||
SystemContext,
|
||
source: null,
|
||
severity: EventSeverity.Medium,
|
||
message: new LocalizedText($"Nodes added under {affected}"));
|
||
// Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName
|
||
// to Server explicitly (we report with source:null since this manager has no Server NodeState handle),
|
||
// so conformant clients that filter events by SourceNode still match this one.
|
||
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
|
||
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
|
||
var change = new ModelChangeStructureDataType
|
||
{
|
||
Affected = affected,
|
||
// The affected node is the parent the children were added under; carry its TypeDefinition (a Folder
|
||
// for an equipment parent) just as the shape-changed builder carries the variable's. Null when the
|
||
// id is unknown — a valid Part 3 "type not applicable", and clients re-browse Affected regardless.
|
||
AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
|
||
Verb = (byte)ModelChangeStructureVerbMask.NodeAdded,
|
||
};
|
||
// SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder
|
||
// relies on for its child PropertyStates).
|
||
e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
|
||
return e;
|
||
}
|
||
|
||
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that the node
|
||
/// at <paramref name="affectedNodeId"/> was DELETED. Mirrors <see cref="BuildNodesAddedModelChange"/>
|
||
/// exactly — the only differences are <c>Verb = NodeDeleted</c> and that the affected node has already
|
||
/// been dropped from the maps, so <c>AffectedType</c> resolves to <see cref="NodeId.Null"/> (a valid Part 3
|
||
/// "type not applicable"; clients re-browse the parent regardless). <c>internal</c> so a node-manager test
|
||
/// can assert the populated Changes structure at the nearest deterministic seam.</summary>
|
||
/// <param name="affectedNodeId">The node id of the deleted node.</param>
|
||
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
|
||
internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||
{
|
||
var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm));
|
||
var e = new GeneralModelChangeEventState(null);
|
||
e.Initialize(
|
||
SystemContext,
|
||
source: null,
|
||
severity: EventSeverity.Medium,
|
||
message: new LocalizedText($"Node deleted: {affected}"));
|
||
// Part 3 §8.7.4: emitted by the Server object — set SourceNode/SourceName to Server explicitly
|
||
// (mirrors BuildNodesAddedModelChange).
|
||
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
|
||
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
|
||
var change = new ModelChangeStructureDataType
|
||
{
|
||
Affected = affected,
|
||
// The node is already gone from the maps, so its TypeDefinition is not applicable (Null).
|
||
AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
|
||
Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted,
|
||
};
|
||
e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
|
||
return e;
|
||
}
|
||
|
||
/// <summary>Report a pre-built <see cref="GeneralModelChangeEventState"/> OUTSIDE <c>Lock</c> —
|
||
/// <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding <c>Lock</c>
|
||
/// across it risks a lock-order inversion (mirrors <see cref="RaiseNodesAddedModelChange"/> /
|
||
/// <c>ReportNodeShapeChangedEvent</c>). Tolerant: swallow-and-log when eventing is disabled / there are no
|
||
/// monitored items / the server is shutting down — the node mutation already stands.</summary>
|
||
/// <param name="e">The pre-built event to report.</param>
|
||
/// <param name="affectedNodeId">The affected node id (for the diagnostic only).</param>
|
||
private void ReportModelChangeOutsideLock(GeneralModelChangeEventState e, string affectedNodeId)
|
||
{
|
||
try
|
||
{
|
||
ReportToServerObject(e);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {NodeId}", affectedNodeId);
|
||
}
|
||
}
|
||
|
||
/// <summary>Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a
|
||
/// model-change event's <c>AffectedType</c>; <see cref="NodeId.Null"/> when the id is not registered.</summary>
|
||
/// <param name="nodeId">The folder-scoped node id whose TypeDefinition is wanted.</param>
|
||
private NodeId ResolveAffectedTypeDefinition(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||
{
|
||
var key = MapKey(realm, nodeId);
|
||
if (_folders.TryGetValue(key, out var folder)) return folder.TypeDefinitionId;
|
||
if (_variables.TryGetValue(key, out var variable)) return variable.TypeDefinitionId;
|
||
return NodeId.Null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #693: the AtTime value-type hint of a historized variable — its DataType's built-in type as a
|
||
/// <see cref="TypeCode"/> when the node is a scalar of a type the historian evaluates (Boolean, the integers, Float,
|
||
/// Double, String, DateTime); <c>null</c> for an array, an unknown node, or any other type. A UNS mirror carries its
|
||
/// backing raw tag's DataType, so either NodeId gives the same hint.
|
||
/// </summary>
|
||
/// <param name="nodeId">The HistoryRead request's node id.</param>
|
||
/// <returns>The hint, or null.</returns>
|
||
internal TypeCode? AtTimeValueType(NodeId nodeId)
|
||
{
|
||
if (!_variables.TryGetValue(MapKey(nodeId), out var variable) || variable.ValueRank != ValueRanks.Scalar) return null;
|
||
return TypeInfo.GetBuiltInType(variable.DataType) switch
|
||
{
|
||
BuiltInType.Boolean => TypeCode.Boolean,
|
||
BuiltInType.SByte => TypeCode.SByte,
|
||
BuiltInType.Byte => TypeCode.Byte,
|
||
BuiltInType.Int16 => TypeCode.Int16,
|
||
BuiltInType.UInt16 => TypeCode.UInt16,
|
||
BuiltInType.Int32 => TypeCode.Int32,
|
||
BuiltInType.UInt32 => TypeCode.UInt32,
|
||
BuiltInType.Int64 => TypeCode.Int64,
|
||
BuiltInType.UInt64 => TypeCode.UInt64,
|
||
BuiltInType.Float => TypeCode.Single,
|
||
BuiltInType.Double => TypeCode.Double,
|
||
BuiltInType.String => TypeCode.String,
|
||
BuiltInType.DateTime => TypeCode.DateTime,
|
||
_ => null,
|
||
};
|
||
}
|
||
|
||
/// <summary>Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
|
||
/// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
|
||
/// (matches CreateVariable's default for lazy-created nodes).</summary>
|
||
private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch
|
||
{
|
||
"Boolean" => DataTypeIds.Boolean,
|
||
"SByte" => DataTypeIds.SByte,
|
||
"Byte" => DataTypeIds.Byte,
|
||
"Int16" => DataTypeIds.Int16,
|
||
"UInt16" => DataTypeIds.UInt16,
|
||
"Int32" => DataTypeIds.Int32,
|
||
"UInt32" => DataTypeIds.UInt32,
|
||
"Int64" => DataTypeIds.Int64,
|
||
"UInt64" => DataTypeIds.UInt64,
|
||
"Float" => DataTypeIds.Float,
|
||
"Double" => DataTypeIds.Double,
|
||
"String" => DataTypeIds.String,
|
||
"DateTime" => DataTypeIds.DateTime,
|
||
_ => DataTypeIds.BaseDataType,
|
||
};
|
||
|
||
/// <summary>Clear every registered variable + folder from the address space. AddressSpaceApplier
|
||
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
||
/// EnsureFolder + WriteValue on the next pass.</summary>
|
||
public void RebuildAddressSpace()
|
||
{
|
||
lock (Lock)
|
||
{
|
||
foreach (var v in _variables.Values)
|
||
{
|
||
v.Parent?.RemoveChild(v);
|
||
PredefinedNodes?.Remove(v.NodeId);
|
||
}
|
||
_variables.Clear();
|
||
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
|
||
_historizedTagnames.Clear();
|
||
_arrayOnScalarRefused.Clear();
|
||
|
||
// Gitea #641: capture every NATIVE condition's live state before the conditions are discarded, so the
|
||
// re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted
|
||
// conditions are not captured — their engine re-asserts them after the reload (#487).
|
||
_rebuildCarryOver.Clear();
|
||
_restoredConditionReports.Clear();
|
||
foreach (var (key, alarm) in _alarmConditions)
|
||
{
|
||
if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm);
|
||
}
|
||
|
||
foreach (var alarm in _alarmConditions.Values)
|
||
{
|
||
alarm.Parent?.RemoveChild(alarm);
|
||
ForgetPredefinedSubtree(alarm);
|
||
}
|
||
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
|
||
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
|
||
// rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
|
||
// detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
|
||
// tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
|
||
foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
|
||
UnwireAlarmNotifiers(alarmKey);
|
||
|
||
_alarmConditions.Clear();
|
||
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
|
||
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
|
||
_nativeAlarmNodeIds.Clear();
|
||
|
||
foreach (var f in _folders.Values)
|
||
{
|
||
f.Parent?.RemoveChild(f);
|
||
PredefinedNodes?.Remove(f.NodeId);
|
||
}
|
||
_folders.Clear();
|
||
|
||
// Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
|
||
// guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
|
||
// object. RemoveRootNotifier just severs that link, so its order relative to the folder
|
||
// teardown above doesn't matter — but it must run under this same Lock.
|
||
foreach (var folder in _notifierFolders.Values)
|
||
{
|
||
RemoveRootNotifier(folder);
|
||
}
|
||
|
||
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
|
||
// equipment folders to event notifiers.
|
||
_notifierFolders.Clear();
|
||
// Phase C: drop the event-history source registrations alongside the notifier folders
|
||
// they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
|
||
_eventNotifierSources.Clear();
|
||
ReconcileAllEventsMonitoring();
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
|
||
/// <remarks>
|
||
/// Gitea #641: reports ONE condition event for each native condition whose off-normal state the rebuild
|
||
/// carried over (stamped with the instant that state came about, not the deploy time — as the scripted
|
||
/// re-assert does), then discards whatever captured state no materialise claimed. A condition the apply
|
||
/// replaced again after restoring it is skipped: the instance reported must be the one restored.
|
||
/// </remarks>
|
||
public void CompleteAddressSpaceApply()
|
||
{
|
||
int reported = 0, discarded;
|
||
lock (Lock)
|
||
{
|
||
foreach (var (key, alarm) in _restoredConditionReports)
|
||
{
|
||
if (!_alarmConditions.TryGetValue(key, out var live) || !ReferenceEquals(live, alarm)) continue;
|
||
// The node's CURRENT Time: the carried instant when nothing reported since the restore, or the
|
||
// newer instant of a report that did (a client method call on an SDK thread can land between the
|
||
// restore and here) — never roll the condition's Time back.
|
||
ReportConditionEvent(alarm, alarm.Time.Value);
|
||
reported++;
|
||
}
|
||
|
||
discarded = _rebuildCarryOver.Count;
|
||
_restoredConditionReports.Clear();
|
||
_rebuildCarryOver.Clear();
|
||
}
|
||
|
||
if (reported > 0 || discarded > 0)
|
||
{
|
||
_logger.LogInformation(
|
||
"OtOpcUaNodeManager: rebuild carried {Reported} active/unacknowledged native condition(s) over; discarded {Discarded} captured condition(s) the apply did not re-create",
|
||
reported, discarded);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
|
||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var key = MapKey(realm, variableNodeId);
|
||
GeneralModelChangeEventState e;
|
||
lock (Lock)
|
||
{
|
||
// Unknown id ⇒ the node-manager maps drifted from what the planner believes; return false so the
|
||
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
|
||
// teardown inside RebuildAddressSpace, scoped to this one id.
|
||
if (!_variables.TryRemove(key, out var variable)) return false;
|
||
// 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds
|
||
// BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a
|
||
// condition removal. AddReference wires the edge bidirectionally: forward on the UNS reference
|
||
// variable, inverse (OrganizedBy) on the backing RAW node. The raw node SURVIVES this scoped
|
||
// remove, so without this it keeps a dangling inverse reference to a node that no longer exists —
|
||
// browsable, unresolvable, and permanent.
|
||
//
|
||
// Why it matters more now than it used to: a full RebuildAddressSpace clears every node and every
|
||
// reference with it, so these edges were swept up on the next structural deploy. Surgical applies
|
||
// made rebuilds rare, so the dangling edges now ACCUMULATE on the raw node for the life of the
|
||
// process — one per re-point or effective-name edit of any reference that ever pointed at it.
|
||
SeverOutgoingReferences(variable);
|
||
variable.Parent?.RemoveChild(variable);
|
||
PredefinedNodes?.Remove(variable.NodeId);
|
||
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
|
||
_historizedTagnames.TryRemove(key, out _);
|
||
_arrayOnScalarRefused.TryRemove(key, out _);
|
||
e = BuildNodesRemovedModelChange(variableNodeId, realm);
|
||
}
|
||
ReportModelChangeOutsideLock(e, variableNodeId);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 2026-08-15 review I3 — remove the INVERSE half, held on the far node, of every FORWARD reference
|
||
/// <paramref name="node"/> carries to another node this manager owns. The exact reverse of the
|
||
/// bidirectional wiring in <see cref="AddReference"/>, and the variable-node counterpart of
|
||
/// <see cref="UnwireAlarmNotifiers"/>. MUST be called under <c>Lock</c>.
|
||
/// <para>Only FORWARD references are walked. A node's link to its own parent manifests as an INVERSE
|
||
/// reference (the parent owns the forward half through <c>AddChild</c>), and the parent's side of that
|
||
/// is severed by <c>Parent.RemoveChild</c> — walking inverse references too would try to unwire the
|
||
/// parent link a second time. A forward reference whose target is not one of this manager's nodes is
|
||
/// skipped: there is nothing here to mutate, and the target's own owner is responsible for it.</para>
|
||
/// </summary>
|
||
/// <param name="node">The node being removed, whose outgoing edges' inverse halves must be severed.</param>
|
||
private void SeverOutgoingReferences(NodeState node)
|
||
{
|
||
var references = new List<IReference>();
|
||
node.GetReferences(SystemContext, references);
|
||
foreach (var r in references)
|
||
{
|
||
if (r.IsInverse || r.TargetId is null || r.TargetId.IsAbsolute) continue;
|
||
var targetNodeId = (NodeId)r.TargetId;
|
||
if (NodeId.IsNull(targetNodeId)) continue;
|
||
if (ResolveNodeState(MapKey(targetNodeId)) is not { } target) continue;
|
||
if (!target.RemoveReference(r.ReferenceTypeId, isInverse: true, node.NodeId)) continue;
|
||
target.ClearChangeMasks(SystemContext, includeChildren: false);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
|
||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var key = MapKey(realm, alarmNodeId);
|
||
GeneralModelChangeEventState e;
|
||
lock (Lock)
|
||
{
|
||
// Unknown id ⇒ map drift ⇒ false (caller rebuilds). Mirrors the condition teardown inside
|
||
// RebuildAddressSpace, scoped to this one condition. The equipment folder's event-notifier
|
||
// promotion is intentionally NOT demoted here — other conditions may still hang under it, and a
|
||
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
|
||
// equipment goes).
|
||
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
|
||
// v3 Batch 4 WP4: unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
|
||
// the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
|
||
// this they would keep a dangling inverse-notifier entry to the removed condition.
|
||
UnwireAlarmNotifiers(key);
|
||
condition.Parent?.RemoveChild(condition);
|
||
ForgetPredefinedSubtree(condition);
|
||
_nativeAlarmNodeIds.Remove(key);
|
||
ReconcileAllEventsMonitoring();
|
||
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
|
||
}
|
||
ReportModelChangeOutsideLock(e, alarmNodeId);
|
||
return true;
|
||
}
|
||
|
||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
|
||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
|
||
EnsureAddressSpaceCreated();
|
||
|
||
var equipKey = MapKey(realm, equipmentNodeId);
|
||
GeneralModelChangeEventState e;
|
||
lock (Lock)
|
||
{
|
||
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
|
||
if (!_folders.ContainsKey(equipKey)) return false;
|
||
|
||
// Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly
|
||
// or begins with "<equipmentNodeId>/" (sub-folders, variables, condition nodes); the explicit realm
|
||
// compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s="
|
||
// — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot
|
||
// each map's in-scope keys first (ToList) so we don't mutate while enumerating.
|
||
var prefix = equipmentNodeId + "/";
|
||
bool InScope(RealmKey key) => key.Realm == realm
|
||
&& (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal));
|
||
|
||
// Value variables (+ their historized-tagname registrations).
|
||
foreach (var id in _variables.Keys.Where(InScope).ToList())
|
||
{
|
||
if (_variables.TryRemove(id, out var v))
|
||
{
|
||
// Gitea #599 (archreview 03/S20): sever the inverse half of each cross-tree Organizes edge
|
||
// first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a
|
||
// RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge.
|
||
SeverOutgoingReferences(v);
|
||
v.Parent?.RemoveChild(v);
|
||
PredefinedNodes?.Remove(v.NodeId);
|
||
}
|
||
_historizedTagnames.TryRemove(id, out _);
|
||
_arrayOnScalarRefused.TryRemove(id, out _);
|
||
}
|
||
|
||
// Part 9 condition nodes (+ their native flags).
|
||
foreach (var id in _alarmConditions.Keys.Where(InScope).ToList())
|
||
{
|
||
if (_alarmConditions.TryRemove(id, out var c))
|
||
{
|
||
c.Parent?.RemoveChild(c);
|
||
ForgetPredefinedSubtree(c);
|
||
}
|
||
_nativeAlarmNodeIds.Remove(id);
|
||
}
|
||
ReconcileAllEventsMonitoring();
|
||
|
||
// v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
|
||
// UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
|
||
// event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
|
||
// else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
|
||
// condition's own scoped removal — if its raw tag is also removed this apply — runs later via
|
||
// RemoveAlarmConditionNode; this only reaches surviving conditions.)
|
||
foreach (var wiring in _alarmNotifierWiring.Values)
|
||
{
|
||
foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
|
||
{
|
||
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
|
||
wiring.Folders.Remove(folder);
|
||
}
|
||
}
|
||
|
||
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
|
||
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
|
||
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
|
||
// _notifierFolders is keyed by the folder's NodeId; match InScope on its realm-qualified map key.
|
||
foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).ToList())
|
||
{
|
||
RemoveRootNotifier(kvp.Value);
|
||
_notifierFolders.Remove(kvp.Key);
|
||
}
|
||
// Drop the event-history source registrations for the subtree (keyed by the folder id string).
|
||
foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList())
|
||
_eventNotifierSources.TryRemove(src, out _);
|
||
|
||
// Sub-folders + the equipment folder itself (children already detached above).
|
||
foreach (var id in _folders.Keys.Where(InScope).ToList())
|
||
{
|
||
if (_folders.TryRemove(id, out var f))
|
||
{
|
||
f.Parent?.RemoveChild(f);
|
||
PredefinedNodes?.Remove(f.NodeId);
|
||
}
|
||
}
|
||
|
||
e = BuildNodesRemovedModelChange(equipmentNodeId, realm);
|
||
}
|
||
ReportModelChangeOutsideLock(e, equipmentNodeId);
|
||
return true;
|
||
}
|
||
|
||
private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm)
|
||
{
|
||
EnsureAddressSpaceCreated();
|
||
if (string.IsNullOrEmpty(parentNodeId)) return _root!;
|
||
// A node's parent lives in the SAME realm (a raw tag's group is raw; a UNS reference's equipment
|
||
// folder is UNS), so resolve the parent key in that realm; unknown parent ⇒ hang under the shared root.
|
||
return _folders.TryGetValue(MapKey(realm, parentNodeId), out var existing) ? existing : _root!;
|
||
}
|
||
|
||
/// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
|
||
/// is only assigned in <see cref="CreateAddressSpace"/>, which the SDK invokes during
|
||
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>, <see cref="WriteValues"/>,
|
||
/// <see cref="WriteAlarmCondition"/>, <see cref="EnsureFolder"/>, <see cref="EnsureVariable"/>,
|
||
/// <see cref="MaterialiseAlarmCondition"/>) and <see cref="ResolveParentFolder"/> assume it has run.
|
||
/// If one is invoked before the server has started (a sink wired or a publish replayed before
|
||
/// <c>StartAsync</c> completes), <c>_root</c> is null and the dereference would NRE; throw a legible
|
||
/// <see cref="InvalidOperationException"/> instead. Happy-path behaviour is unchanged.</summary>
|
||
/// <exception cref="InvalidOperationException">When the address space has not been created yet.</exception>
|
||
private void EnsureAddressSpaceCreated()
|
||
{
|
||
if (_root is null)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"OPC UA address space has not been created yet (server not started).");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// #520 stage 6 — Read / Browse / TranslateBrowsePath / subscribe gates.
|
||
//
|
||
// These are the request-rate seams, so every decision here goes through the authorizer's CACHED
|
||
// entry point: one Browse of a device folder asks the same question once per child, and a client
|
||
// reading a thousand nodes asks it a thousand times.
|
||
//
|
||
// The gates resolve a node purely from its NodeId — namespace index for ownership, `s=` identifier
|
||
// for the scope — and never call GetManagerHandle. That is not only cheaper: it means a DENIED node
|
||
// is never resolved, never read, and never touches a driver.
|
||
//
|
||
// ⚠️ A consequence worth knowing: a NodeId in one of our namespaces that does not exist now answers
|
||
// BadUserAccessDenied instead of BadNodeIdUnknown while enforcement is on, because the gate runs
|
||
// before resolution. That is the information-hiding direction — an ungranted client can no longer
|
||
// use error codes to enumerate which nodes exist — but it does mean "access denied" is not proof
|
||
// that the node is there.
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/// <summary>True when <paramref name="nodeId"/> is minted in one of this manager's two namespaces.</summary>
|
||
/// <param name="nodeId">The node id to test.</param>
|
||
/// <returns><see langword="true"/> when this manager owns the node's namespace.</returns>
|
||
private bool OwnsNamespace(NodeId? nodeId) =>
|
||
nodeId is not null
|
||
&& (nodeId.NamespaceIndex == NamespaceIndexes[0] || nodeId.NamespaceIndex == NamespaceIndexes[1]);
|
||
|
||
/// <summary>Pulls the ACL identity off an SDK operation context.</summary>
|
||
/// <param name="context">The SDK context carrying the session identity.</param>
|
||
/// <returns>The role-carrying identity, or null when the session has none.</returns>
|
||
private static RoleCarryingUserIdentity? IdentityOf(OperationContext context) =>
|
||
context.UserIdentity as RoleCarryingUserIdentity;
|
||
|
||
/// <summary>Pulls the ACL identity off a system context (the protected SDK seams).</summary>
|
||
/// <param name="context">The SDK system context.</param>
|
||
/// <returns>The role-carrying identity, or null when the session has none.</returns>
|
||
private static RoleCarryingUserIdentity? IdentityOf(ServerSystemContext context) =>
|
||
(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
|
||
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
|
||
|
||
/// <summary>Whether the session may perform <paramref name="operation"/> on <paramref name="nodeId"/>.</summary>
|
||
/// <param name="identity">The calling session's identity.</param>
|
||
/// <param name="operation">The operation being attempted.</param>
|
||
/// <param name="nodeId">The target node.</param>
|
||
/// <returns><see langword="true"/> when permitted, or when the node is not ours to gate.</returns>
|
||
private bool Permits(RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId) =>
|
||
!OwnsNamespace(nodeId)
|
||
|| _authorizer!.IsAllowedCached(identity, operation, nodeId, RealmOf(nodeId));
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// Denied slots are answered and marked <see cref="ReadValueId.Processed"/> <b>before</b> the base
|
||
/// runs, so a refused read never resolves the node or samples its value. Every attribute of a node
|
||
/// is gated by the single <c>Read</c> permission rather than only <c>Value</c>: the finer split
|
||
/// would let an ungranted session harvest BrowseNames, DataTypes and descriptions of nodes it
|
||
/// cannot read, which is most of what the address space discloses.
|
||
/// </remarks>
|
||
public override void Read(
|
||
OperationContext context,
|
||
double maxAge,
|
||
IList<ReadValueId> nodesToRead,
|
||
IList<DataValue> values,
|
||
IList<ServiceResult> errors)
|
||
{
|
||
if (AclEnforced)
|
||
{
|
||
var identity = IdentityOf(context);
|
||
var denied = 0;
|
||
|
||
for (var ii = 0; ii < nodesToRead.Count; ii++)
|
||
{
|
||
var nodeToRead = nodesToRead[ii];
|
||
if (nodeToRead.Processed || !OwnsNamespace(nodeToRead.NodeId)) continue;
|
||
if (Permits(identity, OpcUaOperation.Read, nodeToRead.NodeId)) continue;
|
||
|
||
nodeToRead.Processed = true;
|
||
errors[ii] = StatusCodes.BadUserAccessDenied;
|
||
values[ii] = new DataValue(StatusCodes.BadUserAccessDenied);
|
||
denied++;
|
||
}
|
||
|
||
LogAclFiltering("Read", identity, denied, nodesToRead.Count);
|
||
}
|
||
|
||
base.Read(context, maxAge, nodesToRead, values, errors);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Two distinct checks: the browsed node itself must be reachable, and every reference the base
|
||
/// returns is filtered against the target's own grant. Filtering only the source would list
|
||
/// children the session may not see; refusing only the source would make every narrow grant
|
||
/// unreachable, since a client arrives at a deep node by browsing its ancestors.
|
||
/// </para>
|
||
/// <para>
|
||
/// Reachability comes from <c>PermissionTrie.HasBrowseTransit</c> — a node is browsable when it
|
||
/// merely leads somewhere the principal may browse. Note what this necessarily discloses: the
|
||
/// <em>names</em> of the folders on the path to a granted node are visible to anyone holding
|
||
/// that grant, because navigation requires them.
|
||
/// </para>
|
||
/// <para>
|
||
/// ⚠️ There is ONE shared root folder (<c>V3NodeIds.SharedRootFolderId</c>, minted in the raw
|
||
/// namespace by <see cref="CreateAddressSpace"/>) fronting BOTH realm trees — not one root per
|
||
/// namespace. It is referenced from the ns=0 <c>Objects</c> folder, whose browse is served by
|
||
/// the SDK's own node manager and is not gated here, so a session with no grants at all still
|
||
/// sees that the root exists — and is refused on entry. A session holding any Browse grant in
|
||
/// either realm enters it via the zero-segment realm-root scope
|
||
/// <c>Security.DataPlaneAuthorizer.BuildScope</c> maps the anchor to.
|
||
/// </para>
|
||
/// </remarks>
|
||
public override void Browse(
|
||
OperationContext context,
|
||
ref ContinuationPoint continuationPoint,
|
||
IList<ReferenceDescription> references)
|
||
{
|
||
if (!AclEnforced)
|
||
{
|
||
base.Browse(context, ref continuationPoint, references);
|
||
return;
|
||
}
|
||
|
||
var identity = IdentityOf(context);
|
||
// NodeToBrowse holds the MANAGER HANDLE the master node manager resolved — a NodeHandle for this
|
||
// manager — not a NodeState. The declared type is `object`, so casting it to the wrong thing
|
||
// compiles cleanly and silently yields null, which would skip the source gate entirely and leave
|
||
// only the reference filtering below.
|
||
var source = (continuationPoint?.NodeToBrowse as NodeHandle)?.NodeId;
|
||
|
||
if (source is not null && !Permits(identity, OpcUaOperation.Browse, source))
|
||
throw new ServiceResultException(StatusCodes.BadUserAccessDenied);
|
||
|
||
// Only the references THIS call appends are ours to filter — on a BrowseNext the list already
|
||
// carries the earlier pages, and re-examining them would both waste work and skew the summary.
|
||
var before = references.Count;
|
||
base.Browse(context, ref continuationPoint, references);
|
||
var returned = references.Count - before;
|
||
|
||
// Forward two-pointer compaction over the appended range, NOT a per-denial RemoveAt. Removing
|
||
// from the middle shifts every later element down one slot, so denying a large prefix of a wide
|
||
// browse costs O(kept × removed) element moves; copying each survivor down to a write cursor and
|
||
// trimming the tail afterwards is one pass. The tail trim removes from the END, where RemoveAt
|
||
// moves nothing.
|
||
var filtered = 0;
|
||
var write = before;
|
||
for (var ii = before; ii < references.Count; ii++)
|
||
{
|
||
// ExpandedNodeId → NodeId only makes sense for a reference into our own server; a reference
|
||
// to another server's node is left alone, as this manager has no grants that could speak to it.
|
||
var target = references[ii].NodeId;
|
||
var keep = target is null
|
||
|| target.IsAbsolute
|
||
|| ExpandedNodeId.ToNodeId(target, Server.NamespaceUris) is not { } targetId
|
||
|| !OwnsNamespace(targetId)
|
||
|| Permits(identity, OpcUaOperation.Browse, targetId);
|
||
|
||
if (!keep)
|
||
{
|
||
filtered++;
|
||
continue;
|
||
}
|
||
|
||
if (write != ii) references[write] = references[ii];
|
||
write++;
|
||
}
|
||
|
||
for (var ii = references.Count - 1; ii >= write; ii--) references.RemoveAt(ii);
|
||
|
||
LogAclFiltering("Browse", identity, filtered, returned);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// Gated for the same reason Browse is, and it is not optional: without it a client denied a browse
|
||
/// simply asks <c>TranslateBrowsePathsToNodeIds</c> for the same path and gets the NodeId back, which
|
||
/// would make the reference filtering above decorative.
|
||
/// </remarks>
|
||
public override void TranslateBrowsePath(
|
||
OperationContext context,
|
||
object sourceHandle,
|
||
RelativePathElement relativePath,
|
||
IList<ExpandedNodeId> targetIds,
|
||
IList<NodeId> unresolvedTargetIds)
|
||
{
|
||
if (!AclEnforced)
|
||
{
|
||
base.TranslateBrowsePath(context, sourceHandle, relativePath, targetIds, unresolvedTargetIds);
|
||
return;
|
||
}
|
||
|
||
var identity = IdentityOf(context);
|
||
|
||
if (sourceHandle is NodeHandle handle
|
||
&& !Permits(identity, OpcUaOperation.Browse, handle.NodeId))
|
||
{
|
||
// Silence rather than an error: the SDK's contract here is "add what you resolved", and a
|
||
// path that resolves to nothing is the same answer the client gets for a path that does not
|
||
// exist — which is the answer we want an ungranted session to receive.
|
||
return;
|
||
}
|
||
|
||
var before = targetIds.Count;
|
||
base.TranslateBrowsePath(context, sourceHandle, relativePath, targetIds, unresolvedTargetIds);
|
||
|
||
// Forward two-pointer compaction over the appended range — same reasoning as Browse above: a
|
||
// mid-list RemoveAt per denial is O(kept × removed) element moves, one compacting pass plus a
|
||
// tail trim is O(n).
|
||
var write = before;
|
||
for (var ii = before; ii < targetIds.Count; ii++)
|
||
{
|
||
var targetId = ExpandedNodeId.ToNodeId(targetIds[ii], Server.NamespaceUris);
|
||
var keep = targetId is null
|
||
|| !OwnsNamespace(targetId)
|
||
|| Permits(identity, OpcUaOperation.Browse, targetId);
|
||
|
||
if (!keep) continue;
|
||
|
||
if (write != ii) targetIds[write] = targetIds[ii];
|
||
write++;
|
||
}
|
||
|
||
for (var ii = targetIds.Count - 1; ii >= write; ii--) targetIds.RemoveAt(ii);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// Per item, not per batch — OPC UA Part 4 gives each item its own result, and a client subscribing
|
||
/// to ten nodes of which one is off-limits should get nine subscriptions and one denial.
|
||
/// </remarks>
|
||
public override void CreateMonitoredItems(
|
||
OperationContext context,
|
||
uint subscriptionId,
|
||
double publishingInterval,
|
||
TimestampsToReturn timestampsToReturn,
|
||
IList<MonitoredItemCreateRequest> itemsToCreate,
|
||
IList<ServiceResult> errors,
|
||
IList<MonitoringFilterResult> filterErrors,
|
||
IList<IMonitoredItem> monitoredItems,
|
||
bool createDurable,
|
||
MonitoredItemIdFactory monitoredItemIdFactory)
|
||
{
|
||
if (AclEnforced)
|
||
{
|
||
var identity = IdentityOf(context);
|
||
var denied = 0;
|
||
|
||
for (var ii = 0; ii < itemsToCreate.Count; ii++)
|
||
{
|
||
var item = itemsToCreate[ii];
|
||
var target = item.ItemToMonitor?.NodeId;
|
||
if (item.Processed || !OwnsNamespace(target)) continue;
|
||
if (Permits(identity, OpcUaOperation.CreateMonitoredItems, target!)) continue;
|
||
|
||
item.Processed = true;
|
||
errors[ii] = StatusCodes.BadUserAccessDenied;
|
||
denied++;
|
||
}
|
||
|
||
LogAclFiltering("CreateMonitoredItems", identity, denied, itemsToCreate.Count);
|
||
}
|
||
|
||
base.CreateMonitoredItems(
|
||
context, subscriptionId, publishingInterval, timestampsToReturn, itemsToCreate, errors,
|
||
filterErrors, monitoredItems, createDurable, monitoredItemIdFactory);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// Event subscriptions are gated on the notifier they attach to. This is the alarm-delivery path:
|
||
/// without it, a session denied every value on an equipment could still subscribe to its event
|
||
/// notifier and receive every alarm the equipment raises, including the condition fields.
|
||
/// </remarks>
|
||
public override ServiceResult SubscribeToEvents(
|
||
OperationContext context,
|
||
object sourceId,
|
||
uint subscriptionId,
|
||
IEventMonitoredItem monitoredItem,
|
||
bool unsubscribe)
|
||
{
|
||
// ⚠️ sourceId is the MANAGER HANDLE, not a NodeState — the same `object`-typed trap as
|
||
// ContinuationPoint.NodeToBrowse. Casting it to NodeState compiles, always yields null, and
|
||
// silently disables this gate; IsHandleInNamespace is the SDK's own resolver and cannot be
|
||
// wrong about it.
|
||
//
|
||
// Only creation is gated. Refusing an UNSUBSCRIBE would strand the item on a node the session can
|
||
// no longer reach — the client could never detach it, and the server would keep delivering.
|
||
if (AclEnforced && !unsubscribe
|
||
&& IsHandleInNamespace(sourceId) is { } source
|
||
&& !Permits(IdentityOf(context), OpcUaOperation.CreateMonitoredItems, source.NodeId))
|
||
{
|
||
return StatusCodes.BadUserAccessDenied;
|
||
}
|
||
|
||
if (unsubscribe)
|
||
{
|
||
// Gitea #562: an item RevalidateMonitoredItems stood down is ALREADY off its notifier's fan-out.
|
||
// Handing its delete to the SDK would make it remove an item that is not there — at best a
|
||
// spurious BadNodeIdUnknown on the client's delete, at worst a NullReferenceException inside
|
||
// MonitoredNode2.Remove when the node's event list has gone null. Checked and dropped under the
|
||
// same Lock the revalidation pass re-attaches under, so the two cannot interleave into
|
||
// re-attaching an item the client has just deleted.
|
||
lock (Lock)
|
||
{
|
||
if (_eventItems.TryRemove(monitoredItem, out var stoodDown) && stoodDown.Suppressed)
|
||
return ServiceResult.Good;
|
||
}
|
||
|
||
return base.SubscribeToEvents(context, sourceId, subscriptionId, monitoredItem, unsubscribe);
|
||
}
|
||
|
||
var result = base.SubscribeToEvents(context, sourceId, subscriptionId, monitoredItem, unsubscribe);
|
||
TrackLiveEventItem(sourceId, monitoredItem);
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Starts (or refreshes) tracking a direct event subscription for revalidation (Gitea #562).
|
||
/// </summary>
|
||
/// <param name="sourceId">The manager handle the item subscribed through.</param>
|
||
/// <param name="monitoredItem">The event item.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Only items the SDK actually attached are tracked — a subscribe the SDK refused (a source that
|
||
/// is not an event notifier, an unknown node) has nothing to stand down. The SDK reports an item
|
||
/// that was already attached (a <c>ModifyMonitoredItems</c> re-subscribe) as a non-Good status
|
||
/// while having re-attached it anyway, so attachment is read off the fan-out list itself rather
|
||
/// than inferred from the status the subscribe returned.
|
||
/// </para>
|
||
/// <para>
|
||
/// A re-subscribe of an item that was stood down is the client re-asking, and it has just passed
|
||
/// the creation gate above, so the entry is replaced with a live one.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void TrackLiveEventItem(object sourceId, IEventMonitoredItem monitoredItem)
|
||
{
|
||
if (IsHandleInNamespace(sourceId) is not { } handle || !OwnsNamespace(handle.NodeId)) return;
|
||
|
||
lock (Lock)
|
||
{
|
||
if (MonitoredNodes.TryGetValue(handle.NodeId, out var monitored)
|
||
&& monitored.EventMonitoredItems?.Contains(monitoredItem) == true)
|
||
{
|
||
_eventItems[monitoredItem] = new LiveEventItem(handle);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// Re-checked on every mode change so a client cannot re-enable an item that
|
||
/// <see cref="RevalidateMonitoredItems"/> disabled when its grant was revoked. Disabling is always
|
||
/// allowed: a session that has lost its grant must still be able to stand its items down.
|
||
/// </remarks>
|
||
protected override ServiceResult SetMonitoringMode(
|
||
ServerSystemContext context,
|
||
IMonitoredItem monitoredItem,
|
||
MonitoringMode monitoringMode,
|
||
NodeHandle handle)
|
||
{
|
||
if (AclEnforced && monitoringMode != MonitoringMode.Disabled
|
||
&& !Permits(IdentityOf(context), OpcUaOperation.CreateMonitoredItems, handle.NodeId))
|
||
{
|
||
return StatusCodes.BadUserAccessDenied;
|
||
}
|
||
|
||
return base.SetMonitoringMode(context, monitoredItem, monitoringMode, handle);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void OnMonitoredItemCreated(
|
||
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
|
||
{
|
||
base.OnMonitoredItemCreated(context, handle, monitoredItem);
|
||
TrackLiveMonitoredItem(handle, monitoredItem);
|
||
}
|
||
|
||
/// <summary>Starts tracking <paramref name="monitoredItem"/> for revalidation, if we own its node.</summary>
|
||
/// <param name="handle">The handle the item was created against.</param>
|
||
/// <param name="monitoredItem">The item.</param>
|
||
/// <remarks>
|
||
/// Split out of <see cref="OnMonitoredItemCreated"/> so tests can register an item without a client
|
||
/// session; the SDK reaches it only through that override.
|
||
/// </remarks>
|
||
internal void TrackLiveMonitoredItem(NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
|
||
{
|
||
if (OwnsNamespace(handle.NodeId)) _liveItems[monitoredItem] = new LiveItem(handle);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void OnMonitoredItemDeleted(
|
||
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
|
||
{
|
||
_liveItems.TryRemove(monitoredItem, out _);
|
||
base.OnMonitoredItemDeleted(context, handle, monitoredItem);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Live data-change monitored items on nodes this manager owns, so a grant revoked by a deployment
|
||
/// can reach the subscriptions it was authorizing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Keyed by the item itself; the value is the node it monitors. Maintained by
|
||
/// <see cref="OnMonitoredItemCreated"/> / <see cref="OnMonitoredItemDeleted"/>, which the SDK calls
|
||
/// for every create and delete including subscription teardown, so the map cannot outlive the items.
|
||
/// </remarks>
|
||
private readonly ConcurrentDictionary<ISampledDataChangeMonitoredItem, LiveItem> _liveItems = new();
|
||
|
||
/// <summary>A tracked monitored item: the handle it was created against, and whether we have silenced it.</summary>
|
||
/// <param name="Handle">The node handle, which carries the <see cref="MonitoredNode2"/> fan-out list.</param>
|
||
private sealed record LiveItem(NodeHandle Handle)
|
||
{
|
||
/// <summary>True while the item has been detached from its node's fan-out for want of a grant.</summary>
|
||
public bool Suppressed { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Live <b>event</b> monitored items subscribed directly to a notifier this manager owns (Gitea #562),
|
||
/// the event-side twin of <see cref="_liveItems"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A second map because the SDK keeps the two kinds apart: a data-change item reaches this
|
||
/// manager through <see cref="OnMonitoredItemCreated"/>, an event item only through
|
||
/// <see cref="SubscribeToEvents(OperationContext, object, uint, IEventMonitoredItem, bool)"/>, and
|
||
/// they hang off different lists of <see cref="MonitoredNode2"/>. Before #562 nothing tracked event
|
||
/// items at all, so an alarm feed authorized at creation survived every later revocation.
|
||
/// </para>
|
||
/// <para>
|
||
/// Maintained by that override's subscribe/unsubscribe arms — the SDK routes every create, modify
|
||
/// and delete of a direct event item through it, subscription teardown included. Mutated under
|
||
/// <c>Lock</c>, so the membership check the revalidation pass makes before re-attaching an item
|
||
/// cannot race a client deleting it.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Server-object (all-events) subscriptions are not in this map.</b> They are not attached to
|
||
/// any notifier of ours at all; they are held in <see cref="_allEventsItems"/> and decided per
|
||
/// event at delivery (Gitea #586), so there is nothing for this map's stand-down to detach.
|
||
/// </para>
|
||
/// </remarks>
|
||
private readonly ConcurrentDictionary<IEventMonitoredItem, LiveEventItem> _eventItems = new();
|
||
|
||
/// <summary>
|
||
/// Live Server-object (<c>i=2253</c>) event monitored items — every item the SDK handed this manager
|
||
/// through <see cref="SubscribeToAllEvents"/> (Gitea #586).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A set (the value is unused). Maintained by <see cref="SubscribeToAllEvents"/>'s subscribe and
|
||
/// unsubscribe arms, which the SDK's <c>MasterNodeManager</c> drives for every create, modify,
|
||
/// transfer, restore and delete of an all-events item, subscription teardown included.
|
||
/// </remarks>
|
||
private readonly ConcurrentDictionary<IEventMonitoredItem, byte> _allEventsItems = new();
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Gitea #586.</b> The SDK's base implementation attaches a Server-object item to the event
|
||
/// fan-out of every one of this manager's root notifiers, so every alarm in the server reached it
|
||
/// with no ACL decision at all — and, for an item created before a root notifier existed (the
|
||
/// SDK's <c>AddRootNotifier</c> calls its subscribe with <c>unsubscribe: true</c>), through the
|
||
/// root notifier's forward to <c>Server.ReportEvent</c> and the core node manager's Server object,
|
||
/// which this manager never sees. The Server object is where every condition lands exactly once,
|
||
/// so it was the one feed carrying every alarm, ungated.
|
||
/// </para>
|
||
/// <para>
|
||
/// This override attaches the item to nothing. It records it, and each condition hands its events
|
||
/// to the recorded items itself (<see cref="OnConditionReportEvent"/>), deciding every item against
|
||
/// the authorizer at delivery time. With no attachment there is no fan-out to keep in step with a
|
||
/// rebuild, and no stand-down to reverse: revalidation is implicit, because every event re-asks.
|
||
/// </para>
|
||
/// <para>
|
||
/// Creation is never refused: an all-events item is legitimate for a session granted part of the
|
||
/// address space, so what is withheld is each event the session may not see, not the item.
|
||
/// </para>
|
||
/// </remarks>
|
||
public override ServiceResult SubscribeToAllEvents(
|
||
OperationContext context, uint subscriptionId, IEventMonitoredItem monitoredItem, bool unsubscribe)
|
||
{
|
||
lock (Lock)
|
||
{
|
||
if (unsubscribe) _allEventsItems.TryRemove(monitoredItem, out _);
|
||
else _allEventsItems[monitoredItem] = 0;
|
||
ReconcileAllEventsMonitoring();
|
||
}
|
||
|
||
return ServiceResult.Good;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A Server-object (all-events) item is never attached to a notifier of ours (Gitea #586), so a
|
||
/// subscribe or unsubscribe of one against a notifier is a no-op here. The only SDK caller that
|
||
/// still reaches this with such an item is <c>AddRootNotifier</c>, which — an SDK bug — "subscribes"
|
||
/// every existing all-events item to a new root notifier with <c>unsubscribe: true</c>. Passed to the
|
||
/// base, that lowers <see cref="NodeState.AreEventsMonitored"/> on the new notifier, its children and
|
||
/// its forward notifiers — taking back, from every condition materialised under a live item, the
|
||
/// unit <see cref="ReconcileAllEventsMonitoring"/> just gave it (and, before #586, a folder
|
||
/// subscriber's unit too). Caught by the rebuild leg of the monitored-bookkeeping test.
|
||
/// </para>
|
||
/// <para>
|
||
/// Direct notifier items, including the #562 revalidation pass's attach and detach, go to the base
|
||
/// unchanged.
|
||
/// </para>
|
||
/// </remarks>
|
||
protected override ServiceResult SubscribeToEvents(
|
||
ServerSystemContext context, NodeState source, IEventMonitoredItem monitoredItem, bool unsubscribe) =>
|
||
monitoredItem.MonitoringAllEvents
|
||
? ServiceResult.Good
|
||
: base.SubscribeToEvents(context, source, monitoredItem, unsubscribe);
|
||
|
||
/// <summary>
|
||
/// The conditions currently carrying this manager's one "a Server-object item is listening" unit of
|
||
/// <see cref="NodeState.AreEventsMonitored"/> (Gitea #586 review).
|
||
/// </summary>
|
||
/// <remarks>Mutated only by <see cref="ReconcileAllEventsMonitoring"/>, under <c>Lock</c>.</remarks>
|
||
private readonly HashSet<AlarmConditionState> _allEventsMonitoredConditions =
|
||
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||
|
||
/// <summary>
|
||
/// Keeps <see cref="NodeState.AreEventsMonitored"/> raised on every materialised condition while at
|
||
/// least one Server-object item exists, and lowered once none does. Call under <c>Lock</c>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The SDK base attached an all-events item to every root notifier through its own
|
||
/// <c>SubscribeToEvents</c>, which also calls <c>SetAreEventsMonitored(true, includeChildren)</c>
|
||
/// on the notifier — a counter that propagates to every condition beneath or wired to it. Not
|
||
/// attaching the items lost that bookkeeping, and a condition reads it: <c>ConditionState</c>
|
||
/// reports its method audit events only while <c>EventsMonitored()</c> is true. A server whose only
|
||
/// subscribers are Server-object items — the common machine-consumer shape — would otherwise report
|
||
/// none of them. (The method-driven <i>state-change</i> events no longer depend on this: the SDK
|
||
/// reports none, and <see cref="WireMethodTransitionReporting"/> reports them unconditionally —
|
||
/// Gitea #587. That also takes the SDK counter's known imprecision — a folder subscriber's unit never
|
||
/// reaches a condition materialised under the folder later, yet its unsubscribe still lowers it — off
|
||
/// the state-change path; it can now cost at most an audit event while auditing is on.)
|
||
/// </para>
|
||
/// <para>
|
||
/// Held per <b>condition</b>, exactly one unit each, rather than per root notifier per item as the
|
||
/// base did. The counter is shared with the folder subscribers' own units, so it must balance
|
||
/// exactly; a notifier-level unit would also reach a condition materialised under an already-
|
||
/// raised folder never, or be taken back from one it never reached. This reconciles the desired
|
||
/// set (every condition, while any item exists) against what it has raised, so materialise,
|
||
/// remove, rebuild and subscribe all converge through one place.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void ReconcileAllEventsMonitoring()
|
||
{
|
||
// The protected SubscribeToEvents override is what stops the SDK taking these units back.
|
||
lock (Lock)
|
||
{
|
||
var desired = _allEventsItems.IsEmpty
|
||
? new HashSet<AlarmConditionState>(System.Collections.Generic.ReferenceEqualityComparer.Instance)
|
||
: new HashSet<AlarmConditionState>(
|
||
_alarmConditions.Values, System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||
|
||
AllEventsMonitoringConditionVisits += desired.Count + _allEventsMonitoredConditions.Count;
|
||
|
||
foreach (var condition in _allEventsMonitoredConditions.Where(c => !desired.Contains(c)).ToList())
|
||
{
|
||
condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
|
||
_allEventsMonitoredConditions.Remove(condition);
|
||
}
|
||
|
||
foreach (var condition in desired)
|
||
{
|
||
if (_allEventsMonitoredConditions.Add(condition))
|
||
condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The incremental form of <see cref="ReconcileAllEventsMonitoring"/> for one newly materialised
|
||
/// condition: raises its unit when a Server-object item exists, touching no other condition. Call under
|
||
/// <c>Lock</c>.
|
||
/// </summary>
|
||
/// <param name="condition">The condition just added to <c>_alarmConditions</c>.</param>
|
||
/// <remarks>
|
||
/// Equivalent to the full reconcile on the materialise path, because materialising adds one condition
|
||
/// and removes none (a same-key kind-swap gives the dropped instance's unit back itself). Kept separate
|
||
/// so a deploy of N alarms costs N set operations rather than N²/2 (review of Gitea #586).
|
||
/// </remarks>
|
||
private void RaiseAllEventsMonitoring(AlarmConditionState condition)
|
||
{
|
||
AllEventsMonitoringConditionVisits++;
|
||
if (!_allEventsItems.IsEmpty && _allEventsMonitoredConditions.Add(condition))
|
||
condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// How many conditions the Server-object monitored bookkeeping has visited since start — every
|
||
/// condition a full <see cref="ReconcileAllEventsMonitoring"/> walks, plus one per incremental
|
||
/// <see cref="RaiseAllEventsMonitoring"/>. Test observability: bounds the bookkeeping cost of a deploy.
|
||
/// </summary>
|
||
/// <remarks>Mutated only under <c>Lock</c>.</remarks>
|
||
internal long AllEventsMonitoringConditionVisits { get; private set; }
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The base is the delegate <c>AddRootNotifier</c> installs on a root notifier: it forwards every
|
||
/// event reaching that folder to <c>Server.ReportEvent</c>, where the core node manager's Server
|
||
/// object queues it to every all-events item with no ACL decision — the bypass Gitea #586 closes.
|
||
/// Server-object items now receive conditions from the condition itself
|
||
/// (<see cref="OnConditionReportEvent"/>), so the forward could only ever deliver an ungated
|
||
/// duplicate. Dropped.
|
||
/// </para>
|
||
/// <para>
|
||
/// Only condition events bubble through this manager's notifiers; the audit and model-change
|
||
/// events it raises never reach this, and go through <see cref="ReportToServerObject"/> instead
|
||
/// (Gitea #588).
|
||
/// </para>
|
||
/// </remarks>
|
||
protected override void OnReportEvent(ISystemContext context, NodeState node, IFilterTarget e)
|
||
{
|
||
}
|
||
|
||
/// <summary>
|
||
/// Delivers one condition event to the Server-object (all-events) subscriptions this manager holds,
|
||
/// item by item, through the data-plane ACL (Gitea #586).
|
||
/// </summary>
|
||
/// <param name="context">The reporting context; a session-bearing one restricts delivery to that session.</param>
|
||
/// <param name="node">The condition that reported.</param>
|
||
/// <param name="e">The event — the condition's snapshot.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Installed as each condition's <c>OnReportEvent</c> in <see cref="MaterialiseAlarmCondition"/>,
|
||
/// so it sees every event the condition reports exactly once, whichever notifiers it then bubbles
|
||
/// through — the one-copy-at-the-Server-object behaviour the multi-notifier wiring relies on.
|
||
/// </para>
|
||
/// <para>
|
||
/// Everything except the ACL decision mirrors the SDK's <c>MonitoredNode2.OnReportEvent</c>, which
|
||
/// is what delivered these events before: the SDK's own role-permission check, and the rule that an
|
||
/// event reported under a session's context reaches only that session's items.
|
||
/// </para>
|
||
/// <para>
|
||
/// Inert while <c>Security:Acl:Enabled</c> is false — every item receives every condition, as it
|
||
/// always has.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void OnConditionReportEvent(ISystemContext context, NodeState node, IFilterTarget e)
|
||
{
|
||
if (_allEventsItems.IsEmpty) return;
|
||
|
||
var scopes = AclEnforced ? ConditionVisibilityScopes(node) : null;
|
||
var reportingSession = (context as ISessionSystemContext)?.SessionId?.Identifier;
|
||
|
||
foreach (var item in _allEventsItems.Keys)
|
||
{
|
||
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
|
||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||
if (reportingSession is not null
|
||
&& item.Session?.Id?.Identifier is { } own
|
||
&& !own.Equals(reportingSession))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
item.QueueEvent(e);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The SDK's audit-event guard from <c>MonitoredNode2.OnReportEvent</c>, reproduced exactly: an audit
|
||
/// event reaches an item only while the server is auditing, and only over a SignAndEncrypt or
|
||
/// https-binary endpoint.
|
||
/// </summary>
|
||
/// <param name="item">The Server-object event item.</param>
|
||
/// <returns><see langword="true"/> when the audit event may be queued to the item.</returns>
|
||
/// <remarks>
|
||
/// A condition reports its Part 9 method audit events (<c>AuditConditionAcknowledgeEventType</c>,
|
||
/// Comment, Enable, Shelving…) through its own <c>ReportEvent</c>, so they pass through
|
||
/// <see cref="OnConditionReportEvent"/>. Before #586 the Server-object copy took the root-notifier
|
||
/// forward, where <c>ServerInternalData.ReportEvent</c> dropped every audit event unless auditing was
|
||
/// on; without this guard they reached every Server-object item regardless (caught in review, flag off).
|
||
/// </remarks>
|
||
private bool IsAuditDeliverableTo(IEventMonitoredItem item)
|
||
{
|
||
if (!Server.Auditing) return false;
|
||
var endpoint = item.Session?.EndpointDescription;
|
||
return endpoint?.SecurityMode == MessageSecurityMode.SignAndEncrypt
|
||
|| endpoint?.TransportProfileUri == Profiles.HttpsBinaryTransport;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reports a server-wide event this manager raises — a <c>GeneralModelChangeEvent</c> or an audit event
|
||
/// — to the Server object's (<c>i=2253</c>) subscribers, through the data-plane ACL when it is enforced
|
||
/// (Gitea #588).
|
||
/// </summary>
|
||
/// <param name="e">The built event.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// These events are not condition events, so they never pass a notifier of ours: they went straight
|
||
/// to <c>Server.ReportEvent</c>, where the core node manager's Server object queues them to every
|
||
/// all-events item with no ACL decision. A model-change event names the RawPath / UNS path of the
|
||
/// node it is about, so a session holding no grant learned the names of nodes it could not browse;
|
||
/// an audit write event would carry the written value to it too.
|
||
/// </para>
|
||
/// <para>
|
||
/// With enforcement on, this manager delivers them itself, item by item, to the all-events items it
|
||
/// already holds for #586 — the same audience <c>Server.ReportEvent</c> reached, since only Server-object
|
||
/// items receive what is reported at the Server object. Each item receives only what its session may
|
||
/// see (<see cref="ServerObjectEventFor"/>); everything else the SDK's Server object did — its audit
|
||
/// guard, its role-permission check — is kept. With enforcement off the event goes to
|
||
/// <c>Server.ReportEvent</c> exactly as before.
|
||
/// </para>
|
||
/// <para>
|
||
/// Called outside <c>Lock</c>, like the <c>Server.ReportEvent</c> call it replaces; the decision
|
||
/// takes no node-manager lock.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void ReportToServerObject(BaseEventState e)
|
||
{
|
||
if (!AclEnforced)
|
||
{
|
||
Server.ReportEvent(SystemContext, e);
|
||
return;
|
||
}
|
||
|
||
// ServerInternalData.ReportEvent's own guard: no audit event leaves the server while it is not auditing.
|
||
if (e is AuditEventState && !Server.Auditing) return;
|
||
|
||
foreach (var item in _allEventsItems.Keys)
|
||
{
|
||
if (ServerObjectEventFor(item, e) is not { } visible) continue;
|
||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, visible))) continue;
|
||
item.QueueEvent(visible);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// What of a server-wide event <paramref name="item"/>'s session may receive with enforcement on: the
|
||
/// event itself, a copy narrowed to what it may see, or nothing (Gitea #588).
|
||
/// </summary>
|
||
/// <param name="item">A Server-object event item.</param>
|
||
/// <param name="e">The event.</param>
|
||
/// <returns>The event to queue, or <see langword="null"/> to withhold it.</returns>
|
||
/// <remarks>
|
||
/// <list type="bullet">
|
||
/// <item>
|
||
/// A <b>model-change</b> event keeps only the changes whose <c>Affected</c> node the session may
|
||
/// <c>Browse</c> (<see cref="FilterModelChangeFor"/>) — knowing a node exists is exactly what
|
||
/// Browse grants, ancestors of a granted node included.
|
||
/// </item>
|
||
/// <item>
|
||
/// An <b>audit</b> event needs the SDK's own delivery guard (<see cref="IsAuditDeliverableTo"/>)
|
||
/// <b>and</b> <c>Read</c> on its source node: an <c>AuditWriteUpdateEvent</c> carries the node's
|
||
/// prior value and the attempted one, which is what <c>Read</c> protects.
|
||
/// </item>
|
||
/// <item>Anything else is withheld: this manager reports no other server-wide event type, so an
|
||
/// unrecognised one fails closed rather than slipping through ungated.</item>
|
||
/// </list>
|
||
/// </remarks>
|
||
private BaseEventState? ServerObjectEventFor(IEventMonitoredItem item, BaseEventState e)
|
||
{
|
||
var identity = item.EffectiveIdentity as RoleCarryingUserIdentity;
|
||
return e switch
|
||
{
|
||
GeneralModelChangeEventState modelChange => FilterModelChangeFor(identity, modelChange),
|
||
AuditEventState audit => IsAuditDeliverableTo(item)
|
||
&& audit.SourceNode?.Value is { IsNullNodeId: false } source
|
||
&& Permits(identity, OpcUaOperation.Read, source)
|
||
? audit
|
||
: null,
|
||
_ => null,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// A model-change event narrowed to the changes whose affected node <paramref name="identity"/> may
|
||
/// browse: the event itself when every change is visible, <see langword="null"/> when none is, otherwise
|
||
/// a copy carrying only the visible ones (Gitea #588).
|
||
/// </summary>
|
||
/// <param name="identity">The session's identity.</param>
|
||
/// <param name="e">The model-change event.</param>
|
||
/// <returns>The event to deliver, or <see langword="null"/> to withhold it.</returns>
|
||
/// <remarks>
|
||
/// Every builder in this manager emits a single change, so in practice the event is delivered whole or
|
||
/// not at all; the copy keeps a future multi-change event from leaking the hidden names, or from hiding
|
||
/// the visible ones. The copy keeps the original's <c>EventId</c>, time and source, with a neutral
|
||
/// <c>Message</c> (the builders' messages name the affected node). A change with no
|
||
/// <c>Affected</c> node names nothing and is kept.
|
||
/// </remarks>
|
||
internal GeneralModelChangeEventState? FilterModelChangeFor(
|
||
RoleCarryingUserIdentity? identity, GeneralModelChangeEventState e)
|
||
{
|
||
var changes = e.Changes?.Value;
|
||
if (changes is not { Length: > 0 }) return e;
|
||
|
||
var visible = changes
|
||
.Where(c => c.Affected is null || c.Affected.IsNullNodeId || Permits(identity, OpcUaOperation.Browse, c.Affected))
|
||
.ToArray();
|
||
if (visible.Length == changes.Length) return e;
|
||
if (visible.Length == 0) return null;
|
||
|
||
// The original Message names the affected node(s), so the copy carries a neutral one.
|
||
var copy = new GeneralModelChangeEventState(null);
|
||
copy.Initialize(
|
||
SystemContext, source: null, (EventSeverity)e.Severity.Value, new LocalizedText($"{visible.Length} node(s) changed"));
|
||
copy.EventId.Value = e.EventId.Value;
|
||
copy.Time.Value = e.Time.Value;
|
||
copy.ReceiveTime.Value = e.ReceiveTime.Value;
|
||
copy.SetChildValue(SystemContext, BrowseNames.SourceNode, e.SourceNode?.Value ?? ObjectIds.Server, false);
|
||
copy.SetChildValue(SystemContext, BrowseNames.SourceName, e.SourceName?.Value ?? "Server", false);
|
||
copy.SetChildValue(SystemContext, BrowseNames.Changes, visible, false);
|
||
return copy;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The nodes whose grant admits a condition to a session: the condition itself, every root-notifier
|
||
/// folder its events bubble up through, and every equipment folder wired as its extra notifier.
|
||
/// </summary>
|
||
/// <param name="condition">The condition.</param>
|
||
/// <returns>The candidate scopes; <c>Subscribe</c> on any one of them admits the condition.</returns>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The folders are exactly the per-folder path's question. A session that may subscribe to a
|
||
/// notifier the condition reports through already receives the condition there, so the Server
|
||
/// object withholding it would protect nothing; a session granted on none of them receives it
|
||
/// nowhere. The folders must be asked as well as the condition because the trie inherits downward
|
||
/// only within a realm: an <c>Equipment</c> grant covers the UNS folder a native condition is wired
|
||
/// into, but not the condition, which lives at its RawPath in the raw realm.
|
||
/// </para>
|
||
/// <para>
|
||
/// The condition itself (<c>ConditionId</c> = RawPath for a native alarm) admits a grant scoped to
|
||
/// the alarm's own raw tag — the tag's grant covers its alarm wherever the alarm can be named.
|
||
/// </para>
|
||
/// </remarks>
|
||
private List<NodeId> ConditionVisibilityScopes(NodeState condition)
|
||
{
|
||
var scopes = new List<NodeId> { condition.NodeId };
|
||
|
||
lock (Lock)
|
||
{
|
||
for (var ancestor = (condition as BaseInstanceState)?.Parent;
|
||
ancestor is not null;
|
||
ancestor = (ancestor as BaseInstanceState)?.Parent)
|
||
{
|
||
if (_notifierFolders.TryGetValue(ancestor.NodeId, out var notifier) && ReferenceEquals(notifier, ancestor))
|
||
scopes.Add(ancestor.NodeId);
|
||
}
|
||
|
||
if (_alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring)
|
||
&& ReferenceEquals(wiring.Alarm, condition))
|
||
{
|
||
scopes.AddRange(wiring.Folders.Select(f => f.NodeId));
|
||
}
|
||
}
|
||
|
||
return scopes;
|
||
}
|
||
|
||
/// <summary>Whether <paramref name="item"/>'s session may see a condition with the given scopes.</summary>
|
||
/// <param name="item">The Server-object event item.</param>
|
||
/// <param name="scopes">From <see cref="ConditionVisibilityScopes"/>.</param>
|
||
/// <returns><see langword="true"/> when the session holds <c>Subscribe</c> on any scope.</returns>
|
||
/// <remarks>
|
||
/// The identity is the one the item was created under — the thread delivering the event has no
|
||
/// session of its own — and the operation is the one the per-folder creation gate asks.
|
||
/// <see cref="Security.DataPlaneAuthorizer.IsAllowedCached"/> re-checks key revocation and expiry on
|
||
/// every call and keys its decision cache on the current generation, which is what makes revalidation
|
||
/// implicit here: a deployment or an expiry takes effect on the next event, with no pass to run.
|
||
/// </remarks>
|
||
private bool IsConditionVisibleTo(IEventMonitoredItem item, List<NodeId> scopes)
|
||
{
|
||
var identity = item.EffectiveIdentity as RoleCarryingUserIdentity;
|
||
foreach (var scope in scopes)
|
||
{
|
||
if (_authorizer!.IsAllowedCached(identity, OpcUaOperation.CreateMonitoredItems, scope, RealmOf(scope)))
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A refresh on a Server-object item is the other way it learns about conditions — it is how a
|
||
/// client connecting after a transition gets the current alarm picture — and the SDK base replays
|
||
/// every condition under every root notifier to it after only its own role-permission check. With
|
||
/// enforcement on, those items are refreshed here instead, through the same
|
||
/// <see cref="IsConditionVisibleTo"/> decision live delivery uses (Gitea #586); an event that names no
|
||
/// condition is withheld. With it off, the base runs unchanged (its per-notifier repeats of one
|
||
/// condition share an EventId, which the item's queue already collapses to one copy).
|
||
/// </para>
|
||
/// <para>
|
||
/// Items on one of this manager's own notifiers keep the base behaviour: a stood-down item is off
|
||
/// its notifier's fan-out, which the base already treats as "not refreshed".
|
||
/// </para>
|
||
/// </remarks>
|
||
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> monitoredItems)
|
||
{
|
||
if (!AclEnforced) return base.ConditionRefresh(context, monitoredItems);
|
||
|
||
var direct = new List<IEventMonitoredItem>();
|
||
var serverItems = new List<IEventMonitoredItem>();
|
||
foreach (var item in monitoredItems)
|
||
{
|
||
if (item is null) continue;
|
||
(item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item);
|
||
}
|
||
|
||
if (direct.Count > 0) base.ConditionRefresh(context, direct);
|
||
if (serverItems.Count == 0) return ServiceResult.Good;
|
||
|
||
var systemContext = SystemContext.Copy(context);
|
||
List<NodeState> notifiers;
|
||
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
|
||
|
||
var events = new List<IFilterTarget>();
|
||
foreach (var notifier in notifiers) notifier.ConditionRefresh(systemContext, events, true);
|
||
|
||
// One snapshot per condition, with its scopes resolved once.
|
||
var conditions = new List<(IFilterTarget Event, List<NodeId> Scopes)>();
|
||
var seen = new HashSet<NodeState>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||
foreach (var e in events)
|
||
{
|
||
if (ConditionOf(e) is not { } condition || !seen.Add(condition)) continue;
|
||
conditions.Add((e, ConditionVisibilityScopes(condition)));
|
||
}
|
||
|
||
foreach (var item in serverItems)
|
||
{
|
||
foreach (var (e, scopes) in conditions)
|
||
{
|
||
if (!IsConditionVisibleTo(item, scopes)) continue;
|
||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||
item.QueueEvent(e);
|
||
}
|
||
}
|
||
|
||
return ServiceResult.Good;
|
||
}
|
||
|
||
/// <summary>The condition a refresh event describes, or null when it names none (withheld: fail closed).</summary>
|
||
/// <param name="e">A refresh event.</param>
|
||
/// <returns>The condition node.</returns>
|
||
private static NodeState? ConditionOf(IFilterTarget e) => e switch
|
||
{
|
||
InstanceStateSnapshot { Handle: NodeState condition } => condition,
|
||
NodeState condition => condition,
|
||
_ => null,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Whether the condition at <paramref name="conditionNodeId"/> believes it is being listened to
|
||
/// (<c>ConditionState.EventsMonitored()</c>) — the gate on the SDK reporting its method-driven state
|
||
/// changes. Test observability for the Gitea #586 review.
|
||
/// </summary>
|
||
/// <param name="conditionNodeId">The condition's NodeId identifier.</param>
|
||
/// <param name="realm">The condition's realm.</param>
|
||
/// <returns><see langword="true"/> when the condition exists and reports itself monitored.</returns>
|
||
internal bool ConditionEventsMonitored(string conditionNodeId, AddressSpaceRealm realm)
|
||
{
|
||
lock (Lock)
|
||
{
|
||
return _alarmConditions.TryGetValue(MapKey(realm, conditionNodeId), out var condition)
|
||
&& condition.EventsMonitored();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// How many event items are attached to the event fan-out of nodes this manager owns. Test
|
||
/// observability for Gitea #562: an item resurrected onto a notifier after its client deleted it is
|
||
/// invisible to that client, so only the server side can show it.
|
||
/// </summary>
|
||
internal int AttachedEventItemCount
|
||
{
|
||
get
|
||
{
|
||
lock (Lock) return MonitoredNodes.Values.Sum(n => n.EventMonitoredItems?.Count ?? 0);
|
||
}
|
||
}
|
||
|
||
/// <summary>A tracked event item: the notifier it subscribed to, and whether we have stood it down.</summary>
|
||
/// <param name="Handle">The notifier's handle; its <see cref="NodeHandle.Node"/> is what the item is attached to.</param>
|
||
private sealed record LiveEventItem(NodeHandle Handle)
|
||
{
|
||
/// <summary>True while the item has been detached from its notifier's event fan-out for want of a grant.</summary>
|
||
public bool Suppressed { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Re-authorizes every live monitored item against the current generation, standing down the ones
|
||
/// whose grant has gone.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Why this is not optional.</b> Stage 5 made revocation reach an open session for write,
|
||
/// alarm commands and HistoryRead, because each of those re-asks on every request. A
|
||
/// subscription never re-asks: it is authorized once at creation and then streams. Subscriptions
|
||
/// are also how this server is actually consumed — ScadaBridge's Data Connection Layer
|
||
/// subscribes rather than polls — so without this, revoking a grant would stop the operations
|
||
/// nobody uses and leave the data flowing on the one everybody does.
|
||
/// </para>
|
||
/// <para>
|
||
/// A denied item is <b>told, then detached</b>: queued a <c>BadUserAccessDenied</c> so the client
|
||
/// learns why its data stopped rather than watching a feed go quiet, then removed from its node's
|
||
/// <see cref="MonitoredNode2"/> fan-out so no further value reaches it.
|
||
/// </para>
|
||
/// <para>
|
||
/// ⚠️ <b>Detaching, not <see cref="MonitoringMode.Disabled"/> — and the order matters.</b> The
|
||
/// obvious implementation is to queue the denial and then disable the item; it does not work,
|
||
/// and it fails in the quiet direction. Disabling an item <em>discards its queued notifications</em>,
|
||
/// so the client is silenced without ever being told why. This was caught by the over-the-wire
|
||
/// test, not by reasoning: the unit-level path reports the item as stood down either way.
|
||
/// </para>
|
||
/// <para>
|
||
/// Detaching is also reversible, which disabling is not from the server's side. A later
|
||
/// deployment that restores the grant re-attaches the item and the feed resumes on its own — a
|
||
/// disabled item would have needed the client to notice and re-enable it, which a machine
|
||
/// consumer will never do.
|
||
/// </para>
|
||
/// <para>
|
||
/// Items are <b>not deleted</b>. A node manager has no way to remove an item from a client's
|
||
/// subscription, and inventing one would leave the client's own bookkeeping describing items the
|
||
/// server had silently discarded.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>One verdict per (session, node), not per item.</b> A session commonly holds several items
|
||
/// on the same node — one per attribute, or one per downstream consumer sharing a session — and
|
||
/// they all ask the identical question. The pass memoizes the verdict locally so N items on a
|
||
/// node cost one <see cref="Security.DataPlaneAuthorizer.IsAllowedCached"/> call rather than N.
|
||
/// The authorizer's own decision cache does not remove this work: it memoizes the grant walk,
|
||
/// while the per-call key revocation/expiry check in front of it is deliberately outside that
|
||
/// cache and runs every time.
|
||
/// </para>
|
||
/// <para>
|
||
/// The per-item <c>lock (Lock)</c> takes are deliberately left alone. Batching them under one
|
||
/// hold was considered and rejected: it would stretch the SDK lock across the
|
||
/// <c>QueueValue</c> calls between them, and the attach/detach is what actually needs the lock.
|
||
/// </para>
|
||
/// </remarks>
|
||
public void RevalidateMonitoredItems()
|
||
{
|
||
if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return;
|
||
|
||
var now = DateTime.UtcNow;
|
||
var stoodDown = 0;
|
||
var restored = 0;
|
||
|
||
// Pass-local, so single-threaded by construction. A concurrent deployment can advance the
|
||
// generation mid-pass and leave later items decided on the earlier one — exactly as the
|
||
// un-memoized walk already could, and the change that advanced it runs its own pass.
|
||
var verdicts = new Dictionary<(string Session, AddressSpaceRealm Realm, string Node), bool>();
|
||
|
||
foreach (var (item, tracked) in _liveItems)
|
||
{
|
||
// The item carries the identity of the session that created it, which is what must be
|
||
// re-checked — the deploy thread running this has no session of its own.
|
||
if (item is not IMonitoredItem live) continue;
|
||
|
||
var nodeId = tracked.Handle.NodeId;
|
||
var permitted = IsSubscriptionStillPermitted(
|
||
live.EffectiveIdentity as RoleCarryingUserIdentity, nodeId, verdicts);
|
||
|
||
if (permitted)
|
||
{
|
||
if (!tracked.Suppressed) continue;
|
||
|
||
lock (Lock) tracked.Handle.MonitoredNode?.Add(item);
|
||
tracked.Suppressed = false;
|
||
restored++;
|
||
continue;
|
||
}
|
||
|
||
if (tracked.Suppressed) continue;
|
||
|
||
// Queue BEFORE detaching: the notification has to be in the item's queue while the item is
|
||
// still live, or the client is cut off with no explanation.
|
||
item.QueueValue(
|
||
new DataValue(StatusCodes.BadUserAccessDenied) { SourceTimestamp = now, ServerTimestamp = now },
|
||
new ServiceResult(StatusCodes.BadUserAccessDenied),
|
||
ignoreFilters: true);
|
||
|
||
lock (Lock) tracked.Handle.MonitoredNode?.Remove(item);
|
||
tracked.Suppressed = true;
|
||
stoodDown++;
|
||
}
|
||
|
||
var (eventsStoodDown, eventsRestored) = RevalidateEventItems(verdicts);
|
||
|
||
if (stoodDown > 0 || restored > 0)
|
||
{
|
||
_authorizer!.LogFiltered(
|
||
"RevalidateSubscriptions", principal: null, stoodDown, _liveItems.Count);
|
||
}
|
||
|
||
if (eventsStoodDown > 0 || eventsRestored > 0)
|
||
{
|
||
_authorizer!.LogFiltered(
|
||
"RevalidateEventSubscriptions", principal: null, eventsStoodDown, _eventItems.Count);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The event-item half of <see cref="RevalidateMonitoredItems"/> (Gitea #562): re-authorizes every
|
||
/// tracked direct event subscription against its notifier, standing down the denied ones and
|
||
/// re-attaching the restored ones.
|
||
/// </summary>
|
||
/// <param name="verdicts">The pass-local memo shared with the data-change half.</param>
|
||
/// <returns>How many items were stood down and how many restored.</returns>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The verdict is the same question the creation gate in
|
||
/// <see cref="SubscribeToEvents(OperationContext, object, uint, IEventMonitoredItem, bool)"/>
|
||
/// asks — <c>Subscribe</c> on the notifier's scope, for the identity of the session that created
|
||
/// the item — so an event feed is held to exactly the authority it was admitted under, re-asked on
|
||
/// both triggers (a deployment, and the host's <c>AclRevalidationTimer</c>).
|
||
/// </para>
|
||
/// <para>
|
||
/// ⚠️ <b>Detached through the SDK's own unsubscribe, never <see cref="MonitoringMode.Disabled"/>.</b>
|
||
/// Same reason as the data-change half: a disabled item can only be re-enabled by the client, and
|
||
/// a machine consumer will never try, so a revocation reversed by the next deployment would leave
|
||
/// the feed dead. The stand-down is the SDK's protected <c>SubscribeToEvents(…, unsubscribe:
|
||
/// true)</c> — precisely what the client's own delete does to the notifier's
|
||
/// <see cref="MonitoredNode2"/> fan-out — and the restore is the same call with
|
||
/// <c>unsubscribe: false</c>, so neither leaves the fan-out in a shape a client could not have
|
||
/// produced. The item itself stays in the client's subscription untouched.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The client is not told.</b> Unlike a data-change item, an event item has no value slot to
|
||
/// carry a <c>BadUserAccessDenied</c>: its notifications are event field lists, and inventing an
|
||
/// event to say "access denied" would put a fabricated event in front of every alarm consumer. The
|
||
/// feed goes quiet, and the operator-facing signal is the summary line this pass logs.
|
||
/// </para>
|
||
/// <para>
|
||
/// Membership is re-checked under <c>Lock</c> immediately before each attach/detach: the walk is
|
||
/// over a concurrent snapshot, and re-attaching an item the client deleted mid-pass would leave it
|
||
/// on the notifier's fan-out with no owner and no way to remove it.
|
||
/// </para>
|
||
/// </remarks>
|
||
private (int StoodDown, int Restored) RevalidateEventItems(
|
||
Dictionary<(string Session, AddressSpaceRealm Realm, string Node), bool> verdicts)
|
||
{
|
||
var stoodDown = 0;
|
||
var restored = 0;
|
||
|
||
foreach (var (item, tracked) in _eventItems)
|
||
{
|
||
var permitted = IsSubscriptionStillPermitted(
|
||
item.EffectiveIdentity as RoleCarryingUserIdentity, tracked.Handle.NodeId, verdicts);
|
||
if (permitted != tracked.Suppressed) continue;
|
||
|
||
lock (Lock)
|
||
{
|
||
if (!_eventItems.TryGetValue(item, out var current) || !ReferenceEquals(current, tracked)) continue;
|
||
|
||
SubscribeToEvents(SystemContext, tracked.Handle.Node, item, unsubscribe: !permitted);
|
||
tracked.Suppressed = !permitted;
|
||
}
|
||
|
||
if (permitted) restored++;
|
||
else stoodDown++;
|
||
}
|
||
|
||
return (stoodDown, restored);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The per-item verdict for <see cref="RevalidateMonitoredItems"/>, memoized across the pass.
|
||
/// </summary>
|
||
/// <param name="identity">The identity of the session that created the item.</param>
|
||
/// <param name="nodeId">The node the item monitors.</param>
|
||
/// <param name="verdicts">The pass-local memo, keyed by session, realm and node identifier.</param>
|
||
/// <returns><see langword="true"/> when the item may keep receiving values.</returns>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The session key is <c>ApiKeyId ?? Principal</c> — the same derivation
|
||
/// <see cref="Security.DataPlaneAuthorizer"/> uses for its own decision cache, so this memo is
|
||
/// never coarser than the cache it sits in front of. The realm is part of the key for the same
|
||
/// reason it is part of the authorizer's: the raw and UNS namespaces have independent string
|
||
/// identifier spaces, so an identifier alone does not name a node.
|
||
/// </para>
|
||
/// <para>
|
||
/// Anything the key cannot be built from — no role-carrying identity, no principal, a node
|
||
/// identifier that is not a non-empty string — falls through to the un-memoized call and keeps
|
||
/// its existing verdict. Those are the cases the authorizer denies anyway; memoizing them would
|
||
/// only make a deny cheaper. The empty identifier is excluded for the same reason
|
||
/// <see cref="Security.DataPlaneAuthorizer"/> excludes it from its own decision cache: it
|
||
/// addresses no ACL scope, so it is not an identity to key on.
|
||
/// </para>
|
||
/// <para>
|
||
/// This memo does cover the key expiry check that the authorizer's own cache excludes, but only
|
||
/// for the duration of one pass. Since arch-review 03/S19 a subscription is re-checked on the
|
||
/// host's low-frequency timer as well as at a deployment, so an expiry that lands inside a
|
||
/// single pass is caught by the next TICK — one interval later, not one deployment later. What
|
||
/// makes that hold is that the memo is constructed per call and never reused across passes.
|
||
/// </para>
|
||
/// </remarks>
|
||
private bool IsSubscriptionStillPermitted(
|
||
RoleCarryingUserIdentity? identity,
|
||
NodeId nodeId,
|
||
Dictionary<(string Session, AddressSpaceRealm Realm, string Node), bool> verdicts)
|
||
{
|
||
var realm = RealmOf(nodeId);
|
||
|
||
if (identity is null
|
||
|| (identity.ApiKeyId ?? identity.Principal) is not { Length: > 0 } sessionKey
|
||
|| nodeId.Identifier is not string { Length: > 0 } identifier)
|
||
{
|
||
return _authorizer!.IsAllowedCached(
|
||
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
|
||
}
|
||
|
||
var key = (sessionKey, realm, identifier);
|
||
if (verdicts.TryGetValue(key, out var memoized)) return memoized;
|
||
|
||
var permitted = _authorizer!.IsAllowedCached(
|
||
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
|
||
verdicts[key] = permitted;
|
||
return permitted;
|
||
}
|
||
|
||
/// <summary>Emits the one operator-facing summary line per gated request.</summary>
|
||
/// <param name="operation">The service being filtered.</param>
|
||
/// <param name="identity">The calling session's identity.</param>
|
||
/// <param name="denied">How many nodes or references were withheld.</param>
|
||
/// <param name="total">How many were considered.</param>
|
||
private void LogAclFiltering(string operation, RoleCarryingUserIdentity? identity, int denied, int total) =>
|
||
_authorizer?.LogFiltered(operation, identity?.Principal, denied, total);
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Phase C — OPC UA HistoryRead over historized variable nodes.
|
||
//
|
||
// The base CustomNodeManager2.HistoryRead (public + protected dispatcher) does the heavy lifting:
|
||
// it validates handles under Lock, builds `nodesToProcess` (a NodeHandle list for nodes WE own
|
||
// that carry the HistoryRead access bit), validates the timestamp args, handles
|
||
// `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
|
||
// protected virtuals below. We override all four arms: the three variable-history virtuals
|
||
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents). Each override
|
||
// receives the pre-filtered handles and fills results[handle.Index] / errors[handle.Index] —
|
||
// handle.Index is the original index into the service-level results/errors lists, seeded by the
|
||
// base. The base pre-seeds every handle's error to BadHistoryOperationUnsupported, so a handle
|
||
// we don't recognise stays "unsupported" by default.
|
||
//
|
||
// NOTE: unlike OnWriteValue, the SDK does NOT hold the node-manager Lock while invoking these, so
|
||
// block-bridging the async data source (GetAwaiter().GetResult()) is safe — it can't freeze the
|
||
// address space. Each handle is served in isolation under try/catch so one node's failure (timeout,
|
||
// backend throw) never throws out of the batch.
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Drops the handles in a HistoryRead batch the calling session is not granted
|
||
/// <see cref="OpcUaOperation.HistoryRead"/> on, marking each denied slot
|
||
/// <c>BadUserAccessDenied</c> (#520 stage 5).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Until this stage HistoryRead carried no authorization check whatsoever</b> — any session
|
||
/// that could open a connection could read the full history of every historized node. It was
|
||
/// not a regression; it had simply never been gated, because the only two gates in the server
|
||
/// were the write and alarm role checks.
|
||
/// </para>
|
||
/// <para>
|
||
/// Denial is <b>per node, not per batch</b>: OPC UA Part 11 gives every node in a HistoryRead
|
||
/// its own result slot, and a client reading ten nodes of which one is off-limits should get
|
||
/// nine answers and one denial rather than a whole failed request. Failing the batch would also
|
||
/// leak more than it protects — the client learns exactly which read was refused either way,
|
||
/// but a batch failure additionally denies data it was entitled to.
|
||
/// </para>
|
||
/// <para>
|
||
/// Returns the original list unchanged when enforcement is off, so the non-ACL path allocates
|
||
/// nothing and behaves exactly as before.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="context">The SDK context carrying the session identity.</param>
|
||
/// <param name="nodesToProcess">The pre-filtered handles the base dispatcher built.</param>
|
||
/// <param name="results">The service-level results list.</param>
|
||
/// <param name="errors">The service-level errors list.</param>
|
||
/// <returns>The handles that may proceed.</returns>
|
||
private List<NodeHandle> AuthorizeHistoryRead(
|
||
ServerSystemContext context,
|
||
List<NodeHandle> nodesToProcess,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors)
|
||
{
|
||
if (!AclEnforced) return nodesToProcess;
|
||
|
||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
|
||
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
|
||
|
||
var allowed = new List<NodeHandle>(nodesToProcess.Count);
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
var veto = _authorizer!.Authorize(
|
||
identity, OpcUaOperation.HistoryRead, handle.NodeId, RealmOf(handle.NodeId));
|
||
if (veto is null)
|
||
{
|
||
allowed.Add(handle);
|
||
continue;
|
||
}
|
||
|
||
// Set both slots explicitly rather than relying on the base's pre-seeded
|
||
// BadHistoryOperationUnsupported — "unsupported" would tell the client the node has no history,
|
||
// which is a different fact from "you may not read it" and sends them debugging the wrong thing.
|
||
errors[handle.Index] = veto;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied };
|
||
}
|
||
|
||
return allowed;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void HistoryReadRawModified(
|
||
ServerSystemContext context,
|
||
ReadRawModifiedDetails details,
|
||
TimestampsToReturn timestampsToReturn,
|
||
IList<HistoryReadValueId> nodesToRead,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
List<NodeHandle> nodesToProcess,
|
||
IDictionary<NodeId, NodeState> cache)
|
||
{
|
||
var session = context.OperationContext?.Session;
|
||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||
{
|
||
using var deadline = CreateDeadline();
|
||
var ct = deadline?.Token ?? CancellationToken.None;
|
||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
if (details.IsReadModified && details.ReturnBounds && HonorReturnBounds)
|
||
{
|
||
// Part 11 §6.5.3.3: returnBounds "shall be set to FALSE" for a modified read, "otherwise the Server returns
|
||
// a Bad_InvalidArgument" (#691). Off with the ServerHistorian:HonorReturnBounds kill switch.
|
||
errors[handle.Index] = StatusCodes.BadInvalidArgument;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadInvalidArgument };
|
||
continue;
|
||
}
|
||
|
||
if (details.IsReadModified)
|
||
{
|
||
// Part 11 modified-value history (#668): the prior values every HistoryUpdate recorded. Gated by the
|
||
// same HistoryRead authorization as a raw read (above); a backend with no trail ⇒ unsupported.
|
||
work.Add(() => ServeModifiedPagedAsync(
|
||
handle, session, nodesToRead, results, errors,
|
||
details.StartTime, details.EndTime, details.NumValuesPerNode,
|
||
ct));
|
||
continue;
|
||
}
|
||
|
||
work.Add(() => ServeRawPagedAsync(
|
||
handle, session, nodesToRead, results, errors,
|
||
details.StartTime, details.EndTime, details.NumValuesPerNode,
|
||
ct, details.ReturnBounds && HonorReturnBounds));
|
||
}
|
||
|
||
RunBounded(work, HistoryReadBatchParallelism);
|
||
});
|
||
NormalizeHistoryValues(nodesToProcess, results);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void HistoryReadProcessed(
|
||
ServerSystemContext context,
|
||
ReadProcessedDetails details,
|
||
TimestampsToReturn timestampsToReturn,
|
||
IList<HistoryReadValueId> nodesToRead,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
List<NodeHandle> nodesToProcess,
|
||
IDictionary<NodeId, NodeState> cache)
|
||
{
|
||
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
|
||
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
|
||
// #677: a client AggregateConfiguration other than the server defaults is refused per node, never silently ignored.
|
||
var configurationServable = ServerAggregateConfiguration.IsServable(details.AggregateConfiguration);
|
||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||
var served = AdvertisedAggregateFunctions.ServedBy(HistorianDataSource);
|
||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||
{
|
||
using var deadline = CreateDeadline();
|
||
var ct = deadline?.Token ?? CancellationToken.None;
|
||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
|
||
// the base dispatcher). handle.Index is the node's position in that collection.
|
||
var aggregateNodeId = details.AggregateType[handle.Index];
|
||
// #682: one authority — the source's SupportedAggregates (also what HistoryServerCapabilities/
|
||
// AggregateFunctions advertises; null ⇒ all, the same rule). An aggregate the server cannot name, or the
|
||
// backend does not serve, is refused up front without a backend call.
|
||
var aggregate = AggregateFunctionMap.Map(aggregateNodeId);
|
||
if (aggregate is null || !served.Contains(aggregate.Value))
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
|
||
continue;
|
||
}
|
||
|
||
if (!configurationServable)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadAggregateConfigurationRejected;
|
||
continue;
|
||
}
|
||
|
||
// Processed is SINGLE-SHOT (no continuation point). Unlike Raw, ReadProcessedDetails carries
|
||
// NO client count cap (NumValuesPerNode) — the bucket count is deterministic (window / interval)
|
||
// and the single-shot backend returns every bucket in one read, so there is no "full page ⇒
|
||
// maybe more" signal to page on. Returning the complete aggregate result with a null CP is
|
||
// spec-conformant (OPC UA Part 11 lets a server return all available data in one response).
|
||
var agg = aggregate.Value;
|
||
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
|
||
tagname,
|
||
details.StartTime,
|
||
details.EndTime,
|
||
interval,
|
||
agg,
|
||
token), ct));
|
||
}
|
||
|
||
RunBounded(work, HistoryReadBatchParallelism);
|
||
});
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void HistoryReadAtTime(
|
||
ServerSystemContext context,
|
||
ReadAtTimeDetails details,
|
||
TimestampsToReturn timestampsToReturn,
|
||
IList<HistoryReadValueId> nodesToRead,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
List<NodeHandle> nodesToProcess,
|
||
IDictionary<NodeId, NodeState> cache)
|
||
{
|
||
// Snapshot the requested timestamps once — the same list is read for every node.
|
||
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
|
||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||
{
|
||
using var deadline = CreateDeadline();
|
||
var ct = deadline?.Token ?? CancellationToken.None;
|
||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
// #693: the node's scalar value type, for a backend whose stored values carry none (the Gateway).
|
||
var valueType = AtTimeValueType(handle.NodeId);
|
||
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
|
||
tagname,
|
||
timestamps,
|
||
valueType,
|
||
token), ct));
|
||
}
|
||
|
||
RunBounded(work, HistoryReadBatchParallelism);
|
||
});
|
||
NormalizeHistoryValues(nodesToProcess, results);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void HistoryReadEvents(
|
||
ServerSystemContext context,
|
||
ReadEventDetails details,
|
||
TimestampsToReturn timestampsToReturn,
|
||
IList<HistoryReadValueId> nodesToRead,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
List<NodeHandle> nodesToProcess,
|
||
IDictionary<NodeId, NodeState> cache)
|
||
{
|
||
// Snapshot the select clauses once — the same filter projects every node's events.
|
||
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
|
||
var session = context.OperationContext?.Session;
|
||
|
||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||
{
|
||
using var deadline = CreateDeadline();
|
||
var ct = deadline?.Token ?? CancellationToken.None;
|
||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
// Key on the full (namespace-qualified) NodeId so a Raw and a UNS notifier folder that share a
|
||
// bare id resolve to their own source registration.
|
||
if (!_eventNotifierSources.TryGetValue(MapKey(handle.NodeId), out var sourceName))
|
||
{
|
||
// Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
|
||
// Set both errors and results explicitly on every bad path — don't rely on the SDK base
|
||
// pre-seeding results[i], so every path is self-contained and the contract is obvious.
|
||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||
continue;
|
||
}
|
||
|
||
var sn = sourceName;
|
||
work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, nodesToRead, session, results, errors, ct));
|
||
}
|
||
|
||
RunBounded(work, HistoryReadBatchParallelism);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Serve one registered event-history notifier handle: reads the source's events over the request
|
||
/// window, projects them onto the select clauses, and fills the service-level results/errors slots.
|
||
/// Per-node error isolation matches <see cref="ServeNodeAsync"/> — a backend throw becomes a Bad
|
||
/// status for THIS node only and never throws out of the batch; a deadline cancellation surfaces
|
||
/// <c>BadTimeout</c>.
|
||
/// </summary>
|
||
/// <param name="handle">The notifier node handle; <c>handle.Index</c> indexes results/errors.</param>
|
||
/// <param name="sourceName">The registered historian event-source name for this notifier.</param>
|
||
/// <param name="details">The event-read details (window + count cap).</param>
|
||
/// <param name="selectClauses">The event-filter select clauses (the fields to emit, in order).</param>
|
||
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
|
||
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
|
||
/// <param name="ct">Per-request deadline token.</param>
|
||
private async Task ServeEventsAsync(
|
||
NodeHandle handle,
|
||
string? sourceName,
|
||
ReadEventDetails details,
|
||
SimpleAttributeOperandCollection selectClauses,
|
||
IList<HistoryReadValueId> nodesToRead,
|
||
ISession? session,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
CancellationToken ct)
|
||
{
|
||
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
|
||
var resumed = inboundCp is { Length: > 0 };
|
||
// One snapshot of the (hot-swappable) source for the whole read: the capability check and every call must agree.
|
||
var source = _historianDataSource;
|
||
try
|
||
{
|
||
var startUtc = details.StartTime;
|
||
var endUtc = details.EndTime;
|
||
var numValuesPerNode = details.NumValuesPerNode;
|
||
var boundarySkip = 0;
|
||
var walkHint = default(ReverseWalkHint); // #691.4: a walked-back read's width + proven-empty stretch
|
||
bool reverse;
|
||
|
||
if (resumed)
|
||
{
|
||
// Single-use take. A miss (unknown / evicted / malformed / a RAW cursor) ⇒ BadContinuationPointInvalid
|
||
// for THIS node only.
|
||
var state = _historyContinuationStore.TryTake(session, inboundCp!);
|
||
// A point is bound to the node that issued it: another node's source name is a miss too.
|
||
if (state is null || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty))
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
|
||
return;
|
||
}
|
||
|
||
sourceName = state.Tagname;
|
||
startUtc = state.NextStartUtc;
|
||
endUtc = state.EndUtc;
|
||
boundarySkip = state.BoundarySkipCount;
|
||
numValuesPerNode = state.NumValuesPerNode;
|
||
// The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a
|
||
// reverse read resumes reverse, a forward one forward.
|
||
reverse = state.Reverse;
|
||
walkHint = state.WalkHint;
|
||
}
|
||
else
|
||
{
|
||
// Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from
|
||
// StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would
|
||
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
|
||
// (#675, mirroring raw): the reverse window (MinValue, EndTime] — on every backend since #691.4 (a backend
|
||
// that cannot read newest-first is walked back below). Without a count cap an end-only read keeps its
|
||
// forward [MinValue, EndTime) meaning.
|
||
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
||
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
|
||
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
|
||
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
||
|
||
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
|
||
reverse = startUtc > endUtc;
|
||
|
||
// #694: the opt-in events horizon raises the window's earlier bound (the cursor then carries it). A window
|
||
// wholly older than it is empty and is not sent (a forward [start, end) clamped past its end would be refused).
|
||
if (!EventHorizon.TryClamp(ref startUtc, ref endUtc, reverse, EventReadHorizon, Clock.GetUtcNow().UtcDateTime))
|
||
{
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
StatusCode = StatusCodes.GoodNoData,
|
||
HistoryData = new ExtensionObject(ProjectEvents([], selectClauses)),
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// A reverse read on a backend that cannot read newest-first (the Gateway) is never sent there (#675 refused it
|
||
// here); since #691.4 it is walked back from bounded forward reads — the raw walk (#686), with ties ordered by
|
||
// EventId because the gateway orders by event time only. Decided by the capability, not by catching an
|
||
// exception, so a NotSupportedException thrown from inside a backend stays a backend fault.
|
||
var walked = reverse && !source.SupportsReverseEvents;
|
||
|
||
// NOT under the node-manager Lock — awaiting the async source is safe here.
|
||
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
|
||
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel. A resumed page re-reads its
|
||
// boundary timestamp inclusively and then drops the `boundarySkip` ties already emitted, so it asks for that
|
||
// many more rows than the cap — otherwise every resumed page would come back short by the skip.
|
||
var fetch = EventMaxEvents(numValuesPerNode);
|
||
if (numValuesPerNode > 0 && boundarySkip > 0)
|
||
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
|
||
HistoricalEventsResult sourceResult;
|
||
if (walked)
|
||
{
|
||
// Walk for one more than the fetch: finding it is the "more events exist" signal a source gives with its
|
||
// continuation point (the walk itself cannot flag truncation).
|
||
var want = numValuesPerNode == 0 ? 0u : (uint)Math.Min(uint.MaxValue - 1L, (long)fetch + 1);
|
||
var (rows, next) = await ReverseEventsReader.ReadAsync(
|
||
source, sourceName, startUtc, endUtc, want, MaxTieClusterOverfetch, Clock.GetUtcNow().UtcDateTime,
|
||
walkHint, ct)
|
||
.ConfigureAwait(false);
|
||
walkHint = next;
|
||
var more = want > 0 && rows.Count > fetch;
|
||
if (more) rows.RemoveRange(fetch, rows.Count - fetch);
|
||
sourceResult = new HistoricalEventsResult(rows, more ? [0x01] : null);
|
||
}
|
||
else
|
||
{
|
||
sourceResult = await source.ReadEventsAsync(
|
||
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
|
||
// page that happens to be exactly cap-sized but complete never emits a point.
|
||
var truncated = sourceResult.ContinuationPoint is not null && numValuesPerNode > 0;
|
||
|
||
IReadOnlyList<HistoricalEvent> events = sourceResult.Events;
|
||
if (resumed && boundarySkip > 0)
|
||
{
|
||
// Drop the events at the boundary timestamp already emitted on the prior page. The resumed read is in
|
||
// the cursor's direction, so in both directions those ties lead the page.
|
||
var i = 0;
|
||
while (i < events.Count && i < boundarySkip && events[i].EventTimeUtc == startUtc) i++;
|
||
if (i > 0) events = events.Skip(i).ToList();
|
||
}
|
||
|
||
// The skip over-fetch can leave more than the cap (fewer boundary ties came back than were skipped — pruned
|
||
// between pages): keep the cap, and the dropped tail means the read continues.
|
||
if (numValuesPerNode > 0 && events.Count > numValuesPerNode)
|
||
{
|
||
events = events.Take((int)Math.Min(int.MaxValue, numValuesPerNode)).ToList();
|
||
truncated = true;
|
||
}
|
||
|
||
// The cursor to store, if any. Saved only AFTER the page is projected, so a projection throw can
|
||
// never orphan a stored cursor.
|
||
HistoryContinuationState? pendingCursor = null;
|
||
|
||
if (truncated && resumed
|
||
&& (events.Count == 0 || events[^1].EventTimeUtc == startUtc))
|
||
{
|
||
// Oversized tie cluster: more events share startUtc than the page cap, so (time, skip) alone
|
||
// cannot advance. Over-fetch the whole cluster over the half-open [T, T+resolution) window
|
||
// with an explicit bounded cap and page WITHIN it (same scheme as raw).
|
||
var resolution = Math.Max(1L, source.TimestampResolutionTicks);
|
||
var overfetchCap = (int)Math.Min(int.MaxValue - 1L, (long)Math.Max(1, MaxTieClusterOverfetch) + 1);
|
||
IReadOnlyList<HistoricalEvent> cluster = (await source.ReadEventsAsync(
|
||
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
|
||
.ConfigureAwait(false)).Events;
|
||
|
||
// A walked read (#691.4) emitted its ties sorted by EventId; the gateway returns them in any order and its
|
||
// end is inclusive — keep exactly the cluster, in the walk's order, so the skip indexes the same events.
|
||
if (walked)
|
||
cluster = cluster.Where(e => e.EventTimeUtc == startUtc)
|
||
.OrderBy(e => e, EventIdTieOrder.Instance).ToList();
|
||
|
||
// The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and
|
||
// counted boundarySkip — in descending order. Slice in the read's own direction, as raw does.
|
||
if (reverse) cluster = cluster.Reverse().ToList();
|
||
|
||
if (cluster.Count > MaxTieClusterOverfetch)
|
||
{
|
||
_logger.LogError(
|
||
"OtOpcUaNodeManager: HistoryReadEvents tie cluster at {StartUtc:O} for source '{Source}' has {Count} events, " +
|
||
"exceeding MaxTieClusterOverfetch={Max}; cannot page within it. Increase MaxTieClusterOverfetch.",
|
||
startUtc, sourceName, cluster.Count, MaxTieClusterOverfetch);
|
||
errors[handle.Index] = StatusCodes.BadTooManyOperations;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
|
||
return;
|
||
}
|
||
|
||
HistoryPaging.SliceTieCluster(
|
||
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
|
||
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
|
||
reverse: reverse, resolutionTicks: resolution);
|
||
|
||
var slice = new List<HistoricalEvent>(sliceCount);
|
||
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
|
||
events = slice;
|
||
|
||
if (nextStartUtc is { } resumeAt)
|
||
{
|
||
pendingCursor = new HistoryContinuationState(
|
||
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events,
|
||
Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks,
|
||
EmptyToTicks: walkHint.EmptyToTicks);
|
||
}
|
||
}
|
||
else if (truncated && events.Count > 0)
|
||
{
|
||
// Cursor from the last event: resume at its time INCLUSIVE, skipping the trailing ties emitted. In a
|
||
// reverse read the last event is the OLDEST on the page, and the resumed (end, T] read starts there.
|
||
var boundary = events[^1].EventTimeUtc;
|
||
var skip = 0;
|
||
for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++;
|
||
pendingCursor = new HistoryContinuationState(
|
||
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events,
|
||
Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks,
|
||
EmptyToTicks: walkHint.EmptyToTicks);
|
||
}
|
||
|
||
var historyEvent = ProjectEvents(events, selectClauses);
|
||
var outboundCp = pendingCursor is null ? null : _historyContinuationStore.Save(session, pendingCursor);
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
|
||
StatusCode = events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||
HistoryData = new ExtensionObject(historyEvent),
|
||
// Null when the page was complete, or when no session is available to hold the cursor.
|
||
ContinuationPoint = outboundCp,
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// The per-request deadline elapsed while reading this notifier — BadTimeout for THIS node only.
|
||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||
}
|
||
catch (HistoryReadLimitExceededException ex)
|
||
{
|
||
// A request limit (#691.4: a walked-back reverse read met a tie cluster over the budget) — BadTooManyOperations
|
||
// for THIS node, as on the raw path; not a backend fault.
|
||
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadEvents limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message);
|
||
errors[handle.Index] = StatusCodes.BadTooManyOperations;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
|
||
// only. Logged via the injected ILogger.
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {NodeId}", handle.NodeId);
|
||
errors[handle.Index] = HistoryReadFailureStatus(ex);
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// HistoryUpdate (pluggable historian, spec §5). The SDK's public HistoryUpdate admits a variable only when
|
||
// its AccessLevel carries HistoryWrite (see ComposeAccessLevel), pre-seeds every admitted slot
|
||
// BadHistoryOperationUnsupported, RELEASES the node-manager Lock and dispatches by details type to the
|
||
// protected arms below — so, as for HistoryRead, block-bridging the async updater here is safe. Event and
|
||
// structure-data details are not overridden: the base leaves them Unsupported.
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Drops the handles in a HistoryUpdate batch the calling session may not update, marking each denied
|
||
/// slot <c>BadUserAccessDenied</c>. Unlike <see cref="AuthorizeHistoryRead"/> this gate exists in BOTH
|
||
/// modes, because rewriting recorded history was never ungated.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// ACL flag <b>on</b>: the scoped decision for <see cref="OpcUaOperation.HistoryUpdate"/> REPLACES the
|
||
/// role check (never chained — see <see cref="OnEquipmentTagWrite"/> for why chaining would leave
|
||
/// every narrow grant inert).
|
||
/// </para>
|
||
/// <para>
|
||
/// ACL flag <b>off</b>: the session's key is re-checked first (Gitea #563 — an expired or revoked key
|
||
/// loses its activation-time roles), then the dedicated
|
||
/// <see cref="OpcUaDataPlaneRoles.HistoryUpdate"/> role is required. <c>WriteOperate</c> is
|
||
/// deliberately NOT reused: writing a live value and rewriting history are different authorities.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="context">The SDK context carrying the session identity.</param>
|
||
/// <param name="nodesToProcess">The handles the base dispatcher admitted.</param>
|
||
/// <param name="results">The service-level results list.</param>
|
||
/// <param name="errors">The service-level errors list.</param>
|
||
/// <returns>The handles that may proceed.</returns>
|
||
private List<NodeHandle> AuthorizeHistoryUpdate(
|
||
ServerSystemContext context,
|
||
List<NodeHandle> nodesToProcess,
|
||
IList<HistoryUpdateResult> results,
|
||
IList<ServiceResult> errors)
|
||
{
|
||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
|
||
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
|
||
|
||
var allowed = new List<NodeHandle>(nodesToProcess.Count);
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
var veto = AclEnforced
|
||
? _authorizer!.Authorize(identity, OpcUaOperation.HistoryUpdate, handle.NodeId, RealmOf(handle.NodeId))
|
||
: _authorizer?.AuthorizeKeyUsable(identity, OpcUaOperation.HistoryUpdate, handle.NodeId)
|
||
?? EvaluateHistoryUpdateRoleGate(identity);
|
||
if (veto is null)
|
||
{
|
||
allowed.Add(handle);
|
||
continue;
|
||
}
|
||
|
||
// Both slots explicitly: "you may not update this" is a different fact from the pre-seeded
|
||
// "this node has no updatable history".
|
||
errors[handle.Index] = veto;
|
||
results[handle.Index] = new HistoryUpdateResult { StatusCode = veto.StatusCode };
|
||
}
|
||
|
||
return allowed;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The flag-off role gate for HistoryUpdate: a null identity or one without the
|
||
/// <see cref="OpcUaDataPlaneRoles.HistoryUpdate"/> role ⇒ <c>BadUserAccessDenied</c>; otherwise
|
||
/// <c>null</c> (proceed). Case-insensitive, matching the other role gates.
|
||
/// </summary>
|
||
/// <param name="identity">The session's role-carrying identity, or null.</param>
|
||
/// <returns><c>null</c> to proceed; otherwise the veto.</returns>
|
||
internal static ServiceResult? EvaluateHistoryUpdateRoleGate(RoleCarryingUserIdentity? identity) =>
|
||
identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.HistoryUpdate, StringComparer.OrdinalIgnoreCase)
|
||
? new ServiceResult(StatusCodes.BadUserAccessDenied)
|
||
: null;
|
||
|
||
/// <inheritdoc />
|
||
protected override void HistoryUpdateData(
|
||
ServerSystemContext context,
|
||
IList<UpdateDataDetails> nodesToUpdate,
|
||
IList<HistoryUpdateResult> results,
|
||
IList<ServiceResult> errors,
|
||
List<NodeHandle> nodesToProcess,
|
||
IDictionary<NodeId, NodeState> cache)
|
||
{
|
||
// #674: one "now" per request, so every item of the batch is judged against the same bound.
|
||
var latest = LatestAcceptedSourceTime();
|
||
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
|
||
{
|
||
var details = nodesToUpdate[handle.Index];
|
||
HistoryUpdateMode mode;
|
||
switch (details.PerformInsertReplace)
|
||
{
|
||
case PerformUpdateType.Insert: mode = HistoryUpdateMode.Insert; break;
|
||
case PerformUpdateType.Replace: mode = HistoryUpdateMode.Replace; break;
|
||
case PerformUpdateType.Update: mode = HistoryUpdateMode.Update; break;
|
||
default: return null; // Remove (and anything unknown) ⇒ unsupported
|
||
}
|
||
|
||
var items = (IList<DataValue>?)details.UpdateValues ?? Array.Empty<DataValue>();
|
||
return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, latest, who, ct);
|
||
});
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void HistoryDeleteRawModified(
|
||
ServerSystemContext context,
|
||
IList<DeleteRawModifiedDetails> nodesToUpdate,
|
||
IList<HistoryUpdateResult> results,
|
||
IList<ServiceResult> errors,
|
||
List<NodeHandle> nodesToProcess,
|
||
IDictionary<NodeId, NodeState> cache)
|
||
{
|
||
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
|
||
{
|
||
var details = nodesToUpdate[handle.Index];
|
||
// Deleting the modified-value trail itself (Part 11 IsDeleteModified) is not supported: the trail is the
|
||
// audit record of every change and is aged out only by retention (#668).
|
||
if (details.IsDeleteModified) return null;
|
||
var start = details.StartTime;
|
||
var end = details.EndTime;
|
||
// Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse
|
||
// it here rather than let a backend read MinValue as "from the beginning of time".
|
||
if (start == DateTime.MinValue || end == DateTime.MinValue)
|
||
return (_, _, _, _) => Task.FromResult(new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidArgument });
|
||
return async (updater, tagname, who, ct) =>
|
||
{
|
||
var code = await updater.DeleteRawAsync(tagname, start, end, who, ct).ConfigureAwait(false);
|
||
return new HistoryUpdateResult { StatusCode = code };
|
||
};
|
||
});
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void HistoryDeleteAtTime(
|
||
ServerSystemContext context,
|
||
IList<DeleteAtTimeDetails> nodesToUpdate,
|
||
IList<HistoryUpdateResult> results,
|
||
IList<ServiceResult> errors,
|
||
List<NodeHandle> nodesToProcess,
|
||
IDictionary<NodeId, NodeState> cache)
|
||
{
|
||
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
|
||
{
|
||
var times = nodesToUpdate[handle.Index].ReqTimes?.ToList() ?? new List<DateTime>();
|
||
return async (updater, tagname, who, ct) =>
|
||
{
|
||
var codes = times.Count == 0
|
||
? Array.Empty<uint>()
|
||
: await updater.DeleteAtTimeAsync(tagname, times, who, ct).ConfigureAwait(false);
|
||
if (codes.Count != times.Count)
|
||
throw new InvalidOperationException(
|
||
$"historian updater returned {codes.Count} result(s) for {times.Count} time(s)");
|
||
return new HistoryUpdateResult
|
||
{
|
||
StatusCode = StatusCodes.Good,
|
||
OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))),
|
||
};
|
||
};
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// #674 review: a HistoryUpdate timestamp as UTC. Wire timestamps are UTC; an in-process caller can hand a
|
||
/// <see cref="DateTimeKind.Local"/> value, which is converted (comparing its raw ticks against a UTC bound would be
|
||
/// off by the zone offset). <see cref="DateTimeKind.Unspecified"/> is treated as UTC, as the backends treat it.
|
||
/// </summary>
|
||
/// <param name="value">The item's source timestamp.</param>
|
||
/// <returns>The same instant as a UTC <see cref="DateTime"/>.</returns>
|
||
private static DateTime ToUtc(DateTime value) => value.Kind switch
|
||
{
|
||
DateTimeKind.Local => value.ToUniversalTime(),
|
||
DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||
_ => value,
|
||
};
|
||
|
||
/// <summary>
|
||
/// #674: the latest <c>SourceTimestamp</c> a HistoryUpdate item may carry — <see cref="Clock"/>'s now plus
|
||
/// <see cref="HistoryUpdateMaxFutureSkew"/> — or <c>null</c> when the bound is disabled (non-positive skew).
|
||
/// Saturates at <see cref="DateTime.MaxValue"/> rather than overflowing for an absurd configured skew.
|
||
/// </summary>
|
||
/// <returns>The inclusive upper bound, or null for unbounded.</returns>
|
||
private DateTime? LatestAcceptedSourceTime()
|
||
{
|
||
var skew = HistoryUpdateMaxFutureSkew;
|
||
if (skew <= TimeSpan.Zero) return null;
|
||
var now = _clock.GetUtcNow().UtcDateTime;
|
||
return skew.Ticks > DateTime.MaxValue.Ticks - now.Ticks
|
||
? new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc)
|
||
: now + skew;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Applies one node's UpdateDataDetails: items the server can refuse on its own
|
||
/// (<c>SourceTimestamp == DateTime.MinValue</c> ⇒ <c>BadInvalidTimestamp</c>; a <c>SourceTimestamp</c> later than
|
||
/// <paramref name="latest"/> ⇒ <c>BadOutOfRange</c>, #674; a value <see cref="HistorianValue.TryFromObject"/>
|
||
/// cannot type ⇒ <c>BadTypeMismatch</c>) are answered locally, the rest go to the updater in ONE call, and the
|
||
/// per-item codes are stitched back in request order.
|
||
/// </summary>
|
||
/// <param name="updater">The wired updater.</param>
|
||
/// <param name="tagname">The node's historian tagname.</param>
|
||
/// <param name="mode">The update mode.</param>
|
||
/// <param name="items">The request's values, in order.</param>
|
||
/// <param name="latest">The latest accepted source time (<see cref="LatestAcceptedSourceTime"/>); null = unbounded.</param>
|
||
/// <param name="who">The caller, recorded in the modification trail.</param>
|
||
/// <param name="ct">The request deadline.</param>
|
||
/// <returns>The node's result, carrying one operation result per item.</returns>
|
||
private static async Task<HistoryUpdateResult> UpdateDataAsync(
|
||
IHistorianDataUpdater updater, string tagname, HistoryUpdateMode mode, IList<DataValue> items,
|
||
DateTime? latest, HistoryUpdateContext who, CancellationToken ct)
|
||
{
|
||
var codes = new uint[items.Count];
|
||
var toSend = new List<HistorizationValue>(items.Count);
|
||
var sentIndex = new List<int>(items.Count);
|
||
for (var i = 0; i < items.Count; i++)
|
||
{
|
||
var item = items[i];
|
||
if (item is null)
|
||
{
|
||
codes[i] = StatusCodes.BadInvalidArgument;
|
||
}
|
||
else if (item.SourceTimestamp == DateTime.MinValue)
|
||
{
|
||
codes[i] = StatusCodes.BadInvalidTimestamp;
|
||
}
|
||
else if (latest is { } bound && ToUtc(item.SourceTimestamp) > bound)
|
||
{
|
||
// #674: a far-future history edit is refused before any backend sees it.
|
||
codes[i] = StatusCodes.BadOutOfRange;
|
||
}
|
||
else if (!HistorianValue.TryFromObject(item.Value, out var value))
|
||
{
|
||
codes[i] = StatusCodes.BadTypeMismatch;
|
||
}
|
||
else
|
||
{
|
||
toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp), value, item.StatusCode.Code));
|
||
sentIndex.Add(i);
|
||
}
|
||
}
|
||
|
||
if (toSend.Count > 0)
|
||
{
|
||
var backend = await updater.UpdateDataAsync(tagname, mode, toSend, who, ct).ConfigureAwait(false);
|
||
if (backend.Count != toSend.Count)
|
||
throw new InvalidOperationException(
|
||
$"historian updater returned {backend.Count} result(s) for {toSend.Count} value(s)");
|
||
for (var j = 0; j < backend.Count; j++) codes[sentIndex[j]] = backend[j];
|
||
}
|
||
|
||
// Per-node StatusCode stays Good even when item results are mixed — Part 11 reports item outcomes in
|
||
// OperationResults; the node code is for whole-node failures only.
|
||
return new HistoryUpdateResult
|
||
{
|
||
StatusCode = StatusCodes.Good,
|
||
OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))),
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Shared driver for the three HistoryUpdate arms: authorize, then under the shared HistoryRead batch
|
||
/// limiter + per-request deadline resolve each node's tagname and run its operation with bounded
|
||
/// parallelism. A node that is not historized, has no updater, or whose details are out of scope
|
||
/// (<paramref name="plan"/> returns null) is <c>BadHistoryOperationUnsupported</c>.
|
||
/// </summary>
|
||
/// <param name="context">The SDK context.</param>
|
||
/// <param name="nodesToProcess">The handles the base dispatcher admitted.</param>
|
||
/// <param name="results">The service-level results list.</param>
|
||
/// <param name="errors">The service-level errors list.</param>
|
||
/// <param name="plan">Builds a node's operation from its details, or returns null when unsupported.</param>
|
||
private void ServeHistoryUpdate(
|
||
ServerSystemContext context,
|
||
List<NodeHandle> nodesToProcess,
|
||
IList<HistoryUpdateResult> results,
|
||
IList<ServiceResult> errors,
|
||
Func<NodeHandle, Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>>?> plan)
|
||
{
|
||
nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors);
|
||
if (nodesToProcess.Count == 0) return;
|
||
|
||
// Plan BEFORE the Primary gate (#674 review): a request this server can never serve (no updater, a node that is
|
||
// not historized, Remove, IsDeleteModified) is answered with the honest BadHistoryOperationUnsupported on every
|
||
// node, so a Secondary does not send the client to the Primary for something the Primary would also refuse.
|
||
// Planning is pure — it builds closures and reads the in-memory tagname map; nothing reaches a backend.
|
||
var updater = _historianDataUpdater;
|
||
var planned = new List<(NodeHandle Handle, string Tagname,
|
||
Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>> Operation)>(nodesToProcess.Count);
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
var operation = updater is null ? null : plan(handle);
|
||
if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||
continue;
|
||
}
|
||
|
||
planned.Add((handle, tagname, operation));
|
||
}
|
||
|
||
if (planned.Count == 0) return;
|
||
|
||
// #674 — PRIMARY GATE, after authorization (a caller who may not update learns nothing about the node's role)
|
||
// and after planning. Off-Primary every servable node is refused BadInvalidState and nothing reaches the updater.
|
||
if (HistoryUpdatePrimaryGate is { } primary && !primary())
|
||
{
|
||
var refusal = new ServiceResult(StatusCodes.BadInvalidState,
|
||
new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel)."));
|
||
foreach (var (handle, _, _) in planned)
|
||
{
|
||
errors[handle.Index] = refusal;
|
||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidState };
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// Who is rewriting history — recorded on every modification-trail row (Part 11 ModificationInfo.UserName, #668).
|
||
var who = HistoryUpdateCallerOf(context);
|
||
|
||
WithHistoryReadBatchLimiter(planned.Select(p => p.Handle).ToList(), results, errors, () =>
|
||
{
|
||
using var deadline = CreateDeadline();
|
||
var ct = deadline?.Token ?? CancellationToken.None;
|
||
var work = new List<Func<Task>>(planned.Count);
|
||
foreach (var (handle, tagname, operation) in planned)
|
||
{
|
||
var h = handle;
|
||
var tag = tagname;
|
||
var op = operation;
|
||
work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => op(updater!, tag, who, token), ct));
|
||
}
|
||
|
||
RunBounded(work, HistoryReadBatchParallelism);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// The <see cref="HistoryUpdateContext"/> of the session behind <paramref name="context"/>: its API-key principal
|
||
/// (or the reserved <c>anonymous</c> principal) and key id. Only an identity that already passed
|
||
/// <see cref="AuthorizeHistoryUpdate"/> reaches a backend, so the anonymous fallback for a missing identity is
|
||
/// defence in depth, not a path.
|
||
/// </summary>
|
||
/// <param name="context">The SDK context carrying the session identity.</param>
|
||
/// <returns>The caller recorded in the modification trail.</returns>
|
||
internal static HistoryUpdateContext HistoryUpdateCallerOf(ServerSystemContext context)
|
||
{
|
||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
|
||
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
|
||
return identity is null
|
||
? new HistoryUpdateContext(OpcUaPrincipals.Anonymous, null)
|
||
: new HistoryUpdateContext(identity.Principal, identity.ApiKeyId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs one node's HistoryUpdate operation and fills its slots. Never throws (the
|
||
/// <see cref="RunBounded"/> contract): a deadline cancellation is <c>BadTimeout</c>, any backend failure
|
||
/// is logged and reported <c>BadInternalError</c> for THIS node only.
|
||
/// </summary>
|
||
/// <param name="handle">The node handle.</param>
|
||
/// <param name="results">The service-level results list.</param>
|
||
/// <param name="errors">The service-level errors list.</param>
|
||
/// <param name="operation">The node's operation.</param>
|
||
/// <param name="ct">The request deadline.</param>
|
||
private async Task ServeUpdateNodeAsync(
|
||
NodeHandle handle,
|
||
IList<HistoryUpdateResult> results,
|
||
IList<ServiceResult> errors,
|
||
Func<CancellationToken, Task<HistoryUpdateResult>> operation,
|
||
CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
results[handle.Index] = await operation(ct).ConfigureAwait(false);
|
||
// The base pre-seeded BadHistoryOperationUnsupported, and the master node manager copies a non-Good
|
||
// error over the result's StatusCode — so a success MUST clear it.
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTimeout };
|
||
}
|
||
catch (NotSupportedException)
|
||
{
|
||
// The backend cannot perform this update at all (e.g. a SqlServer historian still at schema v1, with no
|
||
// modification trail to record it in, #674) — the spec status for the whole node, not a server fault.
|
||
// Mirrors the HistoryRead side's handling of the same exception.
|
||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryUpdate failed for node {NodeId}", handle.NodeId);
|
||
errors[handle.Index] = StatusCodes.BadInternalError;
|
||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInternalError };
|
||
}
|
||
}
|
||
|
||
/// <summary>Clamp a <see cref="uint"/> request cap to a non-negative <see cref="int"/> for the
|
||
/// event-read surface (whose <c>maxEvents</c> is signed): values above <see cref="int.MaxValue"/>
|
||
/// saturate to <see cref="int.MaxValue"/>.</summary>
|
||
/// <param name="value">The uint cap from the request (<c>NumValuesPerNode</c>).</param>
|
||
/// <returns>The clamped non-negative int.</returns>
|
||
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
|
||
|
||
/// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
|
||
/// <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> <c>maxEvents</c> argument, honouring the OPC UA
|
||
/// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
|
||
/// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
|
||
/// the backend's <c>maxEvents <= 0</c> "use the default cap" sentinel, so a client asking for the
|
||
/// whole window is not silently truncated at the backend default. A positive value passes through
|
||
/// clamped to <see cref="int.MaxValue"/> (mirroring <see cref="ClampToInt"/>).</summary>
|
||
/// <param name="numValuesPerNode">The request's <c>NumValuesPerNode</c> cap (0 ⇒ no limit).</param>
|
||
/// <returns><see cref="int.MaxValue"/> when 0 (unbounded); otherwise the clamped non-negative int.</returns>
|
||
internal static int EventMaxEvents(uint numValuesPerNode) =>
|
||
numValuesPerNode == 0 ? int.MaxValue : ClampToInt(numValuesPerNode);
|
||
|
||
/// <summary>
|
||
/// Project a sequence of <see cref="HistoricalEvent"/>s into an SDK <see cref="HistoryEvent"/> —
|
||
/// one <see cref="HistoryEventFieldList"/> per event, each carrying the requested
|
||
/// <paramref name="selectClauses"/>' fields in select-clause order (see
|
||
/// <see cref="ProjectEventField"/>).
|
||
/// </summary>
|
||
/// <param name="events">The historian's event rows.</param>
|
||
/// <param name="selectClauses">The request's event-filter select clauses (the fields to emit, in order).</param>
|
||
/// <returns>The populated SDK <see cref="HistoryEvent"/>.</returns>
|
||
private static HistoryEvent ProjectEvents(
|
||
IReadOnlyList<HistoricalEvent> events, SimpleAttributeOperandCollection selectClauses)
|
||
{
|
||
var fieldLists = new HistoryEventFieldListCollection(events.Count);
|
||
foreach (var evt in events)
|
||
{
|
||
var fields = new VariantCollection(selectClauses.Count);
|
||
foreach (var operand in selectClauses)
|
||
{
|
||
fields.Add(ProjectEventField(evt, operand));
|
||
}
|
||
|
||
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
|
||
}
|
||
|
||
return new HistoryEvent { Events = fieldLists };
|
||
}
|
||
|
||
/// <summary>
|
||
/// Project one <see cref="HistoricalEvent"/> field requested by a select <paramref name="operand"/>
|
||
/// into a <see cref="Variant"/>. Mapping is by the operand's BrowsePath LEAF QualifiedName name
|
||
/// (case-sensitive, per the OPC UA BaseEventType field names) against the
|
||
/// <see cref="HistoricalEvent"/> shape:
|
||
/// <c>EventId</c>→ByteString, <c>SourceName</c>→String, <c>Time</c>→DateTime,
|
||
/// <c>ReceiveTime</c>→DateTime, <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16. Any other
|
||
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
|
||
/// <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
|
||
/// </summary>
|
||
/// <param name="evt">The source event row.</param>
|
||
/// <param name="operand">The select-clause operand naming the field to project.</param>
|
||
/// <returns>The projected variant, or <see cref="Variant.Null"/> for an unsupported field.</returns>
|
||
private static Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand)
|
||
{
|
||
var leaf = LeafFieldName(operand);
|
||
return leaf switch
|
||
{
|
||
// BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
|
||
"EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
|
||
"SourceName" => evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName),
|
||
"Time" => new Variant(evt.EventTimeUtc),
|
||
"ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
|
||
"Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),
|
||
"Severity" => new Variant(evt.Severity), // UInt16
|
||
// EventType / SourceNode / ConditionName / empty path / unrecognised leaf ⇒ null (spec-conformant).
|
||
_ => Variant.Null,
|
||
};
|
||
}
|
||
|
||
/// <summary>Extract the leaf (last) <see cref="QualifiedName.Name"/> from a select operand's BrowsePath,
|
||
/// or <c>null</c> when the BrowsePath is null/empty.</summary>
|
||
/// <param name="operand">The select-clause operand.</param>
|
||
/// <returns>The leaf field name, or null for an empty BrowsePath.</returns>
|
||
private static string? LeafFieldName(SimpleAttributeOperand operand)
|
||
{
|
||
var path = operand.BrowsePath;
|
||
if (path is null || path.Count == 0) return null;
|
||
return path[^1].Name;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Block-bridge to the historian source for one node handle and project the result onto the
|
||
/// service-level results/errors slots. Resolves the node's registered historian tagname first —
|
||
/// a single <see cref="TryGetHistorizedTagname"/> lookup; the resolved tagname is passed directly
|
||
/// to <paramref name="read"/>, removing any risk of a second concurrent lookup on the same key.
|
||
/// A node we don't recognise as historized maps to <c>BadHistoryOperationUnsupported</c>
|
||
/// (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access
|
||
/// bit, but we guard explicitly). The <paramref name="read"/> callback receives the resolved
|
||
/// tagname and is wrapped in try/catch so a backend throw / timeout becomes a Bad status for
|
||
/// THIS node without throwing out of the batch.
|
||
/// </summary>
|
||
/// <param name="handle">The pre-filtered node handle to serve; <c>handle.Index</c> indexes results/errors.</param>
|
||
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
|
||
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
|
||
/// <param name="read">Invokes the resolved data-source read with the resolved tagname; only called
|
||
/// once the tagname is confirmed present.</param>
|
||
/// <summary>
|
||
/// Run a batch of per-node HistoryRead work items with BOUNDED parallelism (arch-review 03/S3),
|
||
/// block-bridged ONCE at the arm boundary. <b>Contract:</b> every work item must be fully
|
||
/// self-contained — it fills its own results/errors slot and NEVER throws (all per-node handlers
|
||
/// catch every exception, including cancellation, and surface a Bad status for that node only). That
|
||
/// invariant is what makes the single <c>GetAwaiter().GetResult()</c> safe: <see cref="Task.WhenAll(Task[])"/>
|
||
/// can never fault, so it cannot throw out of the synchronous SDK override. A degree ≤ 1 (or a
|
||
/// single item) runs sequentially — no semaphore, no extra tasks.
|
||
/// </summary>
|
||
/// <param name="work">The per-node work items (index-disjoint writes into the shared results/errors).</param>
|
||
/// <param name="degree">Max concurrent work items (<see cref="HistoryReadBatchParallelism"/>).</param>
|
||
private static void RunBounded(IReadOnlyList<Func<Task>> work, int degree)
|
||
{
|
||
if (work.Count == 0) return;
|
||
if (degree <= 1 || work.Count == 1)
|
||
{
|
||
foreach (var item in work)
|
||
item().GetAwaiter().GetResult();
|
||
return;
|
||
}
|
||
|
||
using var gate = new SemaphoreSlim(degree);
|
||
var tasks = new Task[work.Count];
|
||
for (var i = 0; i < work.Count; i++)
|
||
tasks[i] = RunGatedAsync(gate, work[i]);
|
||
|
||
// Safe: every body is self-contained + never throws, so WhenAll never faults (see contract above).
|
||
Task.WhenAll(tasks).GetAwaiter().GetResult();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Run one HistoryRead arm under the process-wide concurrent-batch limiter (arch-review 03/S3). Each
|
||
/// arm waits up to <c>min(HistoryReadDeadline, 5 s)</c> for a slot; on expiry every handle in the
|
||
/// batch gets <c>BadTooManyOperations</c> (fast-fail under flood, brief waits under mild contention)
|
||
/// and <paramref name="serve"/> is skipped. On acquisition the arm body runs and the slot is
|
||
/// released in a <c>finally</c>. Combined with <see cref="HistoryReadBatchParallelism"/> this caps
|
||
/// total in-flight gateway reads at
|
||
/// <c>MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism</c>.
|
||
/// </summary>
|
||
/// <param name="nodesToProcess">The batch's handles — every slot is failed on limiter saturation.</param>
|
||
/// <param name="results">The service-level results list.</param>
|
||
/// <param name="errors">The service-level errors list.</param>
|
||
/// <param name="serve">The arm body to run once a slot is acquired.</param>
|
||
private void WithHistoryReadBatchLimiter(
|
||
List<NodeHandle> nodesToProcess,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
Action serve) =>
|
||
WithHistoryBatchLimiter(nodesToProcess, errors, serve, handle =>
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations });
|
||
|
||
/// <summary>
|
||
/// HistoryUpdate counterpart of <see cref="WithHistoryReadBatchLimiter(List{NodeHandle}, IList{SdkHistoryReadResult}, IList{ServiceResult}, Action)"/>:
|
||
/// the SAME process-wide limiter and admission wait, so update batches and read batches share one
|
||
/// concurrency budget against the historian backend.
|
||
/// </summary>
|
||
/// <param name="nodesToProcess">The batch's handles — every slot is failed on limiter saturation.</param>
|
||
/// <param name="results">The service-level HistoryUpdate results list.</param>
|
||
/// <param name="errors">The service-level errors list.</param>
|
||
/// <param name="serve">The arm body to run once a slot is acquired.</param>
|
||
private void WithHistoryReadBatchLimiter(
|
||
List<NodeHandle> nodesToProcess,
|
||
IList<HistoryUpdateResult> results,
|
||
IList<ServiceResult> errors,
|
||
Action serve) =>
|
||
WithHistoryBatchLimiter(nodesToProcess, errors, serve, handle =>
|
||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTooManyOperations });
|
||
|
||
/// <summary>Shared body of the HistoryRead / HistoryUpdate batch limiter.</summary>
|
||
/// <param name="nodesToProcess">The batch's handles.</param>
|
||
/// <param name="errors">The service-level errors list.</param>
|
||
/// <param name="serve">The arm body.</param>
|
||
/// <param name="rejectResult">Fills the per-node result slot when the batch is rejected.</param>
|
||
private void WithHistoryBatchLimiter(
|
||
List<NodeHandle> nodesToProcess,
|
||
IList<ServiceResult> errors,
|
||
Action serve,
|
||
Action<NodeHandle> rejectResult)
|
||
{
|
||
var gate = GetHistoryReadBatchLimiter();
|
||
|
||
// Bounded admission wait: cap the block at 5 s, and never exceed the per-request deadline.
|
||
var waitMs = HistoryReadDeadline > TimeSpan.Zero
|
||
? Math.Min(HistoryReadDeadline.TotalMilliseconds, 5000.0)
|
||
: 5000.0;
|
||
|
||
if (!gate.Wait(TimeSpan.FromMilliseconds(waitMs)))
|
||
{
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadTooManyOperations;
|
||
rejectResult(handle);
|
||
}
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
serve();
|
||
}
|
||
finally
|
||
{
|
||
gate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Lazily create (double-checked) the process-wide HistoryRead-batch limiter, sized from
|
||
/// <see cref="MaxConcurrentHistoryReadBatches"/> at first use so the Host's post-boot property set is
|
||
/// respected (the same benign wiring race as <see cref="MaxTieClusterOverfetch"/>).
|
||
/// </summary>
|
||
/// <returns>The shared batch limiter semaphore.</returns>
|
||
private SemaphoreSlim GetHistoryReadBatchLimiter()
|
||
{
|
||
var gate = _historyReadBatchLimiter;
|
||
if (gate is not null) return gate;
|
||
lock (_historyReadBatchLimiterLock)
|
||
{
|
||
return _historyReadBatchLimiter ??= new SemaphoreSlim(Math.Max(1, MaxConcurrentHistoryReadBatches));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Create the per-request deadline source for a HistoryRead arm (arch-review 03/S3): a
|
||
/// <see cref="CancellationTokenSource"/> that fires after <see cref="HistoryReadDeadline"/>, so a
|
||
/// single request can never hold an SDK request thread longer than the deadline regardless of node
|
||
/// count. Returns <c>null</c> when the deadline is non-positive (unbounded — the arm then passes
|
||
/// <see cref="CancellationToken.None"/>), matching the <c>ServerHistorianOptions</c> warning.
|
||
/// </summary>
|
||
/// <returns>The deadline source, or <c>null</c> for an unbounded (non-positive) deadline.</returns>
|
||
private CancellationTokenSource? CreateDeadline() =>
|
||
HistoryReadDeadline > TimeSpan.Zero ? new CancellationTokenSource(HistoryReadDeadline) : null;
|
||
|
||
/// <summary>Awaits one work item under a semaphore permit, releasing it when the item completes.</summary>
|
||
/// <param name="gate">The concurrency-limiting semaphore.</param>
|
||
/// <param name="work">The per-node work item.</param>
|
||
private static async Task RunGatedAsync(SemaphoreSlim gate, Func<Task> work)
|
||
{
|
||
await gate.WaitAsync().ConfigureAwait(false);
|
||
try
|
||
{
|
||
await work().ConfigureAwait(false);
|
||
}
|
||
finally
|
||
{
|
||
gate.Release();
|
||
}
|
||
}
|
||
|
||
private async Task ServeNodeAsync(
|
||
NodeHandle handle,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
Func<IHistorianDataSource, string, CancellationToken, Task<HistorianRead>> read,
|
||
CancellationToken ct)
|
||
{
|
||
if (!TryGetHistorizedTagname(handle.NodeId, out var tagname))
|
||
{
|
||
// Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
|
||
// status, but set it explicitly so the contract is local + obvious.)
|
||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so awaiting
|
||
// the async source here is safe and won't freeze the address space.
|
||
var sourceResult = await read(HistorianDataSource, tagname!, ct).ConfigureAwait(false);
|
||
var historyData = ToHistoryData(sourceResult);
|
||
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
// No source samples ⇒ GoodNoData (the node is historized, the window just held no data).
|
||
StatusCode = historyData.DataValues.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||
HistoryData = new ExtensionObject(historyData),
|
||
// Single-shot arms (Processed / AtTime) never page — the backend returns the complete
|
||
// result in one read (no client count cap to detect a "full page" against), so no
|
||
// continuation point. Raw pages via ServeRawPaged, not this helper.
|
||
ContinuationPoint = null,
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// The per-request deadline elapsed while this node was in flight — surface BadTimeout for
|
||
// THIS node only (a spec-conformant history-read status), ahead of the generic catch.
|
||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||
}
|
||
catch (HistoryAggregateNotSupportedException ex)
|
||
{
|
||
// The backend cannot serve this Part 13 aggregate (#285) — the spec status for THIS node,
|
||
// not a logged server fault.
|
||
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend does not support aggregate for node {NodeId}", handle.NodeId);
|
||
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
|
||
}
|
||
catch (HistoryReadLimitExceededException ex)
|
||
{
|
||
// The read would exceed a backend size limit (#671: the time-weighted raw-row cap) — refuse THIS node
|
||
// explicitly instead of a generic fault or a deadline-elapsed BadTimeout.
|
||
_logger.LogWarning("OtOpcUaNodeManager: HistoryRead limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message);
|
||
errors[handle.Index] = StatusCodes.BadTooManyOperations;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
|
||
// Bad status for THIS node only. Logged through the injected ILogger
|
||
// rather than swallowed silently.
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead failed for node {NodeId}", handle.NodeId);
|
||
errors[handle.Index] = HistoryReadFailureStatus(ex);
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Serve one historized variable handle for a HistoryRead-Raw request WITH server-side
|
||
/// continuation-point paging. The single-shot Wonderware backend does not page, so paging is
|
||
/// synthesised time-based:
|
||
/// <list type="bullet">
|
||
/// <item><b>Fresh read</b> (no inbound continuation point): read the window from
|
||
/// <c>details.StartTime</c> to <paramref name="endUtc"/> capped at
|
||
/// <paramref name="numValuesPerNode"/> <b>+ 1</b> — one lookahead row (#692). If the lookahead row came
|
||
/// back, return the first N, store a resume cursor and emit a continuation point; otherwise the page is
|
||
/// the last and carries none (so a total that is an exact multiple of N ends on a full page, not on an
|
||
/// empty trailing one).</item>
|
||
/// <item><b>Resume read</b> (inbound continuation point present): take the stored cursor, read
|
||
/// <c>N + boundarySkip + 1</c> rows from the boundary (the ties about to be trimmed, the page, and the
|
||
/// lookahead row), trim the already-emitted boundary ties, and continue exactly as a fresh read — so every
|
||
/// page carries N values. (Asking for only N used to net N − skip per resumed page, and at N = 1 an EMPTY
|
||
/// page between every value: the trimmed page looked like a stalled tie cluster and paid a
|
||
/// <see cref="MaxTieClusterOverfetch"/> + 1 over-fetch to step one tick — Gitea #692.)</item>
|
||
/// </list>
|
||
/// The resume cursor is tie-safe (see <see cref="HistoryPaging.ComputeResumeCursor"/> /
|
||
/// <see cref="HistoryPaging.TrimBoundaryDuplicates"/>): the next page resumes from the boundary
|
||
/// timestamp INCLUSIVE and drops the head ties already returned, so samples sharing the boundary
|
||
/// SourceTimestamp are neither duplicated nor skipped. Continuation points live in
|
||
/// <see cref="HistoryContinuationStore"/> — session-bound + capped in production. Per-node error
|
||
/// isolation matches <see cref="ServeNode"/>: a backend throw / an unknown continuation point
|
||
/// becomes a Bad status for THIS node only and never throws out of the batch.
|
||
/// </summary>
|
||
/// <param name="handle">The pre-filtered node handle; <c>handle.Index</c> indexes results/errors.</param>
|
||
/// <param name="session">The session the read runs under (null on the session-less in-process path).</param>
|
||
/// <param name="nodesToRead">The per-node read list; <c>nodesToRead[handle.Index].ContinuationPoint</c>
|
||
/// carries the inbound continuation point (non-null ⇒ a resume read).</param>
|
||
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
|
||
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
|
||
/// <param name="startTimeUtc">The request window's (inclusive) lower bound, used for a fresh read.</param>
|
||
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.</param>
|
||
/// <param name="numValuesPerNode">The client's per-page cap; <c>0</c> means "all values, no paging".</param>
|
||
/// <param name="ct">Cancellation (the per-request deadline).</param>
|
||
/// <param name="returnBounds">
|
||
/// Part 11 bounding values (#691, <see cref="HistoryBounds"/>): the start bound leads the first page, the end bound
|
||
/// ends the last, and both count toward <paramref name="numValuesPerNode"/>. A resumed read takes it from the cursor.
|
||
/// </param>
|
||
private async Task ServeRawPagedAsync(
|
||
NodeHandle handle,
|
||
ISession? session,
|
||
IList<HistoryReadValueId> nodesToRead,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
DateTime startTimeUtc,
|
||
DateTime endUtc,
|
||
uint numValuesPerNode,
|
||
CancellationToken ct,
|
||
bool returnBounds = false)
|
||
{
|
||
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
|
||
|
||
try
|
||
{
|
||
DateTime startUtc;
|
||
var boundarySkip = 0;
|
||
var walkHint = default(ReverseWalkHint); // #686/#689/#691: the walk-back's width + proven-empty stretch, page to page
|
||
HistoryEndBound? endBound = null; // #691: the end bound still owed (null = bounds not requested)
|
||
var withinTieCluster = false; // #692: the previous page sliced an oversized tie cluster that still has ties
|
||
|
||
string tagname;
|
||
if (inboundCp is { Length: > 0 })
|
||
{
|
||
// Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
|
||
// BadContinuationPointInvalid for THIS node.
|
||
var state = _historyContinuationStore.TryTake(session, inboundCp);
|
||
if (state is null || state.Kind != HistoryCursorKind.Raw
|
||
|| !TryGetHistorizedTagname(handle.NodeId, out var boundTag) || boundTag != state.Tagname)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
|
||
return;
|
||
}
|
||
|
||
tagname = state.Tagname;
|
||
startUtc = state.NextStartUtc;
|
||
boundarySkip = state.BoundarySkipCount;
|
||
endUtc = state.EndUtc;
|
||
numValuesPerNode = state.NumValuesPerNode;
|
||
walkHint = state.WalkHint;
|
||
endBound = state.PendingEndBound;
|
||
withinTieCluster = state.WithinTieCluster;
|
||
}
|
||
else
|
||
{
|
||
// Fresh read: resolve the node's historian tagname (as ServeNode does).
|
||
if (!TryGetHistorizedTagname(handle.NodeId, out var resolved) || resolved is null)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||
return;
|
||
}
|
||
|
||
tagname = resolved;
|
||
startUtc = startTimeUtc;
|
||
|
||
// Part 11: with NumValuesPerNode > 0 one bound may be left unset. Only a StartTime ⇒ page FORWARD from
|
||
// it to an open upper bound; only an EndTime ⇒ page BACKWARD from it (a reverse read, start > end).
|
||
// Without this an unset EndTime (MinValue) looked like a reverse read to a lower bound of "now".
|
||
// Every backend serves the backward read (#686): natively when it declares SupportsReverseRaw, otherwise
|
||
// walked back from bounded forward reads below — unlike the events path, which keeps it forward there.
|
||
if (numValuesPerNode > 0)
|
||
{
|
||
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
||
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
|
||
else if (startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
|
||
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
||
}
|
||
|
||
if (returnBounds) endBound = HistoryBounds.For(startUtc, endUtc);
|
||
}
|
||
|
||
var nowUtc = Clock.GetUtcNow().UtcDateTime;
|
||
var resolution = Math.Max(1L, HistorianDataSource.TimestampResolutionTicks);
|
||
|
||
// #691: the data is drained — the end bound ends this page when it has room (Part 11: bounds count toward
|
||
// NumValuesPerNode), else it is the whole next page. Returns the continuation point to emit, if any.
|
||
async Task<byte[]?> AppendEndBoundAsync(List<DataValueSnapshot> page, DateTime resumedAtUtc)
|
||
{
|
||
// The previous returned value stamps an open bound's placeholder (Part 11 Table 1 footnotes a/b): this
|
||
// page's last, else the last of an earlier page (carried in the cursor), else where the read resumed.
|
||
var previous = (page.Count > 0 ? page[^1].SourceTimestampUtc : null)
|
||
?? endBound!.LastReturnedUtc ?? resumedAtUtc;
|
||
if (numValuesPerNode == 0 || page.Count < numValuesPerNode)
|
||
{
|
||
page.Add(await HistoryBounds.EndBoundAsync(
|
||
HistorianDataSource, tagname, endBound!, previous, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false));
|
||
return null;
|
||
}
|
||
|
||
return _historyContinuationStore.Save(session, new HistoryContinuationState(
|
||
tagname, previous, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
|
||
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
|
||
PendingEndBound: HistoryBounds.Carry(endBound!, page)! with { DataExhausted = true }));
|
||
}
|
||
|
||
// #691 review: whether a bounded read whose start bound was NOT found has any data or a found end bound — only
|
||
// asked when a one-value page would otherwise be the start-bound placeholder alone. #692: what it learns rides the
|
||
// cursor — the walk hint its walk-back computed, and, when there is no data, the end bound it found (so the next
|
||
// page is that bound with no backend read at all).
|
||
async Task<(bool Any, HistoryEndBound Pending)> BoundedReadHasAnythingAsync()
|
||
{
|
||
var src = HistorianDataSource;
|
||
IReadOnlyList<DataValueSnapshot> first;
|
||
if (startUtc > endUtc && !src.SupportsReverseRaw)
|
||
{
|
||
var walked = await ReverseRawReader.ReadAsync(
|
||
src, tagname, startUtc, endUtc, 1, MaxTieClusterOverfetch, nowUtc, walkHint, ct)
|
||
.ConfigureAwait(false);
|
||
walkHint = walked.Hint;
|
||
first = walked.Samples;
|
||
}
|
||
else first = (await src.ReadRawAsync(tagname, startUtc, endUtc, 1, ct).ConfigureAwait(false)).Samples;
|
||
|
||
if (first.Any(x => HistoryBounds.InDataWindow(x, endBound!, resolution))) return (true, endBound!);
|
||
var end = await HistoryBounds.EndBoundAsync(
|
||
src, tagname, endBound!, startUtc, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false);
|
||
return HistoryBounds.IsPlaceholder(end)
|
||
? (false, endBound!)
|
||
: (true, endBound! with { DataExhausted = true, Resolved = end });
|
||
}
|
||
|
||
// #691: a resumed bounded read whose data is drained (the end bound did not fit on the previous page, or the
|
||
// resume point already sits on the end time) owes only the end bound.
|
||
if (endBound is not null && inboundCp is { Length: > 0 }
|
||
&& (endBound.DataExhausted || HistoryBounds.DataWindowDrained(startUtc, endBound, resolution)))
|
||
{
|
||
var onlyBound = new List<DataValueSnapshot>(1);
|
||
var afterCp = await AppendEndBoundAsync(onlyBound, startUtc).ConfigureAwait(false);
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
StatusCode = StatusCodes.Good,
|
||
HistoryData = new ExtensionObject(ToHistoryDataFromSamples(onlyBound)),
|
||
ContinuationPoint = afterCp,
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
return;
|
||
}
|
||
|
||
// #691: the start bound leads the first page and counts toward the cap; null when a sample sits on StartTime
|
||
// (that sample is the bound, and it is the data's first).
|
||
DataValueSnapshot? startBound = null;
|
||
var readCap = numValuesPerNode;
|
||
if (endBound is not null && inboundCp is not { Length: > 0 })
|
||
{
|
||
startBound = await HistoryBounds.StartBoundAsync(
|
||
HistorianDataSource, tagname, startUtc, endBound.Reverse, MaxTieClusterOverfetch, nowUtc, ct)
|
||
.ConfigureAwait(false);
|
||
var (anything, pending) = startBound is not null && numValuesPerNode == 1
|
||
? HistoryBounds.IsPlaceholder(startBound)
|
||
? await BoundedReadHasAnythingAsync().ConfigureAwait(false)
|
||
: (true, endBound)
|
||
: (false, endBound);
|
||
if (startBound is not null && numValuesPerNode == 1 && anything)
|
||
{
|
||
// The page is the start bound alone; the data starts at StartTime on the next page.
|
||
var boundOnlyCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
|
||
tagname, startUtc, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
|
||
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
|
||
PendingEndBound: HistoryBounds.Carry(pending, [startBound])));
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
StatusCode = StatusCodes.Good,
|
||
HistoryData = new ExtensionObject(ToHistoryDataFromSamples([startBound])),
|
||
ContinuationPoint = boundOnlyCp,
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
return;
|
||
}
|
||
|
||
if (startBound is not null && numValuesPerNode == 1)
|
||
{
|
||
// Nothing at all — no data and no bound found (review of #691): the pre-bounds GoodNoData answer.
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
StatusCode = StatusCodes.GoodNoData,
|
||
HistoryData = new ExtensionObject(ToHistoryDataFromSamples([])),
|
||
ContinuationPoint = null,
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
return;
|
||
}
|
||
|
||
if (startBound is not null && numValuesPerNode > 0) readCap = numValuesPerNode - 1;
|
||
}
|
||
|
||
// #692: the page's read asks for the boundary ties it is about to trim (a resumed page re-reads them, because the
|
||
// cursor resumes AT the boundary timestamp, inclusive) plus ONE lookahead row past the page. Without them a resumed
|
||
// page netted N − skip values, and at N = 1 nothing at all: the lone boundary sample came back, was trimmed, the
|
||
// "full" empty page was taken for a stalled tie cluster, and a MaxTieClusterOverfetch + 1 over-fetch stepped one
|
||
// tick to return an EMPTY page with a continuation point — between every value. The lookahead row is what says
|
||
// "more data follows": a page whose read found no row past it is the last, so the read no longer ends with an
|
||
// empty trailing page either. (RawPageLimit.Plan computes the request: page + boundary ties + 1.)
|
||
var resumed = inboundCp is { Length: > 0 };
|
||
var boundaryTies = resumed ? Math.Max(0, boundarySkip) : 0;
|
||
var want = readCap; // N, less the start bound on a bounded first page; 0 = all values
|
||
|
||
// HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe.
|
||
// A reverse window (explicit, resumed, or the end-only read above) goes to the backend only when it declares it
|
||
// can read newest-first; otherwise it is walked back from bounded forward reads (#686 — the Gateway rejects
|
||
// start > end, which failed the node and counted as a backend fault).
|
||
// A cursor left inside an oversized tie cluster (#692) skips this read: every row it could return is a tie.
|
||
// #694: a backend with a row ceiling (MaxRawValuesPerRead = L > 0, a strict gateway) sizes the page to it — the
|
||
// read asks for page + boundary ties + lookahead ≤ L, and a full page carries a continuation point as always
|
||
// (uncapped reads included). No room left after the boundary ties ⇒ the cursor sits in a tie cluster as large as
|
||
// a read: straight to the over-fetch. See RawPageLimit.
|
||
var source = HistorianDataSource;
|
||
IReadOnlyList<DataValueSnapshot> samples = [];
|
||
var backendFull = false;
|
||
int pageLimit;
|
||
if (!withinTieCluster)
|
||
{
|
||
var read = await RawPageLimit.ReadAsync(source, want, boundaryTies, async plan =>
|
||
{
|
||
if (startUtc > endUtc && !source.SupportsReverseRaw)
|
||
{
|
||
var page = await ReverseRawReader.ReadAsync(
|
||
source, tagname, startUtc, endUtc, plan.RequestCap, MaxTieClusterOverfetch,
|
||
nowUtc, walkHint, ct, retryOnLearnedLimit: false)
|
||
.ConfigureAwait(false);
|
||
walkHint = page.Hint;
|
||
return new HistorianRead(page.Samples, ContinuationPoint: null);
|
||
}
|
||
|
||
return await source.ReadRawAsync(tagname, startUtc, endUtc, plan.RequestCap, ct).ConfigureAwait(false);
|
||
}).ConfigureAwait(false);
|
||
pageLimit = read.Plan.Limit;
|
||
|
||
if (read.Plan.NoRoom)
|
||
{
|
||
if (!resumed)
|
||
throw new HistoryReadLimitExceededException(pageLimit,
|
||
$"The backend's row ceiling ({pageLimit} values per read) leaves no room for a raw page of '{tagname}'.");
|
||
withinTieCluster = true; // every row a read could return is a boundary tie already emitted
|
||
}
|
||
else
|
||
{
|
||
readCap = read.Plan.PageCap;
|
||
var sourceResult = read.Page!;
|
||
backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, read.Plan.RequestCap);
|
||
|
||
// On a resume read, drop the boundary ties already returned on the prior page.
|
||
samples = resumed
|
||
? HistoryPaging.TrimBoundaryDuplicates(sourceResult.Samples, startUtc, boundarySkip)
|
||
: sourceResult.Samples;
|
||
|
||
// #691: a bounded read's data window excludes the end time (a sample there is the end bound). A backend whose
|
||
// forward end is inclusive (the gateway's own) returns it as data; dropping it also means the data is drained.
|
||
if (endBound is not null)
|
||
{
|
||
var inside = samples.Where(x => HistoryBounds.InDataWindow(x, endBound, resolution)).ToList();
|
||
if (inside.Count != samples.Count)
|
||
{
|
||
samples = inside;
|
||
backendFull = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
pageLimit = source.MaxRawValuesPerRead;
|
||
}
|
||
|
||
// Oversized tie cluster: a resume read whose FULL backend read does not advance past the resume boundary
|
||
// timestamp `startUtc` — every row it returned past the trimmed ties, the lookahead row included, still sits on
|
||
// `startUtc`. That happens only when more samples share `startUtc` than the page plus the already-emitted ties
|
||
// plus one: a genuine tie cluster larger than the page. The fixed-(start,end,cap) backend can only ever return
|
||
// the first `cap` of those ties, so growing the read page by page would re-read the cluster quadratically.
|
||
// Instead over-fetch the WHOLE cluster with an explicit, BOUNDED cap (a start==end read at the boundary
|
||
// timestamp) and page WITHIN the timestamp via SliceTieCluster. (#692: before the read asked for the trimmed ties
|
||
// and a lookahead row, a resumed N = 1 page tripped this on every single sample.)
|
||
var stalledOnTieCluster = resumed
|
||
&& (withinTieCluster
|
||
|| (backendFull
|
||
&& (samples.Count == 0
|
||
|| (samples[^1].SourceTimestampUtc ?? DateTime.MinValue) == startUtc)));
|
||
if (stalledOnTieCluster)
|
||
{
|
||
// The over-fetch cap MUST be explicit and non-zero: a cap of 0 falls back to the backend's
|
||
// MaxValuesPerRead, which would re-introduce the very stall we're escaping. +1 over the bound
|
||
// lets us DETECT a cluster strictly larger than the bound (the absurd-burst backstop below).
|
||
// Compute in uint so +1 can never wrap: Math.Max(1,…) also guards against a zero/negative
|
||
// config value slipping through (Validate() warns but does not block startup).
|
||
// #694: on a backend with a row ceiling the bound is also at most L − 1, so the over-fetch (bound + 1 rows)
|
||
// fits one read and an oversized cluster is refused here rather than by a gateway refusal.
|
||
var overfetchBound = pageLimit > 0
|
||
? Math.Min(Math.Max(1, MaxTieClusterOverfetch), Math.Max(1, pageLimit - 1))
|
||
: Math.Max(1, MaxTieClusterOverfetch);
|
||
var overfetchCap = (uint)overfetchBound + 1u;
|
||
IReadOnlyList<DataValueSnapshot> cluster = (await HistorianDataSource
|
||
.ReadRawAsync(tagname, startUtc, startUtc, overfetchCap, ct)
|
||
.ConfigureAwait(false)).Samples;
|
||
|
||
// A start == end point read returns the ties in the backend's FORWARD order, but a reverse read
|
||
// emitted them (and counted boundarySkip) in descending order — the mirror of forward. Slice the
|
||
// cluster in the read's own direction, or the skip would index from the wrong end and re-emit ties.
|
||
var reverse = startUtc > endUtc;
|
||
if (reverse) cluster = cluster.Reverse().ToList();
|
||
|
||
// Absurd burst: more ties than we're willing to buffer in memory. Preserve today's loud-fail
|
||
// for that node rather than over-fetch an unbounded cluster; the operator's remedy is a
|
||
// larger ServerHistorian:MaxTieClusterOverfetch (or NumValuesPerNode).
|
||
if (cluster.Count > overfetchBound)
|
||
{
|
||
_logger.LogError(
|
||
"OtOpcUaNodeManager: HistoryReadRaw tie cluster at {StartUtc:O} for tag '{Tagname}' has {Count} samples, " +
|
||
"exceeding {Max} (MaxTieClusterOverfetch, or the backend's row ceiling less one); cannot page within it. " +
|
||
"Increase MaxTieClusterOverfetch (or the HistorianGateway MaxValuesCeiling).",
|
||
startUtc, tagname, cluster.Count, overfetchBound);
|
||
errors[handle.Index] = StatusCodes.BadTooManyOperations;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
|
||
return;
|
||
}
|
||
|
||
// Direction comes from the window itself (Part 11: start > end is a reverse read). A resumed reverse
|
||
// read keeps start > end, because a reverse resume point is always strictly after endUtc.
|
||
// #694: an uncapped read pages only at the backend's limit, so it slices at that page size — never 0 (a zero
|
||
// slice hands back the same cursor forever; a limit that reverted to 0 under an uncapped cursor in a cluster
|
||
// falls back to the cluster's own size).
|
||
var sliceSize = numValuesPerNode > 0 ? numValuesPerNode
|
||
: readCap > 0 ? readCap
|
||
: pageLimit > 0 ? (uint)pageLimit
|
||
: (uint)Math.Max(1, cluster.Count);
|
||
HistoryPaging.SliceTieCluster(
|
||
cluster.Count, boundarySkip, sliceSize, startUtc, endUtc,
|
||
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
|
||
reverse: reverse, resolutionTicks: Math.Max(1L, HistorianDataSource.TimestampResolutionTicks));
|
||
|
||
var slice = new List<DataValueSnapshot>(sliceCount);
|
||
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
|
||
|
||
// Emit a continuation point only when SliceTieCluster says the read continues (within the
|
||
// cluster, or past it while the window remains). nextSkip is the boundary skip for the next
|
||
// page — within the cluster it counts the ties already emitted at startUtc; past it it's 0.
|
||
byte[]? clusterCp = null;
|
||
if (nextStartUtc is { } resumeAt)
|
||
{
|
||
var clusterState = new HistoryContinuationState(
|
||
tagname, resumeAt, endUtc, nextSkip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
|
||
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
|
||
PendingEndBound: HistoryBounds.Carry(endBound, slice),
|
||
WithinTieCluster: resumeAt == startUtc);
|
||
clusterCp = _historyContinuationStore.Save(session, clusterState);
|
||
}
|
||
else if (endBound is not null)
|
||
{
|
||
// #691: the data is drained — the end bound ends this page, or is the next one when this one is full.
|
||
clusterCp = await AppendEndBoundAsync(slice, startUtc).ConfigureAwait(false);
|
||
}
|
||
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
StatusCode = slice.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||
HistoryData = new ExtensionObject(ToHistoryDataFromSamples(slice)),
|
||
ContinuationPoint = clusterCp,
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
return;
|
||
}
|
||
|
||
// #692: more than the page came back (the lookahead row, after trimming) ⇒ there is more data: return the page and
|
||
// resume after it. Otherwise the data is drained and this is the last data page.
|
||
var more = readCap > 0 && samples.Count > readCap;
|
||
if (more) samples = samples.Take((int)readCap).ToList();
|
||
|
||
HistoryContinuationState? nextState = null;
|
||
if (more)
|
||
{
|
||
// A page that never left the boundary timestamp (ties continuing a resumed page) adds to the ties already
|
||
// emitted there, or the next page would re-emit the earlier ones.
|
||
HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
|
||
if (resumed && nextStart == startUtc) skip += boundarySkip;
|
||
nextState = new HistoryContinuationState(
|
||
tagname, nextStart, endUtc, skip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
|
||
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
|
||
PendingEndBound: HistoryBounds.Carry(endBound, samples));
|
||
}
|
||
|
||
// #691: the start bound (first page only) leads the data.
|
||
if (startBound is not null)
|
||
{
|
||
var led = new List<DataValueSnapshot>(samples.Count + 2) { startBound };
|
||
led.AddRange(samples);
|
||
samples = led;
|
||
}
|
||
|
||
var historyData = ToHistoryDataFromSamples(samples);
|
||
|
||
byte[]? outboundCp = null;
|
||
if (nextState is not null)
|
||
{
|
||
// Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
|
||
// Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
|
||
outboundCp = _historyContinuationStore.Save(session, nextState);
|
||
}
|
||
else if (endBound is not null)
|
||
{
|
||
// #691: the data is drained — the end bound ends this page, or is the next one when this one is full.
|
||
var page = new List<DataValueSnapshot>(samples.Count + 1);
|
||
page.AddRange(samples);
|
||
outboundCp = await AppendEndBoundAsync(page, startUtc).ConfigureAwait(false);
|
||
samples = page;
|
||
historyData = ToHistoryDataFromSamples(samples);
|
||
}
|
||
|
||
// #691 review: a whole bounded read with no data and no bound found (one page, every value a placeholder) is the
|
||
// pre-bounds answer — GoodNoData, no values (§6.5.3.2: bounds make it a success only "if one or both exist").
|
||
if (endBound is not null && inboundCp is not { Length: > 0 } && outboundCp is null
|
||
&& samples.All(HistoryBounds.IsPlaceholder))
|
||
{
|
||
samples = [];
|
||
historyData = ToHistoryDataFromSamples(samples);
|
||
}
|
||
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
// No samples ⇒ GoodNoData (the node is historized, the window just held no data). With the
|
||
// degenerate-cluster guard above, a resumed empty page now only means the window/cluster is
|
||
// genuinely drained — never silent data loss.
|
||
StatusCode = samples.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||
HistoryData = new ExtensionObject(historyData),
|
||
ContinuationPoint = outboundCp,
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// The per-request deadline elapsed while paging this node — BadTimeout for THIS node only.
|
||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||
}
|
||
catch (HistoryReadLimitExceededException ex)
|
||
{
|
||
// A request limit (#686: a reverse read walked back from forward reads met a tie cluster over the row
|
||
// budget) — BadTooManyOperations for THIS node, as on the processed path; not a backend fault.
|
||
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadRaw limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message);
|
||
errors[handle.Index] = StatusCodes.BadTooManyOperations;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// One node's backend failure must not throw out of the batch — Bad for THIS node only.
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryReadRaw (paged) failed for node {NodeId}", handle.NodeId);
|
||
errors[handle.Index] = HistoryReadFailureStatus(ex);
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Serve one historized variable for a HistoryReadRaw with <c>IsReadModified = true</c> (Part 11 modified-value
|
||
/// history, Gitea #668), with continuation-point paging. The backend orders the trail by
|
||
/// <c>(SourceTimeUtc, ModifiedId)</c> and reports truncation itself (<see cref="HistoryModifiedReadResult.HasMore"/>),
|
||
/// so the cursor is an exact, exclusive key — there are no ties to trim. Window normalisation matches
|
||
/// <see cref="ServeRawPagedAsync"/>: with a cap, an unset EndTime pages forward to an open bound and an unset
|
||
/// StartTime pages backward. A cursor is bound to the node's tagname and to the modified read kind; any other
|
||
/// point is <c>BadContinuationPointInvalid</c>. A backend that keeps no trail (Gateway, Null) throws
|
||
/// <see cref="NotSupportedException"/> ⇒ <c>BadHistoryOperationUnsupported</c> for this node.
|
||
/// </summary>
|
||
/// <param name="handle">The node handle; <c>handle.Index</c> indexes results/errors.</param>
|
||
/// <param name="session">The session (null on the session-less in-process path ⇒ single-shot).</param>
|
||
/// <param name="nodesToRead">The per-node read list (inbound continuation points).</param>
|
||
/// <param name="results">The service-level results list.</param>
|
||
/// <param name="errors">The service-level errors list.</param>
|
||
/// <param name="startTimeUtc">Request StartTime.</param>
|
||
/// <param name="endUtc">Request EndTime.</param>
|
||
/// <param name="numValuesPerNode">Per-page cap; <c>0</c> = all.</param>
|
||
/// <param name="ct">Request deadline.</param>
|
||
private async Task ServeModifiedPagedAsync(
|
||
NodeHandle handle,
|
||
ISession? session,
|
||
IList<HistoryReadValueId> nodesToRead,
|
||
IList<SdkHistoryReadResult> results,
|
||
IList<ServiceResult> errors,
|
||
DateTime startTimeUtc,
|
||
DateTime endUtc,
|
||
uint numValuesPerNode,
|
||
CancellationToken ct)
|
||
{
|
||
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
|
||
try
|
||
{
|
||
if (!TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||
return;
|
||
}
|
||
|
||
var startUtc = startTimeUtc;
|
||
HistoryModifiedCursor? resumeAfter = null;
|
||
if (inboundCp is { Length: > 0 })
|
||
{
|
||
var state = _historyContinuationStore.TryTake(session, inboundCp);
|
||
if (state is null || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
|
||
return;
|
||
}
|
||
|
||
// The cursor carries the NORMALISED window, so a resumed page never re-derives its direction.
|
||
startUtc = state.WindowStartUtc;
|
||
endUtc = state.EndUtc;
|
||
numValuesPerNode = state.NumValuesPerNode;
|
||
resumeAfter = new HistoryModifiedCursor(state.NextStartUtc, state.LastModifiedId);
|
||
}
|
||
else if (numValuesPerNode > 0)
|
||
{
|
||
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
||
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
|
||
else if (startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
|
||
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
||
}
|
||
|
||
var page = await HistorianDataSource
|
||
.ReadModifiedAsync(tagname, startUtc, endUtc, numValuesPerNode, resumeAfter, ct)
|
||
.ConfigureAwait(false);
|
||
|
||
var data = ToHistoryModifiedData(page.Values);
|
||
byte[]? outboundCp = null;
|
||
if (page.HasMore && numValuesPerNode > 0 && page.Values.Count > 0)
|
||
{
|
||
var last = page.Values[^1];
|
||
outboundCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
|
||
tagname, last.SourceTimeUtc, endUtc, 0, numValuesPerNode,
|
||
HistoryCursorKind.Modified, last.ModifiedId, startUtc));
|
||
}
|
||
|
||
results[handle.Index] = new SdkHistoryReadResult
|
||
{
|
||
StatusCode = page.Values.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||
HistoryData = new ExtensionObject(data),
|
||
ContinuationPoint = outboundCp,
|
||
};
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||
}
|
||
catch (NotSupportedException ex)
|
||
{
|
||
// The backend keeps no modification trail (Gateway / Null) — the spec status, not a server fault.
|
||
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend serves no modified-value history for node {NodeId}", handle.NodeId);
|
||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead (modified) failed for node {NodeId}", handle.NodeId);
|
||
errors[handle.Index] = HistoryReadFailureStatus(ex);
|
||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Project a modification trail into an SDK <see cref="HistoryModifiedData"/>: one prior <see cref="DataValue"/>
|
||
/// per modification plus a parallel <see cref="ModificationInfo"/> (time, Part 11 update type, user name).
|
||
/// </summary>
|
||
/// <param name="values">The modifications, in the order to emit.</param>
|
||
/// <returns>The populated <see cref="HistoryModifiedData"/>.</returns>
|
||
internal static HistoryModifiedData ToHistoryModifiedData(IReadOnlyList<HistoryModifiedValue> values)
|
||
{
|
||
var dataValues = new DataValueCollection(values.Count);
|
||
var infos = new ModificationInfoCollection(values.Count);
|
||
foreach (var m in values)
|
||
{
|
||
dataValues.Add(ToSdkDataValue(m.Value));
|
||
infos.Add(new ModificationInfo
|
||
{
|
||
ModificationTime = m.ModificationTimeUtc,
|
||
UpdateType = (HistoryUpdateType)(int)m.UpdateType,
|
||
UserName = m.UserName,
|
||
});
|
||
}
|
||
|
||
return new HistoryModifiedData { DataValues = dataValues, ModificationInfos = infos };
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void HistoryReleaseContinuationPoints(
|
||
ServerSystemContext context,
|
||
IList<HistoryReadValueId> nodesToRead,
|
||
IList<ServiceResult> errors,
|
||
List<NodeHandle> nodesToProcess,
|
||
IDictionary<NodeId, NodeState> cache)
|
||
{
|
||
var session = context.OperationContext?.Session;
|
||
foreach (var handle in nodesToProcess)
|
||
{
|
||
var cp = nodesToRead[handle.Index].ContinuationPoint;
|
||
if (cp is { Length: > 0 })
|
||
{
|
||
_historyContinuationStore.Release(session, cp);
|
||
}
|
||
|
||
errors[handle.Index] = ServiceResult.Good;
|
||
}
|
||
}
|
||
|
||
/// <summary>Transport failures are retryable; unsupported operations retain their distinct status (#697).</summary>
|
||
private static uint HistoryReadFailureStatus(Exception error)
|
||
{
|
||
for (Exception? ex = error; ex is not null; ex = ex.InnerException)
|
||
if (ex is System.IO.IOException or System.Net.Sockets.SocketException or System.Net.Http.HttpRequestException or TimeoutException)
|
||
return StatusCodes.BadNoCommunication;
|
||
return StatusCodes.BadHistoryOperationUnsupported;
|
||
}
|
||
|
||
/// <summary>Normalize stored and AtTime history to each scalar node's declared DataType (#698).</summary>
|
||
private void NormalizeHistoryValues(List<NodeHandle> handles, IList<SdkHistoryReadResult> results)
|
||
{
|
||
foreach (var handle in handles)
|
||
{
|
||
if (AtTimeValueType(handle.NodeId) is not { } type || results[handle.Index]?.HistoryData is not { } body) continue;
|
||
var values = ExtensionObject.ToEncodeable(body) switch
|
||
{
|
||
HistoryData data => data.DataValues,
|
||
_ => null,
|
||
};
|
||
if (values is null) continue;
|
||
foreach (var value in values)
|
||
{
|
||
if (value.Value is null) continue;
|
||
try
|
||
{
|
||
// A gateway number cannot reconstruct a DateTime or a String which was never stored.
|
||
if ((type == TypeCode.DateTime && value.Value is not DateTime)
|
||
|| (type == TypeCode.String && value.Value is not string)) throw new InvalidCastException();
|
||
var converted = Convert.ChangeType(value.Value, type, System.Globalization.CultureInfo.InvariantCulture);
|
||
if (converted is float f && float.IsInfinity(f) && value.Value is double d && double.IsFinite(d))
|
||
throw new OverflowException();
|
||
value.WrappedValue = new Variant(converted);
|
||
}
|
||
catch (Exception ex) when (ex is InvalidCastException or OverflowException or FormatException)
|
||
{
|
||
value.WrappedValue = Variant.Null;
|
||
value.StatusCode = StatusCodes.BadTypeMismatch;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Project a plain sample list into an SDK <see cref="HistoryData"/> (the paged Raw path
|
||
/// works on a trimmed <see cref="IReadOnlyList{T}"/> rather than a whole <see cref="HistorianRead"/>).</summary>
|
||
/// <param name="samples">The samples to project (already trimmed of boundary duplicates).</param>
|
||
/// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
|
||
private static HistoryData ToHistoryDataFromSamples(IReadOnlyList<DataValueSnapshot> samples)
|
||
{
|
||
var values = new DataValueCollection(samples.Count);
|
||
foreach (var sample in samples) values.Add(ToSdkDataValue(sample));
|
||
return new HistoryData { DataValues = values };
|
||
}
|
||
|
||
/// <summary>
|
||
/// Project the historian source's <see cref="HistorianRead"/> (Core.Abstractions DTO) into an
|
||
/// SDK <see cref="HistoryData"/> — one <see cref="DataValue"/> per <see cref="DataValueSnapshot"/>,
|
||
/// carrying value / status / source+server timestamps. A null SourceTimestamp maps to
|
||
/// <c>DateTime.MinValue</c> (the SDK's "unset" sentinel for that field).
|
||
/// </summary>
|
||
/// <param name="sourceResult">The data source's read result.</param>
|
||
/// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
|
||
private static HistoryData ToHistoryData(HistorianRead sourceResult)
|
||
{
|
||
var values = new DataValueCollection(sourceResult.Samples.Count);
|
||
foreach (var sample in sourceResult.Samples)
|
||
{
|
||
values.Add(ToSdkDataValue(sample));
|
||
}
|
||
|
||
return new HistoryData { DataValues = values };
|
||
}
|
||
|
||
/// <summary>Convert one driver-agnostic <see cref="DataValueSnapshot"/> to an SDK
|
||
/// <see cref="DataValue"/>, mirroring value / status code / source + server timestamps.</summary>
|
||
/// <param name="snapshot">The source sample.</param>
|
||
/// <returns>The equivalent SDK data value.</returns>
|
||
private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new()
|
||
{
|
||
WrappedValue = new Variant(snapshot.Value),
|
||
StatusCode = new StatusCode(snapshot.StatusCode),
|
||
SourceTimestamp = snapshot.SourceTimestampUtc ?? DateTime.MinValue,
|
||
ServerTimestamp = snapshot.ServerTimestampUtc,
|
||
};
|
||
|
||
/// <inheritdoc />
|
||
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
||
{
|
||
lock (Lock)
|
||
{
|
||
base.CreateAddressSpace(externalReferences);
|
||
|
||
// Create one root folder under Objects/ for every variable we mint to hang under.
|
||
// Its identifier is V3NodeIds.SharedRootFolderId rather than a literal because the ACL gate
|
||
// keys the trie's realm-root scope off exactly this identity — see
|
||
// DataPlaneAuthorizer.IsSharedRootFolder. Both realm trees parent here (ResolveParentFolder),
|
||
// so the anchor is the one node every browse of this server passes through.
|
||
_root = new FolderState(null)
|
||
{
|
||
NodeId = new NodeId(V3NodeIds.SharedRootFolderId, NamespaceIndex),
|
||
BrowseName = new QualifiedName(V3NodeIds.SharedRootFolderId, NamespaceIndex),
|
||
DisplayName = V3NodeIds.SharedRootFolderId,
|
||
EventNotifier = EventNotifiers.None,
|
||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||
};
|
||
_root.AddReference(ReferenceTypeIds.Organizes, isInverse: true, ObjectIds.ObjectsFolder);
|
||
|
||
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
|
||
{
|
||
refs = new List<IReference>();
|
||
externalReferences[ObjectIds.ObjectsFolder] = refs;
|
||
}
|
||
refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId));
|
||
|
||
AddPredefinedNode(SystemContext, _root);
|
||
}
|
||
}
|
||
|
||
private BaseDataVariableState CreateVariable(string nodeId, ushort ns)
|
||
{
|
||
var v = new BaseDataVariableState(_root)
|
||
{
|
||
NodeId = new NodeId(nodeId, ns),
|
||
BrowseName = new QualifiedName(nodeId, ns),
|
||
DisplayName = nodeId,
|
||
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||
DataType = DataTypeIds.BaseDataType,
|
||
// Lazy-created nodes (a WriteValue for a node never declared via EnsureVariable) carry no
|
||
// array intent, so they stay scalar with no ArrayDimensions — mirrors EnsureVariable's
|
||
// scalar branch. Array nodes are always pre-declared via EnsureVariable(isArray:true).
|
||
ValueRank = ValueRanks.Scalar,
|
||
ArrayDimensions = null,
|
||
AccessLevel = AccessLevels.CurrentRead,
|
||
UserAccessLevel = AccessLevels.CurrentRead,
|
||
Historizing = false,
|
||
};
|
||
_root?.AddChild(v);
|
||
AddPredefinedNode(SystemContext, v);
|
||
return v;
|
||
}
|
||
|
||
}
|