7c4c7f9653
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
1554 lines
80 KiB
C#
1554 lines
80 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.MxGateway.Client;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
|
|
|
/// <summary>
|
|
/// In-process .NET 10 Galaxy driver — the only Galaxy backend since PR 7.2 retired
|
|
/// the legacy <c>Galaxy.Host</c> / <c>Galaxy.Proxy</c> / <c>Galaxy.Shared</c>
|
|
/// projects and the <c>OtOpcUaGalaxyHost</c> Windows service. Implements the full
|
|
/// capability surface: <see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
|
|
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IRediscoverable"/>,
|
|
/// <see cref="IHostConnectivityProbe"/>, and <see cref="IAlarmSource"/>. Galaxy
|
|
/// access flows through the in-process driver over gRPC to the separately
|
|
/// installed <c>mxaccessgw</c> gateway (sibling repo), which owns the MXAccess
|
|
/// COM apartment server-side.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Registered as a Tier A in-process driver alongside Modbus / S7 / etc. via
|
|
/// <see cref="GalaxyDriverFactoryExtensions"/> under driver-type name
|
|
/// "GalaxyMxGateway".
|
|
/// </para>
|
|
/// <para>
|
|
/// Tests inject capability seams (<see cref="IGalaxyHierarchySource"/>,
|
|
/// <see cref="IGalaxyDataReader"/>, <see cref="IGalaxyDataWriter"/>,
|
|
/// <see cref="IGalaxySubscriber"/>, <see cref="IGalaxyAlarmAcknowledger"/>,
|
|
/// <see cref="IGalaxyAlarmFeed"/>) via the internal ctor so capability flow
|
|
/// can be exercised without a real gw round-trip.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class GalaxyDriver
|
|
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
|
{
|
|
/// <summary>Authored tags this driver could not dial (Gitea #541) — a TagConfig with no usable
|
|
/// <c>attributeRef</c> has no MXAccess address to bind.</summary>
|
|
private readonly SkippedTagLedger _skippedTags = new();
|
|
|
|
/// <inheritdoc />
|
|
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
|
|
|
|
private readonly string _driverInstanceId;
|
|
private readonly GalaxyDriverOptions _options;
|
|
private readonly ILogger<GalaxyDriver> _logger;
|
|
|
|
// Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store.
|
|
// Injected via ctor (the production factory pulls it from DI). The internal test ctor
|
|
// defaults it to a null-object resolver so the 45 seam-injecting test call sites keep
|
|
// compiling; those tests never use a secret: ref (they inject seams or use env/file/literal).
|
|
private readonly ISecretResolver _secretResolver;
|
|
|
|
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
|
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
|
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
|
// GalaxyDiscoverer's translation logic without a real gRPC channel.
|
|
private IGalaxyHierarchySource? _hierarchySource;
|
|
private GalaxyRepositoryClient? _ownedRepositoryClient;
|
|
|
|
// IGalaxyDataReader is the test seam for IReadable. Production ReadAsync calls
|
|
// ReadViaSubscribeOnceAsync (SubscribeBulk + first-event wait + Unsubscribe) through
|
|
// the live GalaxyMxSession built by InitializeAsync; no legacy-host backend exists.
|
|
// When a test injects a reader via the internal ctor, that injected reader is used
|
|
// instead so capability flow can be exercised without a real gRPC round-trip.
|
|
private IGalaxyDataReader? _dataReader;
|
|
|
|
// PR 4.3 — IGalaxyDataWriter is the test seam for IWritable. Production wraps
|
|
// GalaxyMxSession via GatewayGalaxyDataWriter (Write / WriteSecured routing). The
|
|
// per-tag SecurityClassification map is populated during ITagDiscovery and consumed
|
|
// here at write time.
|
|
private IGalaxyDataWriter? _dataWriter;
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SecurityClassification>
|
|
_securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// v3 RawPath keying. The server hands every read/write/subscribe reference as a RawPath; MXAccess
|
|
// dials the Galaxy attributeRef (tag_name.AttributeName). These two tables — built once in the ctor
|
|
// from options.RawTags (each entry's TagConfig carries the camelCase "attributeRef" key) — bridge the
|
|
// two identity domains at the driver boundary:
|
|
// _attributeRefByRawPath : RawPath -> attributeRef (forward dial: read/write/subscribe/ack)
|
|
// _rawTagByAttributeRef : attributeRef -> RawTagEntry (reverse + WriteIdempotent: discovery,
|
|
// OnDataChange, alarm ConditionId)
|
|
// Both fall back to identity on a miss, so the live-browse discovery path (no authored tags) and any
|
|
// synthetic alarm sub-ref (attributeRef.InAlarm, never an authored RawPath) dial straight through
|
|
// unchanged — reproducing pre-v3 behaviour exactly.
|
|
private readonly Dictionary<string, string> _attributeRefByRawPath;
|
|
private readonly Dictionary<string, RawTagEntry> _rawTagByAttributeRef;
|
|
|
|
// PR 4.4 — subscription lifecycle. The pump consumes the gw event stream and fans
|
|
// out OnDataChange events to every registered driver subscription via the registry's
|
|
// reverse map. The subscriber is the test seam — production uses
|
|
// GatewayGalaxySubscriber over a connected GalaxyMxSession.
|
|
private IGalaxySubscriber? _subscriber;
|
|
private readonly SubscriptionRegistry _subscriptions = new();
|
|
private EventPump? _eventPump;
|
|
private readonly Lock _pumpLock = new();
|
|
|
|
/// <summary>
|
|
/// Re-entry gate for the replay-gap re-snapshot (0 = idle, 1 = running). Interlocked
|
|
/// rather than a lock because the setter runs on the event pump's read loop and must
|
|
/// never block it.
|
|
/// </summary>
|
|
private int _resnapshotInFlight;
|
|
|
|
// IAlarmSource implementation. Production-side acks route through
|
|
// GatewayGalaxyAlarmAcknowledger which calls the session-less
|
|
// MxGatewayClient.AcknowledgeAlarmAsync RPC; alarm transitions arrive on the
|
|
// gateway's session-less StreamAlarms feed via GatewayGalaxyAlarmFeed. Tests inject
|
|
// IGalaxyAlarmAcknowledger + IGalaxyAlarmFeed via the internal ctor to exercise the
|
|
// wiring without a running gateway. This driver bridges the feed's OnAlarmTransition
|
|
// onto IAlarmSource.OnAlarmEvent.
|
|
private IGalaxyAlarmAcknowledger? _alarmAcknowledger;
|
|
private IGalaxyAlarmFeed? _alarmFeed;
|
|
private readonly Lock _alarmHandlersLock = new();
|
|
private readonly Lock _alarmFeedLock = new();
|
|
private bool _alarmFeedWired;
|
|
// List preserves insertion order so OnAlarmFeedTransition always picks the
|
|
// earliest-registered handle — a deterministic choice that doesn't vary as
|
|
// handles are added/removed (HashSet.First() is unstable).
|
|
private readonly List<GalaxyAlarmSubscriptionHandle> _alarmSubscriptions = new();
|
|
|
|
// Production runtime owned by InitializeAsync (BuildProductionRuntimeAsync). The
|
|
// driver builds these when it opens a real gw session; tests bypass them by
|
|
// injecting seams via the internal ctor, which leaves both fields null and skips
|
|
// BuildProductionRuntimeAsync entirely.
|
|
private GalaxyMxSession? _ownedMxSession;
|
|
private MxGatewayClient? _ownedMxClient;
|
|
|
|
// PR 4.5 — reconnect supervisor. Reflects in DriverState.Degraded while not Healthy.
|
|
private ReconnectSupervisor? _supervisor;
|
|
|
|
// PR 4.6 — IRediscoverable plumbing.
|
|
private DeployWatcher? _deployWatcher;
|
|
|
|
// PR 4.7 — IHostConnectivityProbe plumbing. The aggregator owns the merged
|
|
// transport+per-platform view; the forwarder is fed from the supervisor on
|
|
// transport state transitions; the probe watcher subscribes ScanState attributes
|
|
// for every discovered platform and pushes value changes to the aggregator.
|
|
private readonly HostStatusAggregator _hostStatuses = new();
|
|
private HostConnectivityForwarder? _transportForwarder;
|
|
private PerPlatformProbeWatcher? _probeWatcher;
|
|
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Server-pushed data-change notification. Fires from the
|
|
/// <see cref="EventPump"/>'s background loop; handlers should be cheap (or queue
|
|
/// onto another thread) to avoid blocking the gw event stream.
|
|
/// </summary>
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
|
|
/// <summary>Fires when the gateway signals a deploy-time change (PR 4.6 DeployWatcher).</summary>
|
|
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
|
|
|
/// <summary>Fires when a host transitions Running ↔ Stopped (PR 4.7 HostStatusAggregator).</summary>
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
/// <inheritdoc />
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
|
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
|
/// <param name="options">The Galaxy driver configuration options.</param>
|
|
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c>.</param>
|
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
|
public GalaxyDriver(
|
|
string driverInstanceId,
|
|
GalaxyDriverOptions options,
|
|
ISecretResolver secretResolver,
|
|
ILogger<GalaxyDriver>? logger = null)
|
|
: this(driverInstanceId, options,
|
|
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
|
alarmAcknowledger: null, alarmFeed: null, logger,
|
|
secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test-visible ctor — inject custom seams so <see cref="DiscoverAsync"/>,
|
|
/// <see cref="ReadAsync"/>, <see cref="WriteAsync"/>, and
|
|
/// <see cref="SubscribeAsync"/> can be exercised against canned data without
|
|
/// building real gRPC channels.
|
|
/// </summary>
|
|
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
|
/// <param name="options">The Galaxy driver configuration options.</param>
|
|
/// <param name="hierarchySource">Optional custom hierarchy source for testing.</param>
|
|
/// <param name="dataReader">Optional custom data reader for testing.</param>
|
|
/// <param name="dataWriter">Optional custom data writer for testing.</param>
|
|
/// <param name="subscriber">Optional custom subscriber for testing.</param>
|
|
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
|
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
|
/// <param name="secretResolver">
|
|
/// Optional secret resolver for the <c>secret:</c> API-key arm. Defaults to a
|
|
/// null-object resolver (returns null for every name) so seam-injecting tests that
|
|
/// don't exercise a <c>secret:</c> ref need not supply one.
|
|
/// </param>
|
|
internal GalaxyDriver(
|
|
string driverInstanceId,
|
|
GalaxyDriverOptions options,
|
|
IGalaxyHierarchySource? hierarchySource,
|
|
IGalaxyDataReader? dataReader = null,
|
|
IGalaxyDataWriter? dataWriter = null,
|
|
IGalaxySubscriber? subscriber = null,
|
|
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
|
IGalaxyAlarmFeed? alarmFeed = null,
|
|
ILogger<GalaxyDriver>? logger = null,
|
|
ISecretResolver? secretResolver = null)
|
|
{
|
|
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
|
? driverInstanceId
|
|
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
|
_hierarchySource = hierarchySource;
|
|
_dataReader = dataReader;
|
|
_dataWriter = dataWriter;
|
|
_subscriber = subscriber;
|
|
_alarmAcknowledger = alarmAcknowledger;
|
|
_alarmFeed = alarmFeed;
|
|
|
|
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
|
|
// are authored (live-browse path), leaving every translation an identity no-op.
|
|
(_attributeRefByRawPath, _rawTagByAttributeRef) = BuildRawTagTables(_options.RawTags, _logger, _skippedTags);
|
|
|
|
// Forward the aggregator's transitions through IHostConnectivityProbe.
|
|
_hostStatuses.OnHostStatusChanged += (_, args) => OnHostStatusChanged?.Invoke(this, args);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the RawPath → attributeRef and attributeRef → <see cref="RawTagEntry"/> tables from the
|
|
/// authored raw tags. Each entry's <see cref="RawTagEntry.TagConfig"/> carries the Galaxy dialled
|
|
/// address under the camelCase key <c>attributeRef</c>. Entries whose blob has no usable
|
|
/// <c>attributeRef</c> are skipped (they can't be dialled). On a duplicate attributeRef (two
|
|
/// authored RawPaths pointing at the same Galaxy attribute) the first entry wins the reverse map —
|
|
/// the value fan-out / alarm ConditionId then surfaces under that first RawPath; the forward map
|
|
/// still dials both. Case-insensitive to match the driver's reference handling.
|
|
/// </summary>
|
|
private static (Dictionary<string, string> Forward, Dictionary<string, RawTagEntry> Reverse)
|
|
BuildRawTagTables(IReadOnlyList<RawTagEntry> rawTags, ILogger logger, SkippedTagLedger skipped)
|
|
{
|
|
var forward = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
var reverse = new Dictionary<string, RawTagEntry>(StringComparer.OrdinalIgnoreCase);
|
|
skipped.BeginRebuild(rawTags.Count);
|
|
foreach (var entry in rawTags)
|
|
{
|
|
if (string.IsNullOrEmpty(entry.RawPath))
|
|
{
|
|
// Counted, though there is no path to name it by: an artifact carrying such an entry
|
|
// is malformed, and reporting "N authored, N-1 bound" with nothing accounting for the
|
|
// difference is exactly the silence #541 is about.
|
|
skipped.Skip("", "entry has no RawPath");
|
|
continue;
|
|
}
|
|
var attributeRef = TryReadAttributeRef(entry.TagConfig);
|
|
if (string.IsNullOrEmpty(attributeRef))
|
|
{
|
|
skipped.Skip(entry.RawPath, "TagConfig has no 'attributeRef' — cannot dial MXAccess");
|
|
logger.LogWarning(
|
|
"GalaxyDriver raw tag {RawPath} has no 'attributeRef' in its TagConfig — skipped (cannot dial MXAccess).",
|
|
entry.RawPath);
|
|
continue;
|
|
}
|
|
forward[entry.RawPath] = attributeRef;
|
|
if (!reverse.TryAdd(attributeRef, entry)) // first-wins on a shared attributeRef
|
|
{
|
|
// The loser is still dialled (forward map) but its value fan-out / alarm ConditionId surface
|
|
// under the winner's RawPath, so its own node never updates -- say so (archreview 06/S-13).
|
|
var winner = reverse[attributeRef].RawPath;
|
|
skipped.Skip(entry.RawPath,
|
|
$"duplicate attributeRef '{attributeRef}' — value fan-out surfaces under '{winner}'");
|
|
logger.LogWarning(
|
|
"GalaxyDriver raw tag {RawPath} shares attributeRef {AttributeRef} with {Winner} — its values and alarms surface under the winner only.",
|
|
entry.RawPath, attributeRef, winner);
|
|
}
|
|
}
|
|
skipped.Commit();
|
|
return (forward, reverse);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read the Galaxy dialled address from a tag's <c>TagConfig</c> JSON — the camelCase
|
|
/// <c>attributeRef</c> key (v3 rename of the pre-v3 PascalCase <c>FullName</c>). Case-insensitive
|
|
/// on the property name for authoring tolerance; returns null on an absent/blank/non-string value
|
|
/// or unparseable blob (the entry is then skipped, never dialled).
|
|
/// </summary>
|
|
private static string? TryReadAttributeRef(string tagConfigJson)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tagConfigJson)) return null;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(tagConfigJson);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
|
{
|
|
if (!prop.NameEquals("attributeRef")
|
|
&& !string.Equals(prop.Name, "attributeRef", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
if (prop.Value.ValueKind != JsonValueKind.String) return null;
|
|
var v = prop.Value.GetString();
|
|
return string.IsNullOrWhiteSpace(v) ? null : v;
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Malformed blob — treat as un-dialable; BuildRawTagTables logs the skip.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>RawPath → Galaxy attributeRef for MXAccess dialling; identity on a miss.</summary>
|
|
private string ToAttributeRef(string rawPath) =>
|
|
_attributeRefByRawPath.GetValueOrDefault(rawPath, rawPath);
|
|
|
|
/// <summary>Galaxy attributeRef → RawPath (the server-facing identity); identity on a miss.</summary>
|
|
private string ToRawPath(string attributeRef) =>
|
|
_rawTagByAttributeRef.TryGetValue(attributeRef, out var e) ? e.RawPath : attributeRef;
|
|
|
|
/// <inheritdoc />
|
|
public string DriverInstanceId => _driverInstanceId;
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => GalaxyDriverFactoryExtensions.DriverTypeName;
|
|
|
|
/// <summary>Test-visible options snapshot.</summary>
|
|
internal GalaxyDriverOptions Options => _options;
|
|
|
|
/// <summary>
|
|
/// Test-visible entry into <see cref="ReplayAsync"/>. The supervisor's
|
|
/// <see cref="ReconnectSupervisor.ReportTransportFailure"/> drives this on a
|
|
/// background task in production; tests prefer to invoke it directly so the
|
|
/// <see cref="GalaxyReconnectOptions.ReplayOnSessionLost"/> branch can be
|
|
/// asserted deterministically.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token for the replay operation.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
internal Task InvokeReplayForTestAsync(CancellationToken cancellationToken) =>
|
|
ReplayAsync(cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
|
|
// Tests inject seams via the internal ctor; production InitializeAsync builds
|
|
// the gateway client + session + per-capability runtime components from
|
|
// GalaxyDriverOptions. When seams are pre-injected we leave them alone (the
|
|
// test exercises the wired surface without a real gw round-trip).
|
|
if (_subscriber is null && _dataWriter is null && _hierarchySource is null)
|
|
{
|
|
await BuildProductionRuntimeAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug(
|
|
"GalaxyDriver {InstanceId} initializing with pre-injected seams — production runtime build skipped",
|
|
_driverInstanceId);
|
|
}
|
|
|
|
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
|
|
_logger.LogInformation(
|
|
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
|
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the production gw client + session + per-capability runtime components
|
|
/// from <c>_options</c>. Sets up the reconnect supervisor's reopen / replay
|
|
/// callbacks so a transport drop replays every active subscription on the
|
|
/// restored session.
|
|
/// </summary>
|
|
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
|
{
|
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
|
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
|
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
|
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
|
|
|
// PR 6.1 — wrap the gw-facing seams in tracing decorators so every Subscribe /
|
|
// Unsubscribe / Write / StreamEvents call emits a span on the
|
|
// "ZB.MOM.WW.OtOpcUa.Driver.Galaxy" ActivitySource. The host process's tracing
|
|
// listener (OTLP exporter, dotnet-trace, etc.) consumes these without the driver
|
|
// taking a dependency on the OpenTelemetry packages.
|
|
_subscriber = new TracedGalaxySubscriber(
|
|
new GatewayGalaxySubscriber(_ownedMxSession, _logger), _options.MxAccess.ClientName);
|
|
_dataWriter = new TracedGalaxyDataWriter(
|
|
// The writer always AddItems its own handle — a subscription's handle is plain-advised,
|
|
// and MXAccess both refuses a no-login write through it (SECURITY_ERROR 1008) and refuses
|
|
// upgrading its advise mode (E_INVALIDARG), so the former borrow-from-registry
|
|
// optimization could never commit a write. The advise limit is per handle, not per
|
|
// attribute: the writer's own supervisory-advised handle coexists with the subscription's.
|
|
new GatewayGalaxyDataWriter(
|
|
_ownedMxSession, _options.MxAccess.WriteUserId, _logger),
|
|
_options.MxAccess.ClientName);
|
|
|
|
_supervisor = new ReconnectSupervisor(
|
|
reopen: ReopenAsync,
|
|
replay: ReplayAsync,
|
|
options: new ReconnectOptions(
|
|
InitialBackoffOverride: TimeSpan.FromMilliseconds(_options.Reconnect.InitialBackoffMs),
|
|
MaxBackoffOverride: TimeSpan.FromMilliseconds(_options.Reconnect.MaxBackoffMs)),
|
|
logger: _logger);
|
|
|
|
_transportForwarder = new HostConnectivityForwarder(_options.MxAccess.ClientName, _hostStatuses, _logger);
|
|
_transportForwarder.SetTransport(HostState.Running); // initial state — we just connected
|
|
|
|
_supervisor.StateChanged += OnSupervisorStateChanged;
|
|
|
|
_probeWatcher = new PerPlatformProbeWatcher(
|
|
_subscriber, _hostStatuses, _logger,
|
|
bufferedUpdateIntervalMs: _options.MxAccess.PublishingIntervalMs);
|
|
|
|
// Wire the alarm acknowledger + feed to the live gateway client. Both are
|
|
// session-less — the gateway serves alarms from an always-on central monitor —
|
|
// so they hang off the owned MxGatewayClient, not the worker session.
|
|
_alarmAcknowledger ??= new GatewayGalaxyAlarmAcknowledger(_ownedMxClient, _logger);
|
|
_alarmFeed ??= new GatewayGalaxyAlarmFeed(
|
|
_ownedMxClient.StreamAlarmsAsync, _logger, _options.MxAccess.ClientName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reopen callback for <see cref="ReconnectSupervisor"/>: recreate the gw session. After a
|
|
/// gateway restart the existing session handle is faulted / stale but non-null, so a plain
|
|
/// <c>ConnectAsync</c> would no-op and the supervisor would loop forever replaying against a
|
|
/// dead session. <see cref="GalaxyMxSession.RecreateAsync"/> disposes the stale session +
|
|
/// owned client and rebuilds, so a never-connected session still opens fresh.
|
|
/// </summary>
|
|
private async Task ReopenAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_ownedMxSession is null) return;
|
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
|
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
|
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
|
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
|
_dataWriter?.InvalidateHandleCaches();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replay callback. Walks every active subscription, re-issues SubscribeBulk for
|
|
/// its tag list, and <see cref="SubscriptionRegistry.Rebind">rebinds</see> the
|
|
/// registry with the fresh item handles the gateway returned — the pre-reconnect
|
|
/// handles are dead once the session reopened. The faulted <see cref="EventPump"/>
|
|
/// is recreated first so the replayed subscriptions have a live StreamEvents
|
|
/// consumer; without that restart the replayed tags are subscribed on the gw but
|
|
/// never reach <c>OnDataChange</c>. PR 6.x can swap this for
|
|
/// the gw's batched <c>ReplaySubscriptionsCommand</c> once it ships.
|
|
/// </summary>
|
|
private async Task ReplayAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_subscriber is null) return;
|
|
var entries = _subscriptions.SnapshotEntries();
|
|
if (entries.Count == 0) return;
|
|
|
|
// Honor ReplayOnSessionLost. When operators opt out (false) we skip the
|
|
// per-tag SubscribeBulk fan-out — they're delegating to the gateway's
|
|
// session-level ReplaySubscriptions or accept post-reconnect tag loss. We
|
|
// still restart the EventPump so a future Subscribe call lands on a live
|
|
// consumer.
|
|
if (!_options.Reconnect.ReplayOnSessionLost)
|
|
{
|
|
RestartEventPumpForReplay();
|
|
_logger.LogInformation(
|
|
"GalaxyDriver {InstanceId} reconnect replay skipped — ReplayOnSessionLost=false ({SubCount} subscriptions tracked)",
|
|
_driverInstanceId, entries.Count);
|
|
return;
|
|
}
|
|
|
|
// The stream-fault that triggered this recovery left the old pump's RunAsync loop
|
|
// exited and its channel completed; EventPump.Start() is a no-op on a non-null but
|
|
// completed loop. Recreate the pump so the replayed subscriptions have a consumer.
|
|
RestartEventPumpForReplay();
|
|
|
|
var tagCount = await ResubscribeAllAsync(entries, cancellationToken).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation(
|
|
"GalaxyDriver {InstanceId} replay completed — {SubCount} subscriptions, {TagCount} tags re-subscribed",
|
|
_driverInstanceId, entries.Count, tagCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-issues <c>SubscribeBulk</c> for every tracked subscription and rebinds the registry
|
|
/// with the item handles the gateway returned. Shared by the reconnect replay path and
|
|
/// the replay-gap re-snapshot — the difference between them is the event pump, which
|
|
/// replay must recreate (its loop died with the stream) and a gap must NOT touch (the
|
|
/// stream carrying the sentinel is still healthy and still delivering).
|
|
/// </summary>
|
|
/// <returns>The number of tag references re-subscribed.</returns>
|
|
private async Task<int> ResubscribeAllAsync(
|
|
IReadOnlyList<(long SubscriptionId, IReadOnlyList<TagBinding> Bindings)> entries,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tagCount = 0;
|
|
foreach (var (subscriptionId, oldBindings) in entries)
|
|
{
|
|
var refs = oldBindings
|
|
.Select(b => b.FullReference)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
if (refs.Length == 0) continue;
|
|
|
|
var results = await _subscriber!
|
|
.SubscribeBulkAsync(refs, _options.MxAccess.PublishingIntervalMs, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var byAddress = BuildResultIndex(results);
|
|
var newBindings = new List<TagBinding>(refs.Length);
|
|
foreach (var fullRef in refs)
|
|
{
|
|
var itemHandle = byAddress.TryGetValue(fullRef, out var match) && match.WasSuccessful
|
|
? match.ItemHandle
|
|
: 0;
|
|
newBindings.Add(new TagBinding(fullRef, itemHandle));
|
|
}
|
|
|
|
// Rebind so the EventPump fan-out reverse map points at the post-reconnect
|
|
// handles; otherwise events on the new handles miss every subscription.
|
|
_subscriptions.Rebind(subscriptionId, newBindings);
|
|
tagCount += refs.Length;
|
|
}
|
|
|
|
return tagCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Index a SubscribeBulk result list by tag address (OrdinalIgnoreCase) so the
|
|
/// subscribe / replay correlation loops are O(1) per reference rather than a
|
|
/// linear scan. Last-write-wins on a duplicate address (the gw shouldn't emit one).
|
|
/// </summary>
|
|
private static Dictionary<string, SubscribeResult> BuildResultIndex(IReadOnlyList<SubscribeResult> results)
|
|
{
|
|
var index = new Dictionary<string, SubscribeResult>(results.Count, StringComparer.OrdinalIgnoreCase);
|
|
foreach (var result in results)
|
|
{
|
|
if (!string.IsNullOrEmpty(result.TagAddress)) index[result.TagAddress] = result;
|
|
}
|
|
return index;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dispose the faulted <see cref="EventPump"/> and create a fresh one bound to the
|
|
/// same subscriber / registry, started immediately. Invoked from the reconnect
|
|
/// replay path. No-op when no pump was ever started (no active subscriptions).
|
|
/// </summary>
|
|
private void RestartEventPumpForReplay()
|
|
{
|
|
EventPump? old;
|
|
lock (_pumpLock)
|
|
{
|
|
old = _eventPump;
|
|
if (old is null) return; // pump never started — nothing to restart
|
|
_eventPump = null;
|
|
}
|
|
|
|
// Detach + dispose the faulted pump outside the lock so a slow shutdown doesn't
|
|
// block a concurrent EnsureEventPumpStarted; the old loop already exited on fault.
|
|
old.OnDataChange -= OnPumpDataChange;
|
|
try { old.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"GalaxyDriver {InstanceId} faulted EventPump dispose failed during replay — continuing.",
|
|
_driverInstanceId);
|
|
}
|
|
|
|
// EnsureEventPumpStarted creates + starts a fresh pump under the lock. Skip the
|
|
// recreate if the driver is being disposed — Dispose already tore the pump down
|
|
// and a fresh one here would leak past the driver's lifetime.
|
|
if (_disposed) return;
|
|
EnsureEventPumpStarted();
|
|
}
|
|
|
|
private void OnSupervisorStateChanged(object? sender, StateTransition transition)
|
|
{
|
|
// Reflect supervisor state in DriverHealth + transport forwarder.
|
|
_health = transition.Next switch
|
|
{
|
|
ReconnectSupervisor.State.Healthy => new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null),
|
|
_ => new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, transition.Cause),
|
|
};
|
|
if (_transportForwarder is not null)
|
|
{
|
|
var hostState = transition.Next == ReconnectSupervisor.State.Healthy
|
|
? HostState.Running
|
|
: HostState.Stopped;
|
|
_transportForwarder.SetTransport(hostState);
|
|
}
|
|
}
|
|
|
|
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
|
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
|
{
|
|
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
|
// async and you can't await inside an initializer. Pass the logger so the
|
|
// literal-arm cleartext fallback surfaces a startup warning rather than
|
|
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
|
|
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
|
|
// implementation; the secret: arm resolves through the shared ISecretResolver.
|
|
var apiKey = await GalaxySecretRef
|
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return new MxGatewayClientOptions
|
|
{
|
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
|
ApiKey = apiKey,
|
|
UseTls = gw.UseTls,
|
|
CaCertificatePath = gw.CaCertificatePath,
|
|
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
|
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
|
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
|
};
|
|
}
|
|
|
|
private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!_options.Repository.WatchDeployEvents) return;
|
|
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
|
|
|
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
|
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
|
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
|
|
// runs it reuses this client rather than overwriting the field and leaking the
|
|
// first instance — the client-options build is now async (secret: arm).
|
|
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
|
|
|
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
|
_deployWatcher = new DeployWatcher(source, _logger);
|
|
_deployWatcher.OnRediscoveryNeeded += (_, args) => OnRediscoveryNeeded?.Invoke(this, args);
|
|
|
|
// StartAsync schedules the background loop and returns Task.CompletedTask immediately.
|
|
// It throws InvalidOperationException synchronously if called twice (programming error).
|
|
// Don't discard the return value — observe any synchronous throw.
|
|
var startTask = _deployWatcher.StartAsync(CancellationToken.None);
|
|
// The task is already completed (StartAsync is synchronous); surface any synchronous fault.
|
|
if (startTask.IsFaulted) startTask.GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
|
{
|
|
// In-place config reapply. The driver does not currently support hot-swapping
|
|
// GalaxyDriverOptions at runtime — changing the gateway endpoint, MxAccess
|
|
// client name, or reconnect policy requires tearing down the gw session,
|
|
// supervisor, event pump, and address space. The host stack handles that via
|
|
// DriverInstance restart, so this method only accepts an equivalent config (no
|
|
// meaningful change) and refreshes health; a non-equivalent reapply throws
|
|
// NotSupportedException so the caller knows the change wasn't applied.
|
|
//
|
|
// Materialise the incoming config and compare against the live options. We
|
|
// refuse any change that would require a session teardown rather than
|
|
// pretending to apply it.
|
|
GalaxyDriverOptions incoming;
|
|
try
|
|
{
|
|
// Reuse the factory's parse pipeline so any missing-required-field
|
|
// error surfaces with the same diagnostic text as InitializeAsync.
|
|
var transient = GalaxyDriverFactoryExtensions.CreateInstance(_driverInstanceId, driverConfigJson);
|
|
incoming = transient.Options;
|
|
// The transient instance never started a runtime — disposing is cheap.
|
|
transient.Dispose();
|
|
}
|
|
catch (Exception ex) when (ex is not NotSupportedException and not ObjectDisposedException)
|
|
{
|
|
throw new NotSupportedException(
|
|
$"GalaxyDriver.ReinitializeAsync could not parse the incoming DriverConfig JSON for '{_driverInstanceId}': {ex.Message}",
|
|
ex);
|
|
}
|
|
|
|
if (!OptionsAreEquivalent(_options, incoming))
|
|
{
|
|
throw new NotSupportedException(
|
|
"GalaxyDriver.ReinitializeAsync does not support hot-swapping driver options at runtime " +
|
|
"(gateway endpoint, MxAccess client name, reconnect policy, etc.). Restart the DriverInstance " +
|
|
"through the host stack to apply a config change.");
|
|
}
|
|
}
|
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compare two <see cref="GalaxyDriverOptions"/> for runtime equivalence — every
|
|
/// field that drives gw session shape or reconnect behaviour must match. Compared
|
|
/// field-wise (the nested records supply value-equality) and <b>excluding
|
|
/// <see cref="GalaxyDriverOptions.RawTags"/></b>: the authored tag set is address-space
|
|
/// state re-applied through rediscovery, not a session-teardown trigger, and a record's
|
|
/// synthesized equality would compare the two <c>RawTags</c> lists by reference anyway.
|
|
/// </summary>
|
|
private static bool OptionsAreEquivalent(GalaxyDriverOptions a, GalaxyDriverOptions b) =>
|
|
a.Gateway == b.Gateway
|
|
&& a.MxAccess == b.MxAccess
|
|
&& a.Repository == b.Repository
|
|
&& a.Reconnect == b.Reconnect
|
|
&& a.ProbeTimeoutSeconds == b.ProbeTimeoutSeconds;
|
|
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_disposed) return Task.CompletedTask;
|
|
_logger.LogInformation("GalaxyDriver {InstanceId} shutting down", _driverInstanceId);
|
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DriverHealth GetHealth()
|
|
{
|
|
// Reconnect supervisor wins when degraded — the cached _health reflects the last
|
|
// successful operation, but ongoing recovery should surface as Degraded.
|
|
if (_supervisor?.IsDegraded == true)
|
|
{
|
|
return new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, _supervisor.LastError);
|
|
}
|
|
return _health;
|
|
}
|
|
|
|
// ===== IHostConnectivityProbe (PR 4.7 wire-up) =====
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => _hostStatuses.Snapshot();
|
|
|
|
|
|
// ===== ITagDiscovery (PR 4.1) =====
|
|
|
|
/// <inheritdoc />
|
|
// Run-once: DiscoverAsync fetches the full Galaxy hierarchy inline and streams the complete
|
|
// node set within a single awaited call — there is no FOCAS-style background cache that fills
|
|
// in after connect. Galaxy is a heavy network driver, so the bounded post-connect retry loop
|
|
// is deliberately avoided; re-discovery on Galaxy redeploy is handled separately via
|
|
// IRediscoverable + the deploy-event watcher.
|
|
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
|
|
|
/// <inheritdoc />
|
|
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
// Wrap the builder with a SecurityCapturingBuilder so each discovered attribute's
|
|
// SecurityClassification is recorded in _securityByFullRef for write-time routing.
|
|
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
|
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
|
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
|
var source = _hierarchySource ??=
|
|
await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false);
|
|
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
|
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
|
// keyed by RawPath, matching what WriteAsync resolves against.
|
|
var discoverer = new GalaxyDiscoverer(
|
|
source, attributeRef => _rawTagByAttributeRef.GetValueOrDefault(attributeRef));
|
|
await discoverer.DiscoverAsync(capturingBuilder, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (_probeWatcher is not null)
|
|
{
|
|
var hierarchy = await source.GetHierarchyAsync(cancellationToken).ConfigureAwait(false);
|
|
var platforms = hierarchy
|
|
.Where(o => o.TemplateChain.Any(t =>
|
|
string.Equals(t, "$WinPlatform", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(t, "$AppEngine", StringComparison.OrdinalIgnoreCase)))
|
|
.Select(o => o.TagName)
|
|
.Where(name => !string.IsNullOrEmpty(name));
|
|
await _probeWatcher.SyncPlatformsAsync(platforms, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private SecurityClassification ResolveSecurity(string fullReference) =>
|
|
_securityByFullRef.TryGetValue(fullReference, out var sec) ? sec : SecurityClassification.FreeAccess;
|
|
|
|
// ===== IReadable =====
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
|
if (fullReferences.Count == 0) return Task.FromResult<IReadOnlyList<DataValueSnapshot>>([]);
|
|
|
|
// v3: the server addresses by RawPath; translate to the Galaxy attributeRef MXAccess dials.
|
|
// Read returns positionally, so no reverse translation is needed on the result. An unmapped
|
|
// RawPath dials as-is (identity) — the gateway rejects an unknown address → Bad, same as today.
|
|
var dialledRefs = new string[fullReferences.Count];
|
|
for (var i = 0; i < fullReferences.Count; i++) dialledRefs[i] = ToAttributeRef(fullReferences[i]);
|
|
|
|
if (_dataReader is not null)
|
|
{
|
|
// Test-only path — tests inject a canned reader via the internal ctor.
|
|
return _dataReader.ReadAsync(dialledRefs, cancellationToken);
|
|
}
|
|
|
|
if (_subscriber is null)
|
|
{
|
|
throw new NotSupportedException(
|
|
"GalaxyDriver.ReadAsync requires a connected GalaxyMxSession (production runtime not built). " +
|
|
"Either inject a test seam via the internal ctor or call InitializeAsync against a real gateway.");
|
|
}
|
|
|
|
return ReadViaSubscribeOnceAsync(dialledRefs, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Production read path. MxAccess has no one-shot Read RPC — every value comes
|
|
/// through the event stream. We synthesise a Read by:
|
|
/// <list type="number">
|
|
/// <item>Subscribing the requested tags through the existing
|
|
/// <see cref="SubscriptionRegistry"/> + <see cref="EventPump"/>.</item>
|
|
/// <item>Waiting for the first <c>OnDataChange</c> per item handle (the gateway
|
|
/// pushes the current value as the initial event after a SubscribeBulk).</item>
|
|
/// <item>Unsubscribing.</item>
|
|
/// </list>
|
|
/// Tags the gw rejects at SubscribeBulk time, or that never publish before the
|
|
/// caller's cancellation token fires, return a Bad-status snapshot in input order
|
|
/// so the caller still sees one snapshot per requested reference.
|
|
/// </summary>
|
|
/// <summary>Upper bound on one subscribe-once read; internal so tests can shrink it.</summary>
|
|
internal TimeSpan ReadViaSubscribeDeadline { get; set; } = TimeSpan.FromSeconds(30);
|
|
|
|
private async Task<IReadOnlyList<DataValueSnapshot>> ReadViaSubscribeOnceAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken callerToken)
|
|
{
|
|
// Internal deadline (archreview 06/S-3): a tag that never publishes must not hang a caller that
|
|
// passed CancellationToken.None. Still-pending slots fill with BadTimeout exactly as for a caller cancel.
|
|
using var deadlineCts = CancellationTokenSource.CreateLinkedTokenSource(callerToken);
|
|
deadlineCts.CancelAfter(ReadViaSubscribeDeadline);
|
|
var cancellationToken = deadlineCts.Token;
|
|
|
|
var pump = EnsureEventPumpStarted();
|
|
var subscriptionId = _subscriptions.NextSubscriptionId();
|
|
|
|
// Pre-allocate one TaskCompletionSource per full-reference so the OnDataChange
|
|
// handler can complete them out-of-order as events arrive. Wired BEFORE the
|
|
// SubscribeBulk call so we don't race with the first event the gw pushes.
|
|
var pendingByRef = new Dictionary<string, TaskCompletionSource<DataValueSnapshot>>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
foreach (var fullRef in fullReferences.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
pendingByRef[fullRef] = new TaskCompletionSource<DataValueSnapshot>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
}
|
|
|
|
EventHandler<DataChangeEventArgs> handler = (_, args) =>
|
|
{
|
|
// Filter to OUR subscription — the pump's OnDataChange fans out across all
|
|
// subscriptions on the driver, and we don't want a parallel ISubscribable
|
|
// caller's events to leak into our read.
|
|
if (args.SubscriptionHandle is GalaxySubscriptionHandle gsh
|
|
&& gsh.SubscriptionId == subscriptionId
|
|
&& pendingByRef.TryGetValue(args.FullReference, out var tcs))
|
|
{
|
|
tcs.TrySetResult(args.Snapshot);
|
|
}
|
|
};
|
|
pump.OnDataChange += handler;
|
|
|
|
var bufferedIntervalMs = _options.MxAccess.PublishingIntervalMs;
|
|
IReadOnlyList<SubscribeResult> results;
|
|
try
|
|
{
|
|
results = await _subscriber!
|
|
.SubscribeBulkAsync(fullReferences, bufferedIntervalMs, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
pump.OnDataChange -= handler;
|
|
throw;
|
|
}
|
|
|
|
// Register bindings so the pump knows to dispatch events for these handles.
|
|
// Index the SubscribeBulk results once and correlate to references in O(1)
|
|
// instead of FirstOrDefault per element (O(n²) over the batch). On the
|
|
// 50k-tag soak path this turns a 2.5G-comparison loop into a single
|
|
// Dictionary build + linear scan.
|
|
var resultIndex = BuildResultIndex(results);
|
|
var bindings = new List<TagBinding>(fullReferences.Count);
|
|
for (var i = 0; i < fullReferences.Count; i++)
|
|
{
|
|
var fullRef = fullReferences[i];
|
|
var itemHandle = resultIndex.TryGetValue(fullRef, out var match) && match is { WasSuccessful: true }
|
|
? match.ItemHandle
|
|
: 0;
|
|
bindings.Add(new TagBinding(fullRef, itemHandle));
|
|
|
|
// Tags the gw rejected up front — complete with Bad status now so the
|
|
// wait below doesn't time out on them.
|
|
if (itemHandle <= 0
|
|
&& pendingByRef.TryGetValue(fullRef, out var rejectedTcs))
|
|
{
|
|
rejectedTcs.TrySetResult(new DataValueSnapshot(
|
|
Value: null,
|
|
StatusCode: StatusCodeMap.Bad,
|
|
SourceTimestampUtc: null,
|
|
ServerTimestampUtc: DateTime.UtcNow));
|
|
}
|
|
}
|
|
_subscriptions.Register(subscriptionId, bindings);
|
|
|
|
try
|
|
{
|
|
// Wait for every pending TCS to complete or the caller's CT to fire. When the
|
|
// CT fires before all values arrive, fill the still-pending entries with a
|
|
// Bad-status snapshot rather than throwing — Read semantics let callers see
|
|
// partial results.
|
|
using var registration = cancellationToken.Register(() =>
|
|
{
|
|
foreach (var tcs in pendingByRef.Values)
|
|
{
|
|
tcs.TrySetResult(new DataValueSnapshot(
|
|
Value: null,
|
|
StatusCode: StatusCodeMap.BadTimeout,
|
|
SourceTimestampUtc: null,
|
|
ServerTimestampUtc: DateTime.UtcNow));
|
|
}
|
|
});
|
|
|
|
var snapshots = new DataValueSnapshot[fullReferences.Count];
|
|
for (var i = 0; i < fullReferences.Count; i++)
|
|
{
|
|
snapshots[i] = await pendingByRef[fullReferences[i]].Task.ConfigureAwait(false);
|
|
}
|
|
return snapshots;
|
|
}
|
|
finally
|
|
{
|
|
pump.OnDataChange -= handler;
|
|
// Drop the bindings + unsubscribe the live handles. UnsubscribeBulkAsync's
|
|
// failure isn't fatal — the registry is already cleared, so any straggling
|
|
// event from the gw would be a no-op fan-out.
|
|
_subscriptions.Remove(subscriptionId);
|
|
var liveHandles = bindings.Where(b => b.ItemHandle > 0).Select(b => b.ItemHandle).ToArray();
|
|
if (liveHandles.Length > 0)
|
|
{
|
|
try
|
|
{
|
|
await _subscriber!.UnsubscribeBulkAsync(liveHandles, CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"GalaxyDriver.ReadViaSubscribeOnceAsync UnsubscribeBulk failed for {Count} handle(s) — registry already cleared.",
|
|
liveHandles.Length);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== IWritable (PR 4.3) =====
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(writes);
|
|
if (writes.Count == 0) return Task.FromResult<IReadOnlyList<WriteResult>>([]);
|
|
|
|
if (_dataWriter is null)
|
|
{
|
|
// Mirror the IReadable fallback: production write path runs on top of
|
|
// GatewayGalaxyDataWriter, which is wired by InitializeAsync when the gRPC session
|
|
// connects. This guard fires only if the driver is called before initialization completes.
|
|
throw new NotSupportedException(
|
|
"GalaxyDriver.WriteAsync requires GatewayGalaxyDataWriter wired against a connected " +
|
|
"GalaxyMxSession. Call InitializeAsync and wait for a successful connection before " +
|
|
"issuing writes.");
|
|
}
|
|
|
|
// v3: WriteRequest.FullReference is a RawPath. Translate to the Galaxy attributeRef the writer
|
|
// dials to MXAccess. Security was captured at discovery keyed by RawPath (= the node's FullName),
|
|
// so the resolver the writer invokes with the DIALLED attributeRef must reverse it back to RawPath
|
|
// before the lookup. Both legs are identity on a miss, so an un-authored tag behaves as before.
|
|
var dialledWrites = new List<WriteRequest>(writes.Count);
|
|
foreach (var w in writes) dialledWrites.Add(w with { FullReference = ToAttributeRef(w.FullReference) });
|
|
|
|
return _dataWriter.WriteAsync(
|
|
dialledWrites, attributeRef => ResolveSecurity(ToRawPath(attributeRef)), cancellationToken);
|
|
}
|
|
|
|
// ===== ISubscribable (PR 4.4) =====
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
|
|
|
if (_subscriber is null)
|
|
{
|
|
throw new NotSupportedException(
|
|
"GalaxyDriver.SubscribeAsync requires a connected GalaxyMxSession + GatewayGalaxySubscriber. " +
|
|
"Call InitializeAsync and wait for a successful connection before subscribing.");
|
|
}
|
|
|
|
var pump = EnsureEventPumpStarted();
|
|
var subscriptionId = _subscriptions.NextSubscriptionId();
|
|
|
|
if (fullReferences.Count == 0)
|
|
{
|
|
// Empty subscriptions register but never bind anything — keeps Unsubscribe
|
|
// symmetric for callers that conditionally add tags later.
|
|
_subscriptions.Register(subscriptionId, []);
|
|
return new GalaxySubscriptionHandle(subscriptionId);
|
|
}
|
|
|
|
// v3: the server subscribes by RawPath; the registry + gateway operate on the Galaxy attributeRef.
|
|
// Translate here (identity on a miss — a synthetic alarm sub-ref or live-browse tag dials straight
|
|
// through). The value fan-out reverse-translates back to RawPath in OnPumpDataChange.
|
|
var dialledRefs = new string[fullReferences.Count];
|
|
for (var i = 0; i < fullReferences.Count; i++) dialledRefs[i] = ToAttributeRef(fullReferences[i]);
|
|
|
|
// PR 6.3 — when the caller doesn't set a publishing interval (TimeSpan.Zero or
|
|
// negative), fall back to the configured MxAccess.PublishingIntervalMs. The
|
|
// server's UA subscription publishingInterval drives this in production; tests
|
|
// and infrastructure callers (probe watcher, deploy watcher) hit the fallback.
|
|
var requested = (int)Math.Max(0, publishingInterval.TotalMilliseconds);
|
|
var bufferedIntervalMs = requested > 0 ? requested : _options.MxAccess.PublishingIntervalMs;
|
|
var results = await _subscriber
|
|
.SubscribeBulkAsync(dialledRefs, bufferedIntervalMs, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// Build the binding list in input order. Failed entries (gw rejected the tag) are
|
|
// recorded with a non-positive ItemHandle so the caller can detect partial failure
|
|
// by inspecting the returned handle's diagnostic context — full per-tag error
|
|
// surface lands in PR 5.3's parity tests.
|
|
// Index results once, correlate in O(1) per reference rather than
|
|
// FirstOrDefault inside the loop (O(n²) on the 50k-tag path).
|
|
// Bindings key on the dialled attributeRef — that's what the gw returns as the tag address and
|
|
// what the EventPump fans out on. OnPumpDataChange maps it back to RawPath for public consumers.
|
|
var resultIndex = BuildResultIndex(results);
|
|
var bindings = new List<TagBinding>(dialledRefs.Length);
|
|
for (var i = 0; i < dialledRefs.Length; i++)
|
|
{
|
|
var fullRef = dialledRefs[i];
|
|
var hasMatch = resultIndex.TryGetValue(fullRef, out var match);
|
|
var itemHandle = hasMatch && match is { WasSuccessful: true } ? match.ItemHandle : 0;
|
|
bindings.Add(new TagBinding(fullRef, itemHandle));
|
|
if (match is null || !match.WasSuccessful)
|
|
{
|
|
_logger.LogWarning(
|
|
"Galaxy subscribe for {FullRef} failed: {Error}",
|
|
fullRef, match?.ErrorMessage ?? "<no result returned>");
|
|
}
|
|
}
|
|
|
|
_subscriptions.Register(subscriptionId, bindings);
|
|
_ = pump; // keep the pump alive for the subscription's lifetime
|
|
return new GalaxySubscriptionHandle(subscriptionId);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(handle);
|
|
if (handle is not GalaxySubscriptionHandle gsh)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Subscription handle was not issued by this driver (expected GalaxySubscriptionHandle, got {handle.GetType().Name}).",
|
|
nameof(handle));
|
|
}
|
|
|
|
var bindings = _subscriptions.Remove(gsh.SubscriptionId);
|
|
if (bindings is null) return; // already removed or never registered
|
|
|
|
var liveItemHandles = bindings.Where(b => b.ItemHandle > 0).Select(b => b.ItemHandle).ToArray();
|
|
if (liveItemHandles.Length == 0 || _subscriber is null) return;
|
|
|
|
try
|
|
{
|
|
await _subscriber.UnsubscribeBulkAsync(liveItemHandles, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Galaxy UnsubscribeBulk failed for subscription {SubscriptionId} — registry already cleared on driver side.",
|
|
gsh.SubscriptionId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lazily start the <see cref="EventPump"/> on the first subscribe. The pump is
|
|
/// shared across every subscription on this driver — fan-out happens through the
|
|
/// <see cref="SubscriptionRegistry"/> reverse map, not by spinning a pump per
|
|
/// subscription.
|
|
/// </summary>
|
|
private EventPump EnsureEventPumpStarted()
|
|
{
|
|
lock (_pumpLock)
|
|
{
|
|
if (_eventPump is not null) return _eventPump;
|
|
_eventPump = new EventPump(
|
|
_subscriber!, _subscriptions, _logger,
|
|
channelCapacity: _options.MxAccess.EventPumpChannelCapacity,
|
|
clientName: _options.MxAccess.ClientName,
|
|
onStreamFault: OnEventPumpStreamFault,
|
|
onReplayGap: OnEventPumpReplayGap);
|
|
_eventPump.OnDataChange += OnPumpDataChange;
|
|
_eventPump.Start();
|
|
return _eventPump;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replay-gap callback for the <see cref="EventPump"/>. The gateway told us it evicted
|
|
/// events we can never receive, so every cached value may be stale by an unknown amount.
|
|
/// The contract's remedy is to re-snapshot, which for this driver means re-issuing
|
|
/// SubscribeBulk: MXAccess delivers an initial value per item on subscribe, so the
|
|
/// re-subscribe is the re-snapshot.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Three things this must not do, in order of how badly they would bite:
|
|
/// <list type="bullet">
|
|
/// <item>It must <b>not</b> restart the event pump. A gap is not a fault — the stream
|
|
/// that delivered the sentinel is healthy — and this runs ON that stream's read loop,
|
|
/// so disposing the pump from here would deadlock on its own loop.</item>
|
|
/// <item>It must <b>not</b> block. The read loop is stopped for as long as this runs,
|
|
/// and re-subscribing thousands of tags is a round-trip per subscription, so the work
|
|
/// goes to the thread pool and the loop returns to draining the stream immediately.</item>
|
|
/// <item>It must <b>not</b> stack. Re-entry is gated, so a burst of sentinels (or one
|
|
/// arriving while a re-snapshot is still running) collapses to a single pass rather
|
|
/// than queueing a subscribe storm per sentinel.</item>
|
|
/// </list>
|
|
/// <see cref="GalaxyReconnectOptions.ReplayOnSessionLost"/> is honoured here even though the
|
|
/// trigger is not a session loss: an operator who turned driver-side re-subscription off
|
|
/// is delegating recovery to the gateway, and quietly re-subscribing behind that choice
|
|
/// on a different trigger would be the same surprise they opted out of.
|
|
/// </remarks>
|
|
private void OnEventPumpReplayGap(ReplayGap gap)
|
|
{
|
|
if (_subscriber is null) return;
|
|
|
|
if (!_options.Reconnect.ReplayOnSessionLost)
|
|
{
|
|
_logger.LogWarning(
|
|
"GalaxyDriver {InstanceId} replay-gap re-snapshot skipped — ReplayOnSessionLost=false. " +
|
|
"Values missed between worker_sequence {RequestedAfter} and {OldestAvailable} stay stale " +
|
|
"until the gateway or an operator re-primes them.",
|
|
_driverInstanceId, gap.RequestedAfterSequence, gap.OldestAvailableSequence);
|
|
return;
|
|
}
|
|
|
|
if (Interlocked.Exchange(ref _resnapshotInFlight, 1) == 1)
|
|
{
|
|
_logger.LogInformation(
|
|
"GalaxyDriver {InstanceId} replay-gap re-snapshot already in flight — coalescing.",
|
|
_driverInstanceId);
|
|
return;
|
|
}
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
var entries = _subscriptions.SnapshotEntries();
|
|
if (entries.Count == 0) return;
|
|
|
|
var tagCount = await ResubscribeAllAsync(entries, CancellationToken.None).ConfigureAwait(false);
|
|
_logger.LogInformation(
|
|
"GalaxyDriver {InstanceId} replay-gap re-snapshot completed — {SubCount} subscriptions, {TagCount} tags re-subscribed",
|
|
_driverInstanceId, entries.Count, tagCount);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Never surface into the pump's read loop; the stream is still healthy and
|
|
// must keep draining. A failed re-snapshot leaves stale values, which the
|
|
// next reconnect or subscribe will correct.
|
|
_logger.LogWarning(ex,
|
|
"GalaxyDriver {InstanceId} replay-gap re-snapshot failed — cached values may stay stale.",
|
|
_driverInstanceId);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _resnapshotInFlight, 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stream-fault callback for the <see cref="EventPump"/>. The gw StreamEvents
|
|
/// stream faulted (transient gateway drop, network blip, gw restart). Forward
|
|
/// the cause to the <see cref="ReconnectSupervisor"/> so it drives reopen →
|
|
/// replay; without this hand-off a transient transport drop permanently kills
|
|
/// the event stream and <c>GetHealth()</c> keeps reporting Healthy.
|
|
/// </summary>
|
|
private void OnEventPumpStreamFault(Exception cause)
|
|
{
|
|
var supervisor = _supervisor;
|
|
if (supervisor is null)
|
|
{
|
|
// No production runtime (skeleton / injected-seam path) — nothing to drive.
|
|
_logger.LogWarning(cause,
|
|
"GalaxyDriver {InstanceId} event stream faulted but no reconnect supervisor is wired.",
|
|
_driverInstanceId);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
supervisor.ReportTransportFailure(cause);
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// Driver is being disposed — the stream fault is just shutdown noise.
|
|
}
|
|
}
|
|
|
|
// ===== IAlarmSource =====
|
|
|
|
/// <summary>
|
|
/// Start the gateway alarm feed (idempotent) and wire its transitions onto this
|
|
/// driver's <see cref="OnAlarmEvent"/> bridge. The feed is session-less — it does
|
|
/// not depend on a data subscription or the <see cref="EventPump"/>.
|
|
/// </summary>
|
|
private void EnsureAlarmFeedStarted()
|
|
{
|
|
lock (_alarmFeedLock)
|
|
{
|
|
if (_alarmFeed is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"GalaxyDriver alarm feed is not wired. InitializeAsync must run (or a feed " +
|
|
"seam must be injected via the internal ctor) before subscribing to alarms.");
|
|
}
|
|
if (_alarmFeedWired) return;
|
|
_alarmFeed.OnAlarmTransition += OnAlarmFeedTransition;
|
|
_alarmFeed.Start();
|
|
_alarmFeedWired = true;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
|
|
|
// The driver doesn't multiplex alarm subscriptions per source-node-id today —
|
|
// every active handle receives every transition off the gateway's session-less
|
|
// StreamAlarms feed, and the server filters by source node before raising Part 9
|
|
// conditions. The subscription handle is a sentinel the server uses for
|
|
// symmetric Unsubscribe. Same shape AbCip uses.
|
|
EnsureAlarmFeedStarted();
|
|
var handle = new GalaxyAlarmSubscriptionHandle(Guid.NewGuid().ToString("N"));
|
|
lock (_alarmHandlersLock)
|
|
{
|
|
// Collapse to a SINGLE live handle. The session-less StreamAlarms feed survives an in-place
|
|
// reconnect, so DriverInstanceActor re-subscribing on every Connected re-entry (after dropping
|
|
// its own cached handle) would otherwise grow this list by one untracked handle per reconnect —
|
|
// a slow leak (the gate only checks Count > 0 and OnAlarmFeedTransition only reads [0], so the
|
|
// extras were pure dead weight). There is exactly one consumer per driver instance — enforced
|
|
// structurally by the factory-per-actor lifecycle (one GalaxyDriver is created for one owning
|
|
// DriverInstanceActor, the only caller of SubscribeAlarmsAsync), not by mere convention — so
|
|
// replacing the set with the latest handle is semantically faithful and bounds it at 1.
|
|
_alarmSubscriptions.Clear();
|
|
_alarmSubscriptions.Add(handle);
|
|
}
|
|
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(handle);
|
|
if (handle is not GalaxyAlarmSubscriptionHandle gash)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Subscription handle was not issued by this driver (expected GalaxyAlarmSubscriptionHandle, got {handle.GetType().Name}).",
|
|
nameof(handle));
|
|
}
|
|
lock (_alarmHandlersLock)
|
|
{
|
|
_alarmSubscriptions.Remove(gash);
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(acknowledgements);
|
|
if (acknowledgements.Count == 0) return;
|
|
|
|
if (_alarmAcknowledger is null)
|
|
{
|
|
throw new NotSupportedException(
|
|
"GalaxyDriver.AcknowledgeAsync requires GatewayGalaxyAlarmAcknowledger wired against a connected " +
|
|
"GalaxyMxSession. InitializeAsync must complete successfully before alarm acknowledgements can flow.");
|
|
}
|
|
|
|
// Acks are issued one-by-one — the gateway RPC accepts a single alarm
|
|
// reference per call. AlarmConditionState's per-condition Acknowledge in the
|
|
// server-side ACL layer is the natural rate-limit, so issuing in series here
|
|
// keeps the operator-comment ordering deterministic without bursting the
|
|
// worker's STA queue.
|
|
foreach (var ack in acknowledgements)
|
|
{
|
|
// ConditionId carries the alarm's RawPath (v3) for the Galaxy driver — SourceNodeId is the
|
|
// OPC UA browse path, which the gateway can't address. Translate the RawPath back to the
|
|
// Galaxy attributeRef the gateway dials (identity on a miss). The server-side condition state
|
|
// pairs them through AlarmConditionService.
|
|
var conditionReference = !string.IsNullOrEmpty(ack.ConditionId)
|
|
? ack.ConditionId
|
|
: ack.SourceNodeId;
|
|
var alarmFullReference = ToAttributeRef(conditionReference);
|
|
await _alarmAcknowledger.AcknowledgeAsync(
|
|
alarmFullReference,
|
|
ack.Comment ?? string.Empty,
|
|
// Honor the authenticated principal carried on the request when present;
|
|
// fall back to empty (server-side ACL fills this from the OPC UA session).
|
|
operatorUser: ack.OperatorUser ?? string.Empty,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Receives <see cref="GalaxyAlarmTransition"/> events from the gateway alarm
|
|
/// feed and reshapes them into <see cref="AlarmEventArgs"/> for OPC UA-side
|
|
/// consumers. Fires <see cref="OnAlarmEvent"/> only when at least one alarm
|
|
/// subscription is active so a server that hasn't called
|
|
/// <see cref="SubscribeAlarmsAsync"/> yet doesn't surface untracked transitions.
|
|
/// </summary>
|
|
private void OnAlarmFeedTransition(object? sender, GalaxyAlarmTransition transition)
|
|
{
|
|
GalaxyAlarmSubscriptionHandle? handle;
|
|
int subCount;
|
|
lock (_alarmHandlersLock)
|
|
{
|
|
// Pick the earliest-registered handle as the event owner. The server routes
|
|
// by SourceNodeId (not by handle), so every active subscriber sees the same
|
|
// transition regardless of which handle is attached here. Using the first
|
|
// insertion-order entry is deterministic and stable as long as at least one
|
|
// subscription remains — HashSet.First() was unstable across mutations.
|
|
// _alarmSubscriptions is a List, so [0] is always the earliest-registered
|
|
// handle.
|
|
subCount = _alarmSubscriptions.Count;
|
|
handle = subCount > 0 ? _alarmSubscriptions[0] : null;
|
|
}
|
|
// Native-alarm delivery observability (was a black box): one line per feed transition with the
|
|
// resolved alarm ref, the live subscription count, and whether it fans out to OnAlarmEvent. Debug
|
|
// so a flapping galaxy doesn't flood prod, but available on demand to answer "is the feed
|
|
// delivering / is the subscription un-gating it" — the exact question this driver couldn't answer
|
|
// before the SubscribeAlarmsAsync wiring fix.
|
|
_logger.LogDebug(
|
|
"GalaxyDriver alarm feed: {Kind} {Ref} (subs={Subs}, fanout={Fanout})",
|
|
transition.TransitionKind, transition.AlarmFullReference, subCount, handle is not null);
|
|
if (handle is null) return;
|
|
|
|
// v3: the ConditionId the driver reports must be the tag's RawPath — the same key the value
|
|
// fan-out uses (OnPumpDataChange) and the identity the discoverer stamped as SourceName — so the
|
|
// server-side condition state correlates the alarm to its node and a subsequent ack round-trips
|
|
// back through AcknowledgeAsync. The feed reports the Galaxy attributeRef; reverse-map it (identity
|
|
// on a miss).
|
|
var conditionRawPath = ToRawPath(transition.AlarmFullReference);
|
|
var args = new AlarmEventArgs(
|
|
SubscriptionHandle: handle,
|
|
SourceNodeId: transition.SourceObjectReference,
|
|
ConditionId: conditionRawPath,
|
|
AlarmType: transition.AlarmTypeName,
|
|
Message: transition.Description,
|
|
Severity: transition.SeverityBucket,
|
|
SourceTimestampUtc: transition.TransitionTimestampUtc,
|
|
OperatorComment: string.IsNullOrEmpty(transition.OperatorComment) ? null : transition.OperatorComment,
|
|
OriginalRaiseTimestampUtc: transition.OriginalRaiseTimestampUtc,
|
|
AlarmCategory: string.IsNullOrEmpty(transition.Category) ? null : transition.Category,
|
|
// Fully-qualify the Core.Abstractions enum: this file also imports
|
|
// MxGateway.Contracts.Proto, which defines a same-named AlarmTransitionKind.
|
|
Kind: transition.TransitionKind switch
|
|
{
|
|
GalaxyAlarmTransitionKind.Raise => Core.Abstractions.AlarmTransitionKind.Raise,
|
|
GalaxyAlarmTransitionKind.Acknowledge => Core.Abstractions.AlarmTransitionKind.Acknowledge,
|
|
GalaxyAlarmTransitionKind.Clear => Core.Abstractions.AlarmTransitionKind.Clear,
|
|
GalaxyAlarmTransitionKind.Retrigger => Core.Abstractions.AlarmTransitionKind.Retrigger,
|
|
_ => Core.Abstractions.AlarmTransitionKind.Unspecified,
|
|
});
|
|
try
|
|
{
|
|
OnAlarmEvent?.Invoke(this, args);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"GalaxyDriver OnAlarmEvent handler threw for {AlarmRef} — continuing.",
|
|
transition.AlarmFullReference);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Forwards every fan-out event to the public <see cref="OnDataChange"/> for
|
|
/// ISubscribable consumers, AND routes ScanState changes to the per-platform
|
|
/// probe watcher (PR 4.7) so platform health entries update without the watcher
|
|
/// consuming the event stream itself.
|
|
/// </summary>
|
|
private void OnPumpDataChange(object? sender, DataChangeEventArgs args)
|
|
{
|
|
// v3: the pump fans out keyed by the dialled attributeRef; the server correlates values to nodes
|
|
// by RawPath (the node's FullName). Reverse-map before surfacing to public OnDataChange consumers.
|
|
// Identity on a miss, so a live-browse tag surfaces under its attributeRef unchanged.
|
|
var publicArgs = args;
|
|
var rawPath = ToRawPath(args.FullReference);
|
|
if (!string.Equals(rawPath, args.FullReference, StringComparison.Ordinal))
|
|
{
|
|
publicArgs = args with { FullReference = rawPath };
|
|
}
|
|
OnDataChange?.Invoke(this, publicArgs);
|
|
|
|
// Probe watching stays in the attributeRef domain — ScanState attributes are discovered platform
|
|
// attributes, never authored RawPaths, so use the untranslated reference here.
|
|
if (_probeWatcher is not null
|
|
&& args.FullReference.EndsWith(PerPlatformProbeWatcher.ProbeSuffix, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// The probe decoder takes a raw quality byte. Recover it via the canonical
|
|
// StatusCodeMap.ToQualityCategoryByte helper so the mapping lives in one
|
|
// place next to its inverse (FromQualityByte) and cannot desync silently.
|
|
var qualityByte = StatusCodeMap.ToQualityCategoryByte(args.Snapshot.StatusCode);
|
|
_probeWatcher.OnProbeValueChanged(args.FullReference, args.Snapshot.Value, qualityByte);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lazily builds the default <see cref="IGalaxyHierarchySource"/> from
|
|
/// <c>_options.Gateway</c>. Owned <see cref="GalaxyRepositoryClient"/> is disposed in
|
|
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
|
/// internal ctor.
|
|
/// </summary>
|
|
private async Task<IGalaxyHierarchySource> BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken)
|
|
{
|
|
// Reuse a client that StartDeployWatcherAsync may have already created (??=) rather
|
|
// than always overwriting the field and leaking the first instance. Both paths
|
|
// produce equivalent clients from the same options. The client-options build is
|
|
// async now (secret: arm resolves through the shared ISecretResolver).
|
|
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
|
return new TracedGalaxyHierarchySource(
|
|
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronous disposal. Prefer <c>await using</c> over <c>using</c> — the
|
|
/// async path does not block the caller while awaiting EventPump / session /
|
|
/// client shutdown (the sync path blocks on <c>GetAwaiter().GetResult()</c>
|
|
/// for every async sub-component, risking a deadlock under thread-pool
|
|
/// starvation).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
|
|
// Synchronous sub-components first — none of these block.
|
|
try { _deployWatcher?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "DeployWatcher dispose failed"); }
|
|
try { _supervisor?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "ReconnectSupervisor dispose failed"); }
|
|
try { _probeWatcher?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "ProbeWatcher dispose failed"); }
|
|
try { _transportForwarder?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "Transport forwarder dispose failed"); }
|
|
|
|
// Async sub-components: await each so we don't block a thread-pool thread
|
|
// on a slow shutdown (e.g. EventPump draining its channel, gRPC stream closing).
|
|
EventPump? pump;
|
|
lock (_pumpLock) { pump = _eventPump; _eventPump = null; }
|
|
if (pump is not null)
|
|
{
|
|
try { await pump.DisposeAsync().ConfigureAwait(false); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "EventPump dispose failed"); }
|
|
}
|
|
|
|
IGalaxyAlarmFeed? alarmFeed;
|
|
lock (_alarmFeedLock) { alarmFeed = _alarmFeed; _alarmFeed = null; }
|
|
if (alarmFeed is not null)
|
|
{
|
|
try { await alarmFeed.DisposeAsync().ConfigureAwait(false); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "Alarm feed dispose failed"); }
|
|
}
|
|
|
|
if (_ownedMxSession is not null)
|
|
{
|
|
try { await _ownedMxSession.DisposeAsync().ConfigureAwait(false); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "MxSession dispose failed"); }
|
|
_ownedMxSession = null;
|
|
}
|
|
|
|
if (_ownedMxClient is not null)
|
|
{
|
|
try { await _ownedMxClient.DisposeAsync().ConfigureAwait(false); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "MxClient dispose failed"); }
|
|
_ownedMxClient = null;
|
|
}
|
|
|
|
if (_ownedRepositoryClient is not null)
|
|
{
|
|
try { await _ownedRepositoryClient.DisposeAsync().ConfigureAwait(false); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "RepositoryClient dispose failed"); }
|
|
_ownedRepositoryClient = null;
|
|
}
|
|
|
|
_hierarchySource = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Synchronous disposal. Prefer <see cref="DisposeAsync"/> in async contexts —
|
|
/// this path must block on every async sub-component shutdown. Provided for
|
|
/// compatibility with <c>using</c> statements that cannot <c>await</c>.
|
|
/// </summary>
|
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
|
|
|
/// <summary>
|
|
/// Address-space builder wrapper that records each variable's
|
|
/// <see cref="DriverAttributeInfo.SecurityClass"/> into the supplied dictionary
|
|
/// before delegating to the inner builder. Used by <see cref="DiscoverAsync"/>
|
|
/// to capture per-tag classifications for the IWritable routing decision —
|
|
/// PR 4.3 needs the data, but the discoverer itself doesn't (and shouldn't)
|
|
/// know about the driver's internal state.
|
|
/// </summary>
|
|
private sealed class SecurityCapturingBuilder(
|
|
IAddressSpaceBuilder inner,
|
|
System.Collections.Concurrent.ConcurrentDictionary<string, SecurityClassification> map)
|
|
: IAddressSpaceBuilder
|
|
{
|
|
/// <inheritdoc />
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
|
=> new SecurityCapturingBuilder(inner.Folder(browseName, displayName), map);
|
|
|
|
/// <inheritdoc />
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
{
|
|
map[attributeInfo.FullName] = attributeInfo.SecurityClass;
|
|
return inner.Variable(browseName, displayName, attributeInfo);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
|
=> inner.AddProperty(browseName, dataType, value);
|
|
}
|
|
}
|