Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/CentralCommunicationActor.cs
T
Joseph Doherty 63714e430a fix(controlplane): evict retired mesh channels and refresh PSKs off the request path
- MeshGrpcCommandDialer.RetainOnly (IMeshCommandDialer, default no-op): CentralCommunicationActor
  hands the dialer the current endpoint set on every contact refresh, so a renamed host or moved
  port releases its cached GrpcChannel and idle HTTP/2 connection instead of living for the
  process (R03:1.3).
- IMeshPskProvider.RefreshAsync (default = the old InvalidateAll): MeshPskProvider pre-resolves
  every known cluster's key concurrently, bounded like the sync path, and swaps an immutable
  snapshot in atomically; a failed read keeps that cluster's previous key. The actor calls it from
  ContactsLoaded (after SetKnownClusters, off-actor via PipeTo) instead of InvalidateAll, so the
  first ack per cluster per ContactRefreshSeconds no longer blocks a gRPC thread on a 3 s-bounded
  store read or gets denied past the bound (R03:3.4).

Refs #733

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

905 lines
46 KiB
C#

using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
// Grpc.Core (imported for RpcException on the dial path) declares a Status of its own. This file's
// bare Status is Akka's — the Status.Failure a faulted piped task arrives as; the gRPC one is only
// ever reached through an RpcException instance.
using Status = Akka.Actor.Status;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
/// <summary>
/// Central-side end of the central↔node command boundary (per-cluster mesh Phase 2). Routes
/// every central→node command out — over DistributedPubSub (pair-local) or gRPC (cross-mesh),
/// per <see cref="MeshTransportOptions.Mode"/> — and forwards inbound <see cref="ApplyAck"/>s to
/// the deploy coordinator singleton.
/// </summary>
/// <remarks>
/// <para>
/// <b>Registered per node, NOT as a cluster singleton.</b> A driver node posts its ack to
/// whichever central node answers first, so a live comm actor must exist on both. A singleton
/// would be reachable only through the one node hosting it, and the other central node's ack
/// ingress would have nothing to forward to.
/// </para>
/// <para>
/// <b>The gRPC branch fans node by node over the contact load's target set.</b> That load
/// selects exactly the enabled, non-maintenance <c>ClusterNode</c> rows carrying a
/// <c>GrpcPort</c>, and the fan dials every one of them, one call per node. It is deliberately
/// NOT narrower: the node side has no <c>ClusterId</c> or node filter at all — scoping happens
/// later, inside the artifact — so a node left out here silently keeps its old configuration
/// while the deployment seals green.
/// </para>
/// <para>
/// <b>Fire-and-forget, on both transports.</b> The gRPC branch pipes per-node results back to
/// itself for logging ONLY: a failure names the node and the status and is dropped. No retry,
/// no queue, no ordering guarantee across nodes. That is not an omission — it is the
/// DistributedPubSub semantic preserved exactly, and the backstops are unchanged (the deploy
/// coordinator's apply deadline, and a node's boot-time replay).
/// </para>
/// </remarks>
public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
{
private const string RefreshTimerKey = "mesh-contact-refresh";
private const string BootstrapRetryTimerKey = "mesh-contact-bootstrap-retry";
// While the known-cluster gate is unpublished (PSK provider unresolved, or the first
// ClusterNode load failed) a single-shot RefreshContacts is re-armed every
// DefaultBootstrapRetryInterval, at most MaxBootstrapRetries times (~30 s), stopping at the
// first publish. Constants, not config. Steady state costs nothing extra.
private static readonly TimeSpan DefaultBootstrapRetryInterval = TimeSpan.FromSeconds(2);
private const int MaxBootstrapRetries = 15;
private readonly TimeSpan _bootstrapRetryInterval;
private int _bootstrapRetries;
private int _refreshAttempts;
private bool _gatePublished;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly MeshTransportOptions _options;
private readonly Func<IActorRef?> _coordinator;
// Resolved in PreStart, never at construction. See the Props doc: the DI graph behind these two
// reaches actor-registry entries that do not exist yet while the Akka builder's starters are
// still running, and touching it there is what stopped every central node from booting.
private readonly Func<IMeshCommandDialer?>? _dialerFactory;
private readonly Func<IMeshPskProvider?>? _pskProviderFactory;
private IMeshCommandDialer? _dialer;
private IMeshPskProvider? _pskProvider;
// Memoize-on-first-use, NOT PreStart-only: PreStart runs on a dispatcher thread concurrently
// with the builder's remaining starters, so its resolve can lose a millisecond-wide race
// against AuditWriterActorKey's registration (the fourth starter) and Resolve() would swallow that
// into a silent null — the known-cluster gate unpublished, fail-open. These properties
// retry on first use, but the first RefreshContacts is sent from PreStart (TimeSpan.Zero) and
// can ALSO land inside the starter window (a fast DB load finishes in ms), so first-use is not
// enough: while the gate is unpublished a bounded fast retry (ScheduleBootstrapRetry) re-arms
// RefreshContacts every couple of seconds until it publishes. Actor-thread
// only, so ??= needs no synchronisation; both are container singletons, so a repeat resolve
// is a dictionary hit.
private IMeshPskProvider? PskProvider =>
_pskProvider ??= Resolve(_pskProviderFactory, nameof(IMeshPskProvider), firstUse: true);
private IMeshCommandDialer? Dialer =>
_dialer ??= Resolve(_dialerFactory, nameof(IMeshCommandDialer), firstUse: true);
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly bool _useGrpc;
// Node ids already warned about for a null GrpcPort. A persistently portless row would otherwise
// produce one warning per refresh forever; same warn-once shape as TelemetryNodeSource. Actor
// thread only.
private readonly HashSet<string> _warnedPortlessNodes = new(StringComparer.Ordinal);
// Set once PublishKnownClusters has reported a genuinely-absent PSK provider (a factory WAS
// supplied and still resolves null after the first-use retry). PublishKnownClusters runs on
// the contact-refresh timer for the actor's whole life, so an unguarded Error there would
// repeat every ContactRefreshSeconds forever. Same warn-once shape as _warnedPortlessNodes;
// actor thread only.
private bool _pskProviderAbsenceReported;
// The gRPC dispatch set, replaced wholesale on every contact load. Actor thread only; the fan
// captures the list reference, so a refresh mid-fan cannot mutate a dispatch already under way.
private IReadOnlyList<MeshNodeTarget> _targets = [];
private CancellationTokenSource _lifecycle = new();
/// <summary>Gets the timer scheduler driving the periodic contact refresh.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Creates the props for this actor.</summary>
/// <param name="dbFactory">Factory for the config database holding <c>ClusterNode</c> rows.</param>
/// <param name="options">The bound mesh-transport options.</param>
/// <param name="coordinator">
/// Resolves the deploy-coordinator singleton proxy. A <see cref="Func{TResult}"/> rather than
/// an <see cref="IActorRef"/> so this actor never depends on registration order at wiring
/// time — it resolves on first use and caches.
/// </param>
/// <param name="dialer">
/// Resolves the thing that places one command call on one node, under
/// <see cref="MeshTransportOptions.ModeGrpc"/>. Optional so the Dps transport needs nothing
/// new wired; a <see langword="null"/> dialer under <c>Grpc</c> is a mis-wiring and is
/// reported loudly rather than silently dropping every command.
/// </param>
/// <param name="pskProvider">
/// Resolves central's per-cluster key lookup, told on every contact refresh which clusters
/// exist (the known-cluster gate) and to drop its cache (so a rotated key takes effect within
/// one refresh). Optional for the same reason as <paramref name="dialer"/>.
/// </param>
/// <param name="bootstrapRetryInterval">
/// Interval of the bounded fast retry run while the known-cluster gate is unpublished
/// (default 2 s, at most 15 attempts).
/// </param>
/// <returns>The props.</returns>
/// <remarks>
/// <para>
/// <b>Both dependencies arrive as <see cref="Func{TResult}"/>, and that is not a style
/// choice.</b> Passing instances meant the caller resolved them while the Akka builder's
/// starters were still running, and the DI graph behind them reaches back into the actor
/// registry: dialer → <c>IMeshPskProvider</c> → <c>ISecretResolver</c> → <c>AddZbSecrets</c>
/// → <c>IAuditWriter</c> → <c>ActorAuditWriter</c>, whose constructor calls
/// <c>registry.Get&lt;AuditWriterActorKey&gt;()</c> — a singleton registered dozens of
/// lines LATER in the very same builder. Every central node failed to boot, in every
/// transport mode, on a <c>MissingActorRegistryEntryException</c>. Deferring to
/// <see cref="PreStart"/> moves the first resolve to a point where the host is fully
/// built, and matches the <see cref="Func{TResult}"/> the coordinator proxy already uses
/// one argument earlier for the same class of reason.
/// </para>
/// <para>
/// This is the third instance of the same failure mode in this family — eager resolution
/// inside an <c>AddAkka</c> configurator, previously <c>scadaproj#1</c> (a DI cycle that
/// deadlocked) and mesh Phase 6's <c>IClusterRoleInfo</c> boot <c>StackOverflow</c>. The
/// rule it keeps re-teaching: inside an Akka builder, resolve nothing you can defer.
/// </para>
/// </remarks>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
MeshTransportOptions options,
Func<IActorRef?> coordinator,
Func<IMeshCommandDialer?>? dialer = null,
Func<IMeshPskProvider?>? pskProvider = null,
TimeSpan? bootstrapRetryInterval = null) =>
Akka.Actor.Props.Create(() =>
new CentralCommunicationActor(
dbFactory, options, coordinator, dialer, pskProvider, bootstrapRetryInterval));
/// <summary>Initializes a new instance of the <see cref="CentralCommunicationActor"/> class.</summary>
/// <param name="dbFactory">Factory for the config database.</param>
/// <param name="options">The bound mesh-transport options.</param>
/// <param name="coordinator">Lazily resolves the deploy-coordinator singleton proxy.</param>
/// <param name="dialer">Resolves the gRPC command dialer; invoked in <see cref="PreStart"/>.</param>
/// <param name="pskProvider">Resolves the PSK provider; invoked in <see cref="PreStart"/>.</param>
/// <param name="bootstrapRetryInterval">Fast-retry interval while the gate is unpublished; default 2 s.</param>
public CentralCommunicationActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
MeshTransportOptions options,
Func<IActorRef?> coordinator,
Func<IMeshCommandDialer?>? dialer = null,
Func<IMeshPskProvider?>? pskProvider = null,
TimeSpan? bootstrapRetryInterval = null)
{
_bootstrapRetryInterval = bootstrapRetryInterval ?? DefaultBootstrapRetryInterval;
_dbFactory = dbFactory;
_options = options;
_coordinator = coordinator;
_dialerFactory = dialer;
_pskProviderFactory = pskProvider;
_useGrpc = string.Equals(
options.Mode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase);
Receive<MeshCommand>(HandleMeshCommand);
Receive<ApplyAck>(HandleApplyAck);
Receive<RefreshContacts>(_ =>
{
_refreshAttempts++;
LoadContactsFromDb();
});
Receive<ContactsLoaded>(HandleContactsLoaded);
Receive<PskRefreshed>(msg => _log.Debug("Mesh PSK refresh completed for {Count} cluster(s)", msg.ClusterCount));
Receive<PskRefreshFailed>(msg => _log.Warning(msg.Error,
"Mesh PSK refresh failed; previously resolved keys stay in service until the next refresh"));
Receive<CommandDispatched>(HandleCommandDispatched);
// A faulted LoadContactsFromDb task is piped here as a Status.Failure. Without this handler
// the failure is an unhandled message (debug level only) and the refresh fails silently —
// an operator cannot distinguish "no nodes configured" from "the database is down", and the
// contact set silently freezes at whatever it last held.
//
// The freeze extends to the known-cluster gate, deliberately: a failed load must neither
// widen the gate nor narrow it, and the previous set is the last thing central actually knew.
// The second line exists because that freeze is INVISIBLE otherwise, and it is fail-OPEN
// before the first successful load — a central that has never reached its ConfigDb refuses
// no cluster at all. Until the first publish a bounded fast retry (above) heals it.
Receive<Status.Failure>(f =>
{
// Gate still unpublished (boot with ConfigDb briefly down): retry fast, bounded.
if (!_gatePublished)
ScheduleBootstrapRetry();
_log.Warning(
f.Cause,
"Failed to load ClusterNode contact points; the mesh contact set was NOT refreshed "
+ "and may be stale or empty. Commands may be dropped until the next refresh");
_log.Warning(
"The known-cluster gate was NOT refreshed either: it still holds the set from the "
+ "last successful load, and if none has succeeded yet (boot, or central SQL Server "
+ "unreachable since start) it is INACTIVE — every cluster id reaches the secret "
+ "store, which is the pre-authentication cost the gate exists to remove");
});
Receive<SubscribeAck>(_ => { /* DPS subscribe confirmation */ });
}
/// <inheritdoc />
protected override void PreStart()
{
// First point at which the host is fully built — see the Props remark. Re-runs on every
// actor restart, which re-reads the container rather than caching a stale reference; both
// dependencies are container singletons, so a restart gets the same instances back.
//
// Quiet (firstUse: false — see Resolve): PreStart runs on a dispatcher thread concurrently
// with the builder's remaining starters and loses a millisecond-wide race against
// AuditWriterActorKey's registration (the fourth starter) on essentially every admin boot (live
// gates: 1A 2/3 boots, 1B 4/4 boots). That is the ORDINARY case, not a malfunction — the
// memoized PskProvider/Dialer properties retry on first use, and the bounded bootstrap
// retry re-arms RefreshContacts until the gate publishes (the first refresh itself can
// land inside the starter window). Logging it as
// Warning/Error twice a boot is what made a soak error-grep on a healthy rig impossible.
_dialer = Resolve(_dialerFactory, nameof(IMeshCommandDialer), firstUse: false);
_pskProvider = Resolve(_pskProviderFactory, nameof(IMeshPskProvider), firstUse: false);
if (!_useGrpc)
{
_log.Info(
"Mesh transport is {Mode}: commands are published on DistributedPubSub and nothing "
+ "is dialled. The contact refresh still runs — it is what keeps the known-cluster "
+ "gate current, and that gate belongs to the inbound ack endpoint, which is mapped "
+ "by port and does not care about this mode", _options.Mode);
}
// Deliberately NO null guard for either dependency here. At this point a still-null value
// is indistinguishable from the starter-ordering race Resolve just logged quietly above,
// so a loud report here cannot tell a lost race from a genuine absence — that decision
// belongs where a still-null result after the memoized properties' first-use retry
// actually costs something: DispatchOverGrpc for the dialer (every dropped command), and
// PublishKnownClusters for the PSK provider (once per incarnation, since that handler runs
// on the contact-refresh timer for the actor's whole life).
// Started in EVERY mode. The refresh is not transport state: it is central's ClusterNode
// inventory, and the known-cluster gate it feeds protects an endpoint mapped by PORT, not by
// MeshTransport:Mode. Gating the timer on the mode would mean a dark-switch rollback to
// Dps left that endpoint live with the gate permanently inactive — silently
// reopening both the timing oracle and the pre-auth store/audit flood. Under Dps this is a
// ConfigDb query every ContactRefreshSeconds that did not run before; central always has a
// ConfigDb, and one indexed read a minute is the cheaper half of the trade.
Timers.StartPeriodicTimer(
RefreshTimerKey,
new RefreshContacts(),
TimeSpan.Zero,
TimeSpan.FromSeconds(_options.ContactRefreshSeconds));
}
/// <summary>Re-arms a single-shot <see cref="RefreshContacts"/> while the gate is unpublished.</summary>
/// <returns><see langword="false"/> when the retry budget is spent and nothing was scheduled.</returns>
private bool ScheduleBootstrapRetry()
{
if (_bootstrapRetries >= MaxBootstrapRetries)
return false;
_bootstrapRetries++;
Timers.StartSingleTimer(BootstrapRetryTimerKey, new RefreshContacts(), _bootstrapRetryInterval);
return true;
}
/// <summary>
/// Invokes a dependency factory, converting a resolution failure into a logged absence.
/// </summary>
/// <typeparam name="T">The dependency's type.</typeparam>
/// <param name="factory">The factory, or <see langword="null"/> when nothing was supplied.</param>
/// <param name="name">The dependency's name, for the log.</param>
/// <param name="firstUse">
/// <see langword="false"/> for the eager <see cref="PreStart"/> call, <see langword="true"/>
/// for the retry the memoized <see cref="Dialer"/>/<see cref="PskProvider"/> properties make
/// on first use. Controls wording only, deliberately at the SAME (Debug) severity either
/// way — see the remarks on why the loud signal lives elsewhere.
/// </param>
/// <returns>The resolved dependency, or <see langword="null"/>.</returns>
/// <remarks>
/// <para>
/// Catching is the point. A throw out of <see cref="PreStart"/> is an actor failure, and
/// the supervisor's answer to that is to restart — which calls <see cref="PreStart"/>
/// again, and a broken DI graph is not a transient condition. That is an unbounded
/// restart loop on the node's command boundary, taking the transport-independent ack
/// path down with it. Logging and continuing leaves the ack path serving.
/// </para>
/// <para>
/// <b>Never logs above Debug, on EITHER call site.</b> From inside this method, a
/// PreStart resolve losing the starter-ordering race and a genuinely broken factory are
/// indistinguishable — both return null. Only the CALLER can tell them apart, because
/// only the caller knows whether a value came back after the retry the memoized
/// properties perform. So the loud, operator-facing signal is deliberately NOT here: it
/// is at the two places that consume a still-null result after that retry —
/// <see cref="DispatchOverGrpc"/> for the dialer (fires every dropped command) and
/// <see cref="PublishKnownClusters"/> for the PSK provider (fires once per incarnation,
/// because that call site runs on a timer forever, where <see cref="DispatchOverGrpc"/>
/// only runs once per command).
/// </para>
/// </remarks>
private T? Resolve<T>(Func<T?>? factory, string name, bool firstUse)
where T : class
{
if (factory is null)
return null; // Never supplied — not a resolve failure, nothing to report at either call site.
T? result;
try
{
result = factory();
}
catch (Exception ex)
{
LogUnresolved(name, firstUse, ex);
return null;
}
if (result is null)
LogUnresolved(name, firstUse, null);
return result;
}
private void LogUnresolved(string name, bool firstUse, Exception? ex)
{
if (firstUse)
{
_log.Debug(
ex,
"{Dependency} is still not resolvable on the first-use retry; continuing without "
+ "it for now. Whether this is worth an operator-facing signal is the caller's call",
name);
}
else
{
_log.Debug(
ex,
"{Dependency} was not yet resolvable at PreStart — likely a starter-ordering race "
+ "with a later starter that has not finished registering yet; will retry on first "
+ "use", name);
}
}
/// <inheritdoc />
protected override void PostStop()
{
// Cancelling the lifecycle token also cancels every gRPC dial in flight. The dialer itself is
// deliberately NOT disposed here: it is a container-owned singleton shared with anything else
// that may dial, and an actor restart must not tear its channel pool down.
_lifecycle.Cancel();
_lifecycle.Dispose();
}
private void HandleMeshCommand(MeshCommand cmd)
{
// Checked first, so a Grpc-mode command never reaches DistributedPubSub. DPS is mesh-wide and
// cannot cross an application-Cluster boundary at all in the split topology.
if (_useGrpc)
{
DispatchOverGrpc(cmd);
return;
}
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(cmd.Topic, cmd.Message));
}
/// <summary>
/// Encodes one command once and fans it to every target node, one unary gRPC call each.
/// </summary>
/// <param name="cmd">The command envelope carrying the domain record.</param>
/// <remarks>
/// <para>
/// <b>Encoded once, before the fan.</b> Not per node — partly because the wire form is
/// identical for every recipient, but mostly because <c>AlarmCommand</c> has no
/// correlation member of its own and the envelope mints one: minting inside the loop
/// would give one operator action N unrelated correlation ids and make it untraceable.
/// </para>
/// <para>
/// <b>Nothing captured from the actor.</b> Each dial is a static call taking only locals,
/// so no per-node continuation can touch actor state off the actor's thread. Results come
/// back as ordinary messages via <c>PipeTo</c> and are handled on the actor thread like
/// anything else.
/// </para>
/// <para>
/// <b>Every dial starts on the thread pool, not here.</b> An <c>async</c> method runs
/// synchronously up to its first <c>await</c>, and the dialer's pre-await work includes
/// <c>IMeshPskProvider.TryGetPsk</c>, which blocks on the secret store (bounded, but
/// seconds) the first time a cluster is looked up — and again after every refresh, since
/// the refresh drops the cache to pick up rotations. Calling straight into it would park
/// this actor's mailbox, and deployment acks queue behind that mailbox.
/// </para>
/// </remarks>
private void DispatchOverGrpc(MeshCommand cmd)
{
var commandType = cmd.Message.GetType().Name;
var dialer = Dialer;
if (dialer is null)
{
_log.Error(
"Mesh transport is Grpc but no command dialer is wired — dropping {MessageType}. The "
+ "command is not buffered and will not be retried",
commandType);
return;
}
// Captured before the fan: a contact refresh between here and the last dial must not change
// the recipient set of a dispatch already under way.
var targets = _targets;
if (targets.Count == 0)
{
// App-level drop. The command is NOT queued and will NOT be retried: a deploy fails at
// the coordinator's apply deadline naming the silent nodes, which is a better operator
// signal than a command arriving minutes late against a config the DB already recorded
// as failed.
_log.Warning(
"No mesh gRPC targets — dropping {MessageType}. The last contact refresh produced no "
+ "enabled, non-maintenance ClusterNode row carrying a GrpcPort in any cluster; the "
+ "command is not buffered and will not be retried",
commandType);
return;
}
CommandEnvelope envelope;
try
{
envelope = MeshTransportDtoMapper.ToEnvelope(cmd.Message, new CorrelationId(Guid.NewGuid()));
}
catch (Exception ex)
{
// The mapper refuses a payload the far side could not decode. Dropping here is the same
// outcome the old transport gave for such a message (nothing on the node matched it), but
// loud instead of silent.
_log.Error(
ex,
"Cannot encode {MessageType} for the gRPC command plane; the command is dropped and "
+ "was NOT delivered to any node",
commandType);
return;
}
CancellationToken ct;
try
{
ct = _lifecycle.Token;
}
catch (ObjectDisposedException)
{
return; // Stopping.
}
var self = Self;
var correlationId = envelope.CorrelationId;
foreach (var target in targets)
{
var node = target;
// CancellationToken.None on Task.Run, deliberately: cancelling the SCHEDULING would make
// the task complete as canceled, which PipeTo converts into a Status.Failure — the one
// message this actor already reads as "the contact load failed". The lifecycle token is
// passed into the dial instead, where it cancels the call and comes back as an ordinary
// per-node outcome.
Task.Run(
() => DispatchOneAsync(dialer, node, envelope, commandType, correlationId, ct),
CancellationToken.None)
.PipeTo(self);
}
}
/// <summary>
/// Dials one node and turns every possible outcome into a <see cref="CommandDispatched"/>.
/// </summary>
/// <param name="dialer">The dialer seam.</param>
/// <param name="target">The node to dial.</param>
/// <param name="envelope">The already-encoded command.</param>
/// <param name="commandType">CLR type name of the command, for the log.</param>
/// <param name="correlationId">Transport correlation, for the log.</param>
/// <param name="ct">The actor's lifecycle token.</param>
/// <returns>The outcome, always — this method does not fault.</returns>
/// <remarks>
/// Catching everything is what keeps the result on ONE channel. A faulting task piped to an
/// actor arrives as <c>Status.Failure</c>, which this actor already handles as "the contact
/// load failed" — so an unhandled dial fault would log a database-outage warning about a node
/// that simply did not answer.
/// </remarks>
private static async Task<CommandDispatched> DispatchOneAsync(
IMeshCommandDialer dialer,
MeshNodeTarget target,
CommandEnvelope envelope,
string commandType,
string correlationId,
CancellationToken ct)
{
try
{
await dialer.DispatchAsync(target.Endpoint, envelope, target.ClusterId, ct)
.ConfigureAwait(false);
return new CommandDispatched(target.NodeId, commandType, correlationId, null, null);
}
catch (RpcException ex)
{
return new CommandDispatched(
target.NodeId,
commandType,
correlationId,
ex.StatusCode.ToString(),
Truncate(ex.Status.Detail));
}
catch (OperationCanceledException)
{
// Named rather than swept into the generic arm: this is the ordinary shape of a node that
// did not answer in time, and "Unknown / TaskCanceledException" would send an operator
// looking for a bug instead of at DispatchTimeoutSeconds.
return new CommandDispatched(
target.NodeId,
commandType,
correlationId,
"Cancelled",
"the dial did not complete within MeshTransport:DispatchTimeoutSeconds, or central "
+ "is shutting down");
}
catch (Exception ex)
{
// Type name only, never the message: this path also covers whatever the dialer's channel
// construction can throw, and a message is the one place a misconfigured endpoint string
// could carry something an operator did not mean to put in a log.
return new CommandDispatched(
target.NodeId, commandType, correlationId, "Unknown", ex.GetType().Name);
}
}
/// <summary>
/// Bounds a status detail before it reaches the log. The string comes from the far side, and
/// the far side is a node — trusted, but not a reason to let it choose how many lines of
/// central's log one failed command occupies.
/// </summary>
/// <param name="detail">The received status detail.</param>
/// <returns>The detail, at most 200 characters.</returns>
private static string Truncate(string? detail)
=> detail is null || detail.Length <= 200 ? detail ?? "" : detail[..200] + "…";
private void HandleCommandDispatched(CommandDispatched result)
{
if (result.FailureStatus is null)
{
_log.Debug(
"Mesh: {CommandType} (correlationId={CorrelationId}) accepted by {NodeId} over gRPC",
result.CommandType, result.CorrelationId, result.NodeId);
return;
}
_log.Warning(
"Mesh: {CommandType} (correlationId={CorrelationId}) was NOT delivered to {NodeId} "
+ "({Status}: {Detail}). It is dropped and will NOT be retried — the deployment apply "
+ "deadline and the node's boot-time replay are the backstops",
result.CommandType, result.CorrelationId, result.NodeId,
result.FailureStatus, result.FailureDetail);
}
private void HandleApplyAck(ApplyAck ack)
{
var coordinator = _coordinator();
if (coordinator is null)
{
_log.Warning(
"Received ApplyAck for {DeploymentId} from {NodeId} but the deploy coordinator is "
+ "not resolvable on this node; the ack is lost and the deployment will time out "
+ "naming a node that applied successfully",
ack.DeploymentId, ack.NodeId);
return;
}
// Forward, not Tell: preserves the original sender across the hop so the coordinator sees
// the node rather than this relay.
coordinator.Forward(ack);
}
private void LoadContactsFromDb()
{
var self = Self;
CancellationToken ct;
try
{
ct = _lifecycle.Token;
}
catch (ObjectDisposedException)
{
return; // Stopping.
}
var dbFactory = _dbFactory;
// Off the actor thread, result piped back as a message. A synchronous DB read here would
// block every command dispatch behind a slow or unreachable SQL Server.
Task.Run(async () =>
{
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
// UNFILTERED, with the eligibility flags projected and applied per row below. The two
// outputs of this read want different row sets: the dispatch set is the enabled,
// non-maintenance nodes central talks TO, while the known-cluster gate answers "is this a
// cluster central has ever heard of" and must include a cluster whose every node is
// disabled or in maintenance — such a cluster can still send an ack, and denying it would
// make maintenance mode an authentication switch.
var rows = await db.ClusterNodes
.AsNoTracking()
.Select(n => new
{
n.NodeId, n.ClusterId, n.Host, n.GrpcPort, n.Enabled, n.MaintenanceMode,
})
.ToListAsync(ct)
.ConfigureAwait(false);
// The gRPC dispatch set: one entry per eligible row that carries a port.
var targets = new List<MeshNodeTarget>(rows.Count);
var portless = new List<string>();
// Every cluster with ANY ClusterNode row — the known-cluster gate's membership set, and
// deliberately a superset of the fan set. Independent of Enabled, MaintenanceMode and
// GrpcPort alike: none of those describe whether the cluster exists.
var clusterIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
clusterIds.Add(row.ClusterId);
// The eligibility filter that used to be the query's WHERE clause. Everything below
// this line — the gRPC targets — sees exactly the rows it saw before.
if (!row.Enabled || row.MaintenanceMode)
continue;
if (row.GrpcPort is { } grpcPort)
targets.Add(new MeshNodeTarget(
row.NodeId, row.ClusterId, $"http://{row.Host}:{grpcPort}"));
else
portless.Add(row.NodeId);
}
return new ContactsLoaded(targets, portless, clusterIds.ToList());
}, ct).PipeTo(self);
}
private void HandleContactsLoaded(ContactsLoaded msg)
{
// Order is load-bearing and is pinned by test: the dispatch set is adopted BEFORE the gate is
// published, so anything that observes the gate can rely on the targets already being current.
if (_useGrpc)
AdoptGrpcTargets(msg);
// EVERY mode. See PreStart — the gate protects an endpoint mapped by port, not by mode.
PublishKnownClusters(msg);
}
/// <summary>
/// Adopts a contact load's gRPC dispatch set. <see cref="MeshTransportOptions.ModeGrpc"/> only
/// — the other two transports have no use for it.
/// </summary>
/// <param name="msg">The load result.</param>
/// <remarks>
/// A host that is unusable as a gRPC endpoint is deliberately NOT screened here: it surfaces
/// as a named per-node dial failure on every dispatch, which is a truer signal than a
/// refresh-time warning about a string that may well be fine.
/// </remarks>
private void AdoptGrpcTargets(ContactsLoaded msg)
{
// Rebuilt against this refresh's skip list, not merely added to. A node that gained a port
// and later lost it again must warn a second time — the operator's mental model is "the last
// refresh told me", not "you were told once, in a log that has since rotated away".
_warnedPortlessNodes.IntersectWith(msg.PortlessNodes);
foreach (var nodeId in msg.PortlessNodes)
{
// Warn-once per node id per continuous skip run: a persistently portless row is
// re-skipped every refresh, and that is the repetition TelemetryNodeSource suppresses.
if (_warnedPortlessNodes.Add(nodeId))
{
_log.Warning(
"ClusterNode {NodeId} has no GrpcPort; it exposes no command endpoint and is "
+ "skipped in this dispatch set (further skips of this node are silent). Commands "
+ "will not reach it until the row carries a port", nodeId);
}
}
_targets = msg.Targets;
// R03:1.3 — release the channels of endpoints that left the dispatch set (host renamed, port
// moved). Only the resolved dialer is asked; nothing is resolved just to prune it.
if (_dialer is { } dialer)
{
try
{
dialer.RetainOnly(_targets.Select(t => t.Endpoint).ToHashSet(StringComparer.Ordinal));
}
catch (Exception ex)
{
_log.Warning(ex, "Mesh dialer failed to release channels for retired endpoints; they stay cached until the next refresh");
}
}
if (_targets.Count == 0)
{
_log.Warning(
"No enabled, non-maintenance ClusterNode row carries a GrpcPort in any cluster; there "
+ "are no gRPC command targets and every central→node command will be dropped until a "
+ "refresh finds one");
}
}
/// <summary>
/// Hands the PSK provider this refresh's cluster inventory and drops its key cache. Runs in
/// <b>every</b> transport mode.
/// </summary>
/// <param name="msg">The load result.</param>
/// <remarks>
/// <para>
/// <b>Not gated on <see cref="MeshTransportOptions.Mode"/>, on purpose.</b> The gate feeds
/// <c>CentralCommandAuthInterceptor</c>, which is registered on every admin node, and the
/// ack service it protects is mapped by listener port — neither consults the mode. Gating
/// this on <c>Grpc</c> would mean a dark-switch rollback to
/// <c>Dps</c> left that endpoint serving with a permanently inactive gate: the timing
/// oracle and the pre-authentication store/audit flood would both come back, silently,
/// as a side effect of a rollback nobody would connect to authentication.
/// </para>
/// <para>
/// <b><c>InvalidateAll</c>, not per-cluster <c>Invalidate</c>.</b> The requirement is that
/// a rotated key takes effect within one refresh, and only a whole-cache drop delivers
/// that: a key can rotate without the cluster's membership changing at all, so
/// invalidating only departed clusters would serve the pre-rotation key forever. The cost
/// is one store read per cluster per refresh — the cache holds a single entry per cluster.
/// </para>
/// <para>
/// <b>This is the PSK provider's "first use" call site.</b> <see cref="PskProvider"/> is
/// evaluated exactly once per call and its result reused below; evaluating it
/// per-statement would double the memoized property's retry rate for no benefit. A
/// still-null result here means the property's own retry, on top of PreStart's original
/// attempt, has ALSO failed — the genuinely permanent absence — so this is where that
/// gets reported loudly, guarded to once per incarnation because this handler runs on
/// the contact-refresh timer for the actor's entire life and an unguarded Error here
/// would repeat every <c>ContactRefreshSeconds</c> forever.
/// </para>
/// </remarks>
private void PublishKnownClusters(ContactsLoaded msg)
{
var pskProvider = PskProvider;
if (pskProvider is null)
{
// Gated on a factory having been supplied at all: a never-supplied factory is a
// deliberate wiring/test choice Resolve() already stayed silent about (see its
// remarks), not a resolve failure, so it earns no Error here either.
// Error only when the scheduler REFUSES (budget spent and this final attempt also
// failed), so a healed boot race stays quiet.
var budgetSpent = _pskProviderFactory is not null && !_gatePublished
&& !ScheduleBootstrapRetry();
if (budgetSpent && !_pskProviderAbsenceReported)
{
_pskProviderAbsenceReported = true;
_log.Error(
"No IMeshPskProvider is resolvable for this actor, even after a first-use retry and "
+ "the bounded bootstrap retries; the known-cluster gate will NEVER be published and cached per-cluster keys "
+ "will NEVER be dropped. Inbound mesh acks stay exposed to the "
+ "pre-authentication secret-store cost the gate removes, and a rotated key "
+ "needs a central restart. Check that the ControlPlane registration wires up "
+ "IMeshPskProvider (this will not be logged again this incarnation)");
}
return;
}
pskProvider.SetKnownClusters(msg.ClusterIds);
// R03:3.4 — refresh the per-cluster keys OFF the request path and swap them in atomically,
// instead of dropping the cache and making the first ack per cluster re-read the store on a
// gRPC thread. A provider that does not pre-resolve (the interface default, and every fake)
// still drops its cache here, synchronously, exactly as before.
CancellationToken ct;
try
{
ct = _lifecycle.Token;
}
catch (ObjectDisposedException)
{
return; // Stopping.
}
var self = Self;
Task refresh;
try
{
refresh = pskProvider.RefreshAsync(msg.ClusterIds, ct);
}
catch (Exception ex)
{
_log.Warning(ex, "Mesh PSK refresh threw synchronously; cached keys are unchanged until the next refresh");
refresh = Task.CompletedTask;
}
if (!refresh.IsCompletedSuccessfully)
{
refresh.PipeTo(self,
success: () => new PskRefreshed(msg.ClusterIds.Count),
failure: ex => new PskRefreshFailed(ex));
}
if (!_gatePublished)
{
_gatePublished = true;
Timers.Cancel(BootstrapRetryTimerKey);
_log.Info(
"Known-cluster gate published ({Count} clusters) after {Attempts} refresh attempt(s)",
msg.ClusterIds.Count, _refreshAttempts);
}
}
/// <summary>Internal tick asking for a contact-point refresh.</summary>
public sealed record RefreshContacts;
/// <summary>The asynchronous PSK refresh (R03:3.4) completed.</summary>
/// <param name="ClusterCount">How many clusters were refreshed.</param>
public sealed record PskRefreshed(int ClusterCount);
/// <summary>The asynchronous PSK refresh (R03:3.4) threw; the previous keys stay in service.</summary>
/// <param name="Error">The failure.</param>
public sealed record PskRefreshFailed(Exception Error);
/// <summary>Result of a contact-point load, piped back from the DB read.</summary>
/// <param name="Targets">
/// The gRPC dispatch set: one entry per enabled, non-maintenance row that carries a
/// <c>GrpcPort</c>.
/// </param>
/// <param name="PortlessNodes">
/// Node ids skipped because their row has no <c>GrpcPort</c>. Carried rather than logged in
/// the load task so the warn-once bookkeeping stays on the actor's thread.
/// </param>
/// <param name="ClusterIds">
/// Every application <c>Cluster</c> with <b>any</b> <c>ClusterNode</c> row — the PSK
/// provider's known-cluster gate membership, and deliberately a superset of
/// <paramref name="Targets"/>: a cluster whose nodes are all disabled or in maintenance can
/// still send an ack, and refusing it would make maintenance mode an authentication switch.
/// </param>
public sealed record ContactsLoaded(
IReadOnlyList<MeshNodeTarget> Targets,
IReadOnlyList<string> PortlessNodes,
IReadOnlyList<string> ClusterIds);
/// <summary>One node the gRPC command plane can dial.</summary>
/// <param name="NodeId">The <c>ClusterNode</c> row's id — what a failure log names.</param>
/// <param name="ClusterId">
/// The node's application <c>Cluster</c>. Selects the pre-shared key presented to it: a node
/// dialled with another cluster's id is graded against the wrong key and refuses the command.
/// </param>
/// <param name="Endpoint">The h2c base address, <c>http://{Host}:{GrpcPort}</c>.</param>
public sealed record MeshNodeTarget(string NodeId, string ClusterId, string Endpoint);
/// <summary>
/// The outcome of one node's dial, piped back to the actor <b>for logging only</b>. Nothing
/// retries, counts or waits on these — a deployment's completion is judged by the acks nodes
/// send back, never by whether central's outbound call succeeded.
/// </summary>
/// <param name="NodeId">The node dialled.</param>
/// <param name="CommandType">CLR type name of the command.</param>
/// <param name="CorrelationId">The envelope's transport correlation.</param>
/// <param name="FailureStatus">
/// <see langword="null"/> on success; otherwise the gRPC status code's name, or
/// <c>Unknown</c> for a non-gRPC fault.
/// </param>
/// <param name="FailureDetail">
/// The gRPC status detail, or the exception's type name for a non-gRPC fault. Never a key,
/// and never a raw exception message.
/// </param>
public sealed record CommandDispatched(
string NodeId,
string CommandType,
string CorrelationId,
string? FailureStatus,
string? FailureDetail);
}