Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsClient.cs
T
Joseph Doherty 953bd27533 fix(runtime,adminui): per-tag write ordering, pessimistic init bound, unique generations, PostStop budget (fix round 1)
- Writes: a second write to a tag already inside the driver waits in the
  queue behind it (_tagsInFlight) while other tags still run under the
  cap of 4; queued writes are stamped and failed BadTimeout at dequeue
  once older than the 8 s host Ask budget (Props queuedWriteBudget)
  instead of being written late.
- Initialise attempts are bounded pessimistically (initializeTimeout,
  default 30 s = 2x Tier C) on top of the invoker's token-only timeout:
  an overrun is logged as abandoned, reported InitializeFailed so the
  retry loop continues, and its late result is dropped by generation.
  Generations come from a process-wide Interlocked counter so a prior
  incarnation's late result cannot collide (F6).
- PostStop shares one shutdownTimeout budget across shutdown + dispose.
- TelemetryDialSupervisor cancels the establishment deadline on
  StreamStopped; FleetDiagnosticsClient builds the ActorSelection inside
  the try (a malformed node degrades to "unavailable"); the diagnostics
  page labels the column "Last health publish".
- Tests: same-tag serialisation with a distinct-tag positive control;
  stale queued write BadTimeout; token-ignoring hung init -> later
  attempt connects; stopwatch assertion demoted to a ceiling.

Refs #731

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:36:30 -04:00

68 lines
3.2 KiB
C#

using Akka.Actor;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>
/// <see cref="IFleetDiagnosticsClient"/> backed by an Akka <see cref="ActorSelection"/> against
/// the target node's <c>DriverHostActor</c> at <c>akka.tcp://otopcua@&lt;host&gt;:&lt;port&gt;/user/driver-host</c>.
/// Sends <see cref="GetDiagnostics"/>; expects a <see cref="NodeDiagnosticsSnapshot"/> reply.
///
/// On timeout or any other failure (peer down, GetDiagnostics handler missing) returns an
/// empty snapshot so the UI degrades to "no data" instead of throwing. The failure is logged at
/// Warning, since the snapshot shape cannot carry it: an empty snapshot reads the same as a node with
/// no drivers. Consumed by the <c>/fleet/diagnostics</c> page.
/// </summary>
public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
{
private static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(3);
private readonly ActorSystem _system;
private readonly string _systemName;
private readonly ILogger<FleetDiagnosticsClient> _log;
/// <summary>Initializes a new FleetDiagnosticsClient with the given actor system and cluster options.</summary>
/// <param name="system">The Akka actor system.</param>
/// <param name="options">Cluster configuration options.</param>
/// <param name="log">Logger for the degraded-to-empty path; defaults to a no-op logger.</param>
public FleetDiagnosticsClient(
ActorSystem system, IOptions<AkkaClusterOptions> options, ILogger<FleetDiagnosticsClient>? log = null)
{
_system = system;
_systemName = options.Value.SystemName;
_log = log ?? NullLogger<FleetDiagnosticsClient>.Instance;
}
/// <inheritdoc />
public async Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct)
{
try
{
// Inside the try: a malformed node value makes ActorSelection throw, and that must degrade to
// "unavailable" like a dead node does, not surface through the page's error boundary.
var selection = _system.ActorSelection($"akka.tcp://{_systemName}@{nodeId.Value}/user/driver-host");
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(AskTimeout);
return await selection.Ask<NodeDiagnosticsSnapshot>(
new GetDiagnostics(CorrelationId.NewId()), AskTimeout, linked.Token);
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
_log.LogWarning(ex,
"Fleet diagnostics for node {NodeId} unavailable (no answer within {Timeout}s); returning an empty snapshot",
nodeId.Value, AskTimeout.TotalSeconds);
return new NodeDiagnosticsSnapshot(
nodeId,
CurrentRevision: null,
Drivers: Array.Empty<DriverInstanceDiagnostics>(),
AsOfUtc: DateTime.UtcNow);
}
}
}