using System.Collections.Immutable;
using Akka.Actor;
using Akka.Cluster.Tools.Client;
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;
///
/// Central-side end of the central↔node command boundary (per-cluster mesh Phase 2). Routes
/// every central→node command out — over DistributedPubSub, ClusterClient or gRPC, per
/// — and forwards inbound s to the
/// deploy coordinator singleton.
///
///
///
/// Registered per node, NOT as a cluster singleton. A driver node's ClusterClient
/// rotates across contact points and must find a live comm actor at whichever central node
/// answers. A singleton would be reachable only through the one node hosting it, and the
/// rotation would fail silently against the other.
///
///
/// One ClusterClient per application Cluster (Phase 6). The fleet is now one
/// Akka mesh per cluster, so a single fleet-wide client's SendToAll would reach only
/// the one mesh whose receptionist answered. This actor therefore holds a client per cluster
/// and fans SendToAll across all of them. See — this is the
/// single most important thing to understand before changing this actor.
///
///
/// The gRPC branch fans over the same recipient set, node by node
/// (ClusterClient→gRPC Phase 1A). SendToAll reaches every node registered on a mesh's
/// receptionist, and the contact load already selects exactly the enabled, non-maintenance
/// rows those nodes come from; the gRPC branch dials that same row set directly, one call per
/// node, so the recipient set is identical by construction rather than by coincidence. It is
/// deliberately NOT narrower: the node side has no ClusterId 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.
///
///
/// Fire-and-forget, on every transport. 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
/// ClusterClient semantic preserved exactly, and the backstops are unchanged (the deploy
/// coordinator's apply deadline, and a node's boot-time replay).
///
///
public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
{
private const string RefreshTimerKey = "mesh-contact-refresh";
private readonly IDbContextFactory _dbFactory;
private readonly MeshTransportOptions _options;
private readonly IMeshClusterClientFactory _clientFactory;
private readonly Func _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? _dialerFactory;
private readonly Func? _pskProviderFactory;
private IMeshCommandDialer? _dialer;
private IMeshPskProvider? _pskProvider;
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly bool _useClusterClient;
private readonly bool _useGrpc;
// One client per application Cluster, keyed by ClusterId. A ClusterClient reaches only the mesh
// whose receptionist it dialled, and after Phase 6 every cluster is its own mesh — so central must
// hold a client per cluster and fan SendToAll across all of them.
private readonly Dictionary Contacts)> _clients =
new();
// 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 _warnedPortlessNodes = new(StringComparer.Ordinal);
// 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 _targets = [];
private CancellationTokenSource _lifecycle = new();
/// Gets the timer scheduler driving the periodic contact refresh.
public ITimerScheduler Timers { get; set; } = null!;
/// Creates the props for this actor.
/// Factory for the config database holding ClusterNode rows.
/// The bound mesh-transport options.
/// Creates the ClusterClient; substituted in tests.
///
/// Resolves the deploy-coordinator singleton proxy. A rather than
/// an so this actor never depends on registration order at wiring
/// time — it resolves on first use and caches.
///
///
/// Resolves the thing that places one command call on one node, under
/// . Optional so the two older transports need
/// nothing new wired; a dialer under Grpc is a mis-wiring and
/// is reported loudly rather than silently dropping every command.
///
///
/// 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 .
///
/// The props.
///
///
/// Both dependencies arrive as , and that is not a style
/// choice. 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 → IMeshPskProvider → ISecretResolver → AddZbSecrets
/// → IAuditWriter → ActorAuditWriter, whose constructor calls
/// registry.Get<AuditWriterActorKey>() — a singleton registered dozens of
/// lines LATER in the very same builder. Every central node failed to boot, in every
/// transport mode, on a MissingActorRegistryEntryException. Deferring to
/// moves the first resolve to a point where the host is fully
/// built, and matches the the coordinator proxy already uses
/// one argument earlier for the same class of reason.
///
///
/// This is the third instance of the same failure mode in this family — eager resolution
/// inside an AddAkka configurator, previously scadaproj#1 (a DI cycle that
/// deadlocked) and mesh Phase 6's IClusterRoleInfo boot StackOverflow. The
/// rule it keeps re-teaching: inside an Akka builder, resolve nothing you can defer.
///
///
public static Props Props(
IDbContextFactory dbFactory,
MeshTransportOptions options,
IMeshClusterClientFactory clientFactory,
Func coordinator,
Func? dialer = null,
Func? pskProvider = null) =>
Akka.Actor.Props.Create(() =>
new CentralCommunicationActor(
dbFactory, options, clientFactory, coordinator, dialer, pskProvider));
/// Initializes a new instance of the class.
/// Factory for the config database.
/// The bound mesh-transport options.
/// Creates the ClusterClient.
/// Lazily resolves the deploy-coordinator singleton proxy.
/// Resolves the gRPC command dialer; invoked in .
/// Resolves the PSK provider; invoked in .
public CentralCommunicationActor(
IDbContextFactory dbFactory,
MeshTransportOptions options,
IMeshClusterClientFactory clientFactory,
Func coordinator,
Func? dialer = null,
Func? pskProvider = null)
{
_dbFactory = dbFactory;
_options = options;
_clientFactory = clientFactory;
_coordinator = coordinator;
_dialerFactory = dialer;
_pskProviderFactory = pskProvider;
_useClusterClient = string.Equals(
options.Mode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase);
_useGrpc = string.Equals(
options.Mode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase);
Receive(HandleMeshCommand);
Receive(HandleApplyAck);
Receive(_ => LoadContactsFromDb());
Receive(HandleContactsLoaded);
Receive(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.
Receive(f =>
{
_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(_ => { /* DPS subscribe confirmation */ });
}
///
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.
_dialer = Resolve(_dialerFactory, nameof(IMeshCommandDialer));
_pskProvider = Resolve(_pskProviderFactory, nameof(IMeshPskProvider));
if (!_useClusterClient && !_useGrpc)
{
_log.Info(
"Mesh transport is {Mode}: commands are published on DistributedPubSub and no "
+ "ClusterClient is created. 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);
}
if (_useGrpc && _dialer is null)
{
// Not a fatal throw: the ack path (HandleApplyAck) is transport-independent and still
// works, so taking the actor down would turn a command-plane mis-wiring into an ack
// outage as well. Every dispatch logs its own error, so this cannot go unnoticed.
_log.Error(
"Mesh transport is Grpc but no command dialer was supplied to this actor; EVERY "
+ "central→node command will be dropped. Check the ControlPlane registration wires "
+ "IMeshCommandDialer");
}
if (_pskProvider is null)
{
// Mode-independent, like the gate itself. A silent absence here is the worst of the
// three outcomes in this method: nothing malfunctions, so nothing is ever investigated,
// while the gate stays permanently inactive and a rotated key is never picked up.
_log.Error(
"No IMeshPskProvider was supplied to this actor; 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 the ControlPlane registration");
}
// 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
// ClusterClient or 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));
}
///
/// Invokes a dependency factory, converting a resolution failure into a logged absence.
///
/// The dependency's type.
/// The factory, or when nothing was supplied.
/// The dependency's name, for the log.
/// The resolved dependency, or .
///
/// Catching is the point. A throw out of is an actor failure, and the
/// supervisor's answer to that is to restart — which calls 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 and hands the two null guards below a genuine
/// absence to report, which is the same outcome as never having been wired.
///
private T? Resolve(Func? factory, string name)
where T : class
{
if (factory is null)
return null;
try
{
return factory();
}
catch (Exception ex)
{
_log.Error(
ex,
"Failed to resolve {Dependency} for the mesh command boundary; continuing without "
+ "it. The guards below report what that costs", name);
return null;
}
}
///
protected override void PostStop()
{
foreach (var (client, _) in _clients.Values) Context.Stop(client);
_clients.Clear();
// 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 before the DPS arm: _useClusterClient is false under Grpc too, so the order is
// what keeps a Grpc-mode command off DistributedPubSub.
if (_useGrpc)
{
DispatchOverGrpc(cmd);
return;
}
if (!_useClusterClient)
{
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(cmd.Topic, cmd.Message));
return;
}
if (_clients.Count == 0)
{
// App-level drop, matching the sister project's decision and this repo's buffer-size = 0.
// 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 ClusterClient — dropping {MessageType}. The last contact refresh produced "
+ "no usable contact point from the enabled, non-maintenance ClusterNode rows in any "
+ "cluster; the command is not buffered and will not be retried",
cmd.Message.GetType().Name);
return;
}
// SendToAll, NEVER Send, fanned across EVERY cluster's client. Today's DPS publish reaches
// EVERY DriverHostActor — there is no ClusterId or node filter on the node side at all; scoping
// happens later, inside the artifact (DeploymentArtifact.ResolveClusterScope). Each ClusterClient
// reaches only the one mesh it dialled, so central must fan across all of them to preserve the
// fleet-wide reach DPS gives. ClusterClient.Send delivers to exactly ONE registered actor, which
// would deploy to a single node while every other node silently kept its old configuration — and
// the deployment would still seal green if the ack set happened to be satisfied.
foreach (var (client, _) in _clients.Values)
client.Tell(new ClusterClient.SendToAll(MeshPaths.NodeCommunication, cmd.Message));
}
///
/// Encodes one command once and fans it to every target node, one unary gRPC call each.
///
/// The command envelope carrying the domain record.
///
///
/// Encoded once, before the fan. Not per node — partly because the wire form is
/// identical for every recipient, but mostly because AlarmCommand 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.
///
///
/// Nothing captured from the actor. 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 PipeTo and are handled on the actor thread like
/// anything else.
///
///
/// Every dial starts on the thread pool, not here. An async method runs
/// synchronously up to its first await, and the dialer's pre-await work includes
/// IMeshPskProvider.TryGetPsk, 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.
///
///
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, exactly as the ClusterClient arm does with no client. 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);
}
}
///
/// Dials one node and turns every possible outcome into a .
///
/// The dialer seam.
/// The node to dial.
/// The already-encoded command.
/// CLR type name of the command, for the log.
/// Transport correlation, for the log.
/// The actor's lifecycle token.
/// The outcome, always — this method does not fault.
///
/// Catching everything is what keeps the result on ONE channel. A faulting task piped to an
/// actor arrives as Status.Failure, 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.
///
private static async Task 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);
}
}
///
/// 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.
///
/// The received status detail.
/// The detail, at most 200 characters.
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 systemName = Context.System.Name;
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 contact/dispatch sets are 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.AkkaPort, n.GrpcPort, n.Enabled, n.MaintenanceMode,
})
.ToListAsync(ct)
.ConfigureAwait(false);
// Grouped BY ClusterId: central now dials one ClusterClient per application Cluster, so the
// contact set must be partitioned the same way. A row's contacts join only its own cluster's
// client.
var byCluster = new Dictionary>();
var malformed = new List();
// The gRPC dispatch set, built from the SAME row set in the SAME pass. Sharing the read is
// what makes "the gRPC fan reaches exactly the ClusterClient recipient set" structural
// rather than a claim two separate queries would have to keep agreeing on.
var targets = new List(rows.Count);
var portless = new List();
// 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(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 — receptionist contacts and 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);
var address = $"akka.tcp://{systemName}@{row.Host}:{row.AkkaPort}/system/receptionist";
// Parse up front, per row, inside the loop's own guard. A single malformed row must
// not abort the whole refresh and leave the contact set half-built — the sister
// project shipped that bug and its regression test deliberately orders the bad row
// first.
if (ActorPath.TryParse(address, out _))
{
if (!byCluster.TryGetValue(row.ClusterId, out var list))
byCluster[row.ClusterId] = list = new List();
list.Add(address);
}
else
{
malformed.Add($"{row.NodeId} ({row.ClusterId}) -> {address}");
}
}
var contactsByCluster = byCluster.ToDictionary(
kv => kv.Key, kv => (IReadOnlyList)kv.Value);
return new ContactsLoaded(
contactsByCluster, malformed, 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);
if (!_useClusterClient)
return; // Dps or Grpc: no ClusterClient to rebuild.
foreach (var bad in msg.Malformed)
{
_log.Warning(
"ClusterNode {Row} does not yield a usable receptionist address; skipping it in this "
+ "refresh (other nodes are unaffected). Check the row's Host and AkkaPort", bad);
}
var byCluster = msg.ContactsByCluster.ToDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet());
// Clusters that dropped out of the DB entirely, or whose only rows were malformed this refresh:
// stop and forget their clients so a vanished cluster stops receiving. LoadContactsFromDb only
// creates a ByCluster key when at least one of its rows parsed, so an all-malformed cluster has
// NO key at all and is caught here by the absent-key (!TryGetValue) arm — not by an empty value.
var gone = _clients.Keys
.Where(id => !byCluster.TryGetValue(id, out var c) || c.IsEmpty)
.ToList();
foreach (var clusterId in gone)
{
_log.Info(
"Cluster {ClusterId} no longer yields any usable contact point; stopping its "
+ "ClusterClient", clusterId);
Context.Stop(_clients[clusterId].Client);
_clients.Remove(clusterId);
}
var anyUsable = false;
foreach (var (clusterId, contacts) in byCluster)
{
if (contacts.IsEmpty)
{
// Defensive: LoadContactsFromDb never emits an empty-but-present cluster (a key is
// created only on a first successfully-parsed row), so this arm is unreachable for the
// DB producer — an all-malformed cluster is handled by the absent-key branch above. It
// guards a ContactsLoaded (a public record) constructed elsewhere with an empty list.
_log.Warning(
"Cluster {ClusterId} has no usable ClusterNode contact points; no ClusterClient "
+ "was created for it and its nodes' commands will be dropped until a refresh finds "
+ "one", clusterId);
continue;
}
anyUsable = true;
if (_clients.TryGetValue(clusterId, out var existing) && existing.Contacts.SetEquals(contacts))
continue;
RebuildClient(clusterId, contacts);
}
if (!anyUsable && _clients.Count == 0)
{
_log.Warning(
"No usable ClusterNode contact points in any cluster; no mesh ClusterClient was "
+ "created and every central→node command will be dropped until a refresh finds one");
}
}
///
/// Adopts a contact load's gRPC dispatch set. only
/// — the other two transports have no use for it.
///
/// The load result.
///
/// The malformed-receptionist-address warnings are deliberately NOT logged here: nothing on
/// this transport dials a receptionist, and reporting an unusable Akka address as a
/// command-plane problem would send an operator after the wrong column. A host that is
/// unusable as a gRPC endpoint surfaces instead as a named per-node dial failure on every
/// dispatch, which is a truer signal.
///
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;
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");
}
}
///
/// Hands the PSK provider this refresh's cluster inventory and drops its key cache. Runs in
/// every transport mode.
///
/// The load result.
///
///
/// Not gated on , on purpose. The gate feeds
/// CentralCommandAuthInterceptor, 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 Grpc would mean a dark-switch rollback to ClusterClient or
/// Dps 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.
///
///
/// InvalidateAll, not per-cluster Invalidate. 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.
///
///
private void PublishKnownClusters(ContactsLoaded msg)
{
_pskProvider?.SetKnownClusters(msg.ClusterIds);
_pskProvider?.InvalidateAll();
}
/// Stops a cluster's existing client and creates a replacement for its new contact set.
///
///
/// One client per application Cluster (delivered in Phase 6). This actor now
/// holds one ClusterClient per cluster and fans
/// SendToAll across all of them. Once the fleet is split into one Akka mesh per
/// cluster, a ClusterClientReceptionist serves only its own mesh, so a single
/// client's SendToAll would reach the one mesh whose receptionist answered and leave
/// every other cluster silently on its old configuration. Fanning across a client per
/// cluster restores the fleet-wide reach the pre-split DPS broadcast gave.
///
///
/// Why this is not N× duplicate dispatch: each client dials only its own cluster's
/// receptionists, so a command is delivered once per mesh, not once per cluster into
/// every mesh. (While the fleet was a single mesh, the same code would have duplicated —
/// which is exactly why the fan-out could not land until the meshes were partitioned.)
///
///
/// Corollary worth stating because it is easy to assume otherwise: the contact set
/// does not scope delivery within a mesh. Excluding a maintenance-mode node from a
/// cluster's contacts does not stop it receiving commands — its receptionist still delivers
/// to every registered node-comm actor in that mesh. The filter is about which receptionists
/// are worth dialling (a node in maintenance may be switched off), not about who gets
/// the message.
///
///
/// The application Cluster whose client is being (re)built.
/// The receptionist addresses to dial for that cluster.
private void RebuildClient(string clusterId, ImmutableHashSet contacts)
{
if (_clients.TryGetValue(clusterId, out var existing))
{
_log.Info("Contact set for cluster {ClusterId} changed; replacing its ClusterClient", clusterId);
Context.Stop(existing.Client);
// Removed now: if the create below throws, a stale ref would route commands into a
// stopping actor and they would vanish without a warning.
_clients.Remove(clusterId);
}
try
{
var paths = contacts.Select(ActorPath.Parse).ToImmutableHashSet();
var client = _clientFactory.Create(Context.System, clusterId, paths);
_clients[clusterId] = (client, contacts);
_log.Info(
"Mesh ClusterClient for cluster {ClusterId} created with {Count} contact point(s)",
clusterId, contacts.Count);
}
catch (Exception ex)
{
_log.Error(ex,
"Failed to create the mesh ClusterClient for cluster {ClusterId}; its nodes' commands "
+ "are dropped until the next contact refresh succeeds", clusterId);
}
}
/// Internal tick asking for a contact-point refresh.
public sealed record RefreshContacts;
/// Result of a contact-point load, piped back from the DB read.
///
/// Receptionist addresses that parsed cleanly, grouped by the owning application
/// Cluster's id. One ClusterClient is built per key.
///
/// Rows that did not parse, described for the log.
///
/// The gRPC dispatch set: one entry per enabled, non-maintenance row that carries a
/// GrpcPort. Built from the same rows as .
///
///
/// Node ids skipped because their row has no GrpcPort. Carried rather than logged in
/// the load task so the warn-once bookkeeping stays on the actor's thread.
///
///
/// Every application Cluster with any ClusterNode row — the PSK
/// provider's known-cluster gate membership, and deliberately a superset of
/// : 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.
///
public sealed record ContactsLoaded(
IReadOnlyDictionary> ContactsByCluster,
IReadOnlyList Malformed,
IReadOnlyList Targets,
IReadOnlyList PortlessNodes,
IReadOnlyList ClusterIds);
/// One node the gRPC command plane can dial.
/// The ClusterNode row's id — what a failure log names.
///
/// The node's application Cluster. 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.
///
/// The h2c base address, http://{Host}:{GrpcPort}.
public sealed record MeshNodeTarget(string NodeId, string ClusterId, string Endpoint);
///
/// The outcome of one node's dial, piped back to the actor for logging only. 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.
///
/// The node dialled.
/// CLR type name of the command.
/// The envelope's transport correlation.
///
/// on success; otherwise the gRPC status code's name, or
/// Unknown for a non-gRPC fault.
///
///
/// The gRPC status detail, or the exception's type name for a non-gRPC fault. Never a key,
/// and never a raw exception message.
///
public sealed record CommandDispatched(
string NodeId,
string CommandType,
string CorrelationId,
string? FailureStatus,
string? FailureDetail);
}