refactor(mesh): step 1 — delete the central ClusterClient factory and per-cluster client cache
IMeshClusterClientFactory/DefaultMeshClusterClientFactory are gone, along with
CentralCommunicationActor's _clients map, RebuildClient, the SendToAll fan and the
receptionist-address half of the contact load. LoadContactsFromDb SURVIVES — it feeds the
gRPC dialer's endpoint resolution and the known-cluster gate — but now projects only the
columns those two need (the AkkaPort/ActorPath.TryParse parse and its Malformed list went
with the receptionist addresses).
Ripples fixed in this step because the build must be green at every commit:
- ControlPlane/ServiceCollectionExtensions drops the factory argument.
- CentralCommunicationActorTests: the six ClusterClient-only facts are deleted (each has a
Grpc counterpart that already asserted the same property independently), the three
transport-agnostic facts move to Grpc mode, and the Dps fact keeps its own coverage.
- MeshClusterClientFactoryTests deleted (its subject is gone).
- MeshClusterClientBoundaryTests deleted here rather than in step 7: it drives
DefaultMeshClusterClientFactory directly, so it cannot compile past this commit. Its
replacement, MeshGrpcBoundaryTests, lands in step 7 and keeps the load-bearing assertion
(two separate meshes, no cross-mesh Akka association).
This commit is contained in:
+46
-260
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.Client;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using Grpc.Core;
|
||||
@@ -20,39 +18,30 @@ 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, ClusterClient or gRPC, per
|
||||
/// <see cref="MeshTransportOptions.Mode"/> — and forwards inbound <see cref="ApplyAck"/>s to the
|
||||
/// deploy coordinator singleton.
|
||||
/// 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'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.
|
||||
/// <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>One ClusterClient per application <c>Cluster</c> (Phase 6).</b> The fleet is now one
|
||||
/// Akka mesh per cluster, so a single fleet-wide client's <c>SendToAll</c> would reach only
|
||||
/// the one mesh whose receptionist answered. This actor therefore holds a client per cluster
|
||||
/// and fans <c>SendToAll</c> across all of them. See <see cref="RebuildClient"/> — this is the
|
||||
/// single most important thing to understand before changing this actor.
|
||||
/// <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>The gRPC branch fans over the same recipient set, node by node</b>
|
||||
/// (ClusterClient→gRPC Phase 1A). <c>SendToAll</c> 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 <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 every transport.</b> The gRPC branch pipes per-node results back to
|
||||
/// <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
|
||||
/// ClusterClient semantic preserved exactly, and the backstops are unchanged (the deploy
|
||||
/// DistributedPubSub semantic preserved exactly, and the backstops are unchanged (the deploy
|
||||
/// coordinator's apply deadline, and a node's boot-time replay).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -62,7 +51,6 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly MeshTransportOptions _options;
|
||||
private readonly IMeshClusterClientFactory _clientFactory;
|
||||
private readonly Func<IActorRef?> _coordinator;
|
||||
|
||||
// Resolved in PreStart, never at construction. See the Props doc: the DI graph behind these two
|
||||
@@ -88,15 +76,8 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
_dialer ??= Resolve(_dialerFactory, nameof(IMeshCommandDialer), firstUse: true);
|
||||
|
||||
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<string, (IActorRef Client, ImmutableHashSet<string> 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.
|
||||
@@ -121,7 +102,6 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// <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="clientFactory">Creates the ClusterClient; substituted in tests.</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
|
||||
@@ -129,9 +109,9 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// </param>
|
||||
/// <param name="dialer">
|
||||
/// Resolves the thing that places one command call on one node, under
|
||||
/// <see cref="MeshTransportOptions.ModeGrpc"/>. Optional so the two older transports need
|
||||
/// 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.
|
||||
/// <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
|
||||
@@ -163,37 +143,30 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
MeshTransportOptions options,
|
||||
IMeshClusterClientFactory clientFactory,
|
||||
Func<IActorRef?> coordinator,
|
||||
Func<IMeshCommandDialer?>? dialer = null,
|
||||
Func<IMeshPskProvider?>? pskProvider = null) =>
|
||||
Akka.Actor.Props.Create(() =>
|
||||
new CentralCommunicationActor(
|
||||
dbFactory, options, clientFactory, coordinator, dialer, pskProvider));
|
||||
new CentralCommunicationActor(dbFactory, options, coordinator, dialer, pskProvider));
|
||||
|
||||
/// <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="clientFactory">Creates the ClusterClient.</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>
|
||||
public CentralCommunicationActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
MeshTransportOptions options,
|
||||
IMeshClusterClientFactory clientFactory,
|
||||
Func<IActorRef?> coordinator,
|
||||
Func<IMeshCommandDialer?>? dialer = null,
|
||||
Func<IMeshPskProvider?>? 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);
|
||||
|
||||
@@ -246,13 +219,13 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
_dialer = Resolve(_dialerFactory, nameof(IMeshCommandDialer), firstUse: false);
|
||||
_pskProvider = Resolve(_pskProviderFactory, nameof(IMeshPskProvider), firstUse: false);
|
||||
|
||||
if (!_useClusterClient && !_useGrpc)
|
||||
if (!_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);
|
||||
"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
|
||||
@@ -266,7 +239,7 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
// 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
|
||||
// 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.
|
||||
@@ -357,9 +330,6 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// <inheritdoc />
|
||||
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.
|
||||
@@ -369,43 +339,15 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
|
||||
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.
|
||||
// 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;
|
||||
}
|
||||
|
||||
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));
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(cmd.Topic, cmd.Message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -453,10 +395,10 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
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.
|
||||
// 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 "
|
||||
@@ -632,7 +574,6 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
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
|
||||
@@ -641,29 +582,21 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
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.
|
||||
// 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.AkkaPort, n.GrpcPort, n.Enabled, n.MaintenanceMode,
|
||||
n.NodeId, n.ClusterId, n.Host, 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<string, List<string>>();
|
||||
var malformed = new List<string>();
|
||||
|
||||
// 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.
|
||||
// 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>();
|
||||
|
||||
@@ -677,8 +610,7 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
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.
|
||||
// this line — the gRPC targets — sees exactly the rows it saw before.
|
||||
if (!row.Enabled || row.MaintenanceMode)
|
||||
continue;
|
||||
|
||||
@@ -687,29 +619,9 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
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<string>();
|
||||
list.Add(address);
|
||||
}
|
||||
else
|
||||
{
|
||||
malformed.Add($"{row.NodeId} ({row.ClusterId}) -> {address}");
|
||||
}
|
||||
}
|
||||
|
||||
var contactsByCluster = byCluster.ToDictionary(
|
||||
kv => kv.Key, kv => (IReadOnlyList<string>)kv.Value);
|
||||
return new ContactsLoaded(
|
||||
contactsByCluster, malformed, targets, portless, clusterIds.ToList());
|
||||
return new ContactsLoaded(targets, portless, clusterIds.ToList());
|
||||
}, ct).PipeTo(self);
|
||||
}
|
||||
|
||||
@@ -722,66 +634,6 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -790,11 +642,9 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
/// <param name="msg">The load result.</param>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// 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)
|
||||
{
|
||||
@@ -837,7 +687,7 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// <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>ClusterClient</c> or
|
||||
/// 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.
|
||||
@@ -887,75 +737,13 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
pskProvider.InvalidateAll();
|
||||
}
|
||||
|
||||
/// <summary>Stops a cluster's existing client and creates a replacement for its new contact set.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One client per application <c>Cluster</c> (delivered in Phase 6).</b> This actor now
|
||||
/// holds one <c>ClusterClient</c> per cluster and <see cref="HandleMeshCommand"/> fans
|
||||
/// <c>SendToAll</c> across all of them. Once the fleet is split into one Akka mesh per
|
||||
/// cluster, a <c>ClusterClientReceptionist</c> serves only <i>its own</i> mesh, so a single
|
||||
/// client's <c>SendToAll</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why this is not N× duplicate dispatch: each client dials only its own cluster's
|
||||
/// receptionists, so a command is delivered <b>once per mesh</b>, 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.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Corollary worth stating because it is easy to assume otherwise: <b>the contact set
|
||||
/// does not scope delivery within a mesh.</b> 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 <i>dialling</i> (a node in maintenance may be switched off), not about who gets
|
||||
/// the message.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="clusterId">The application <c>Cluster</c> whose client is being (re)built.</param>
|
||||
/// <param name="contacts">The receptionist addresses to dial for that cluster.</param>
|
||||
private void RebuildClient(string clusterId, ImmutableHashSet<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Internal tick asking for a contact-point refresh.</summary>
|
||||
public sealed record RefreshContacts;
|
||||
|
||||
/// <summary>Result of a contact-point load, piped back from the DB read.</summary>
|
||||
/// <param name="ContactsByCluster">
|
||||
/// Receptionist addresses that parsed cleanly, grouped by the owning application
|
||||
/// <c>Cluster</c>'s id. One <c>ClusterClient</c> is built per key.
|
||||
/// </param>
|
||||
/// <param name="Malformed">Rows that did not parse, described for the log.</param>
|
||||
/// <param name="Targets">
|
||||
/// The gRPC dispatch set: one entry per enabled, non-maintenance row that carries a
|
||||
/// <c>GrpcPort</c>. Built from the same rows as <paramref name="ContactsByCluster"/>.
|
||||
/// <c>GrpcPort</c>.
|
||||
/// </param>
|
||||
/// <param name="PortlessNodes">
|
||||
/// Node ids skipped because their row has no <c>GrpcPort</c>. Carried rather than logged in
|
||||
@@ -968,8 +756,6 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// still send an ack, and refusing it would make maintenance mode an authentication switch.
|
||||
/// </param>
|
||||
public sealed record ContactsLoaded(
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> ContactsByCluster,
|
||||
IReadOnlyList<string> Malformed,
|
||||
IReadOnlyList<MeshNodeTarget> Targets,
|
||||
IReadOnlyList<string> PortlessNodes,
|
||||
IReadOnlyList<string> ClusterIds);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.Client;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the <c>ClusterClient</c> central uses to reach driver nodes. Exists as a seam so
|
||||
/// <c>CentralCommunicationActor</c> can be tested against a <c>TestProbe</c> without standing up
|
||||
/// a second cluster.
|
||||
/// </summary>
|
||||
public interface IMeshClusterClientFactory
|
||||
{
|
||||
/// <summary>Creates a client for the given receptionist contact points.</summary>
|
||||
/// <param name="system">The actor system to spawn the client in.</param>
|
||||
/// <param name="clusterId">
|
||||
/// The application <c>Cluster</c> these contacts belong to. Central now creates one client per
|
||||
/// cluster (Phase 6), so the id is woven into the client's actor name to keep the per-cluster
|
||||
/// clients distinct and diagnosable.
|
||||
/// </param>
|
||||
/// <param name="contacts">Receptionist actor paths, one per reachable node.</param>
|
||||
/// <returns>The new client's <see cref="IActorRef"/>.</returns>
|
||||
IActorRef Create(ActorSystem system, string clusterId, ImmutableHashSet<ActorPath> contacts);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed class DefaultMeshClusterClientFactory : IMeshClusterClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-incarnation generation counter, appended to the actor name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>Context.Stop</c> of the previous client is <b>asynchronous</b> — its actor name stays
|
||||
/// reserved until termination completes — so recreating the same name while handling a single
|
||||
/// message throws <see cref="InvalidActorNameException"/>. That is exactly what a contact-set
|
||||
/// change does: stop the old client, create the replacement, both in one handler. A
|
||||
/// generation suffix makes every incarnation's name unique by construction. Ported from the
|
||||
/// sister project, where it was a shipped bug fix rather than foresight.
|
||||
/// </remarks>
|
||||
private long _generation;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IActorRef Create(ActorSystem system, string clusterId, ImmutableHashSet<ActorPath> contacts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(system);
|
||||
ArgumentException.ThrowIfNullOrEmpty(clusterId);
|
||||
ArgumentNullException.ThrowIfNull(contacts);
|
||||
|
||||
var settings = ClusterClientSettings.Create(system).WithInitialContacts(contacts);
|
||||
var name = $"mesh-client-{SanitizeForActorName(clusterId)}-{Interlocked.Increment(ref _generation)}";
|
||||
return system.ActorOf(ClusterClient.Props(settings), name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reduces a cluster id to characters safe in an Akka actor name. The id is operator-authored
|
||||
/// (e.g. a GUID or a slug), so it may carry characters Akka rejects; anything outside
|
||||
/// <c>[A-Za-z0-9-]</c> becomes <c>-</c>. The trailing generation counter still guarantees
|
||||
/// uniqueness, so a lossy sanitisation cannot cause a name collision.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The raw cluster id.</param>
|
||||
/// <returns>An actor-name-safe rendering of the id.</returns>
|
||||
private static string SanitizeForActorName(string clusterId)
|
||||
{
|
||||
var chars = clusterId.Select(c => char.IsAsciiLetterOrDigit(c) || c == '-' ? c : '-');
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -56,11 +56,11 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
// Per-cluster mesh Phase 2: the central end of the central↔node command boundary.
|
||||
//
|
||||
// A plain WithActors, NOT a singleton — and that is the load-bearing part. A driver node's
|
||||
// ClusterClient rotates across contact points and must find a live comm actor at whichever
|
||||
// central node answers. As a singleton it would exist on one central node only, and the
|
||||
// rotation would fail silently against the other: acks would land sometimes, depending on
|
||||
// which contact the client happened to settle on.
|
||||
// A plain WithActors, NOT a singleton — and that is the load-bearing part. A driver node
|
||||
// walks MeshTransport:CentralGrpcEndpoints in order and posts its ack to whichever central
|
||||
// node answers first, so a live comm actor must exist on both. As a singleton it would exist
|
||||
// on one central node only, and the other node's ack ingress would have nothing to forward
|
||||
// to: acks would land sometimes, depending on which endpoint answered.
|
||||
builder.WithActors((system, registry, resolver) =>
|
||||
{
|
||||
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||
@@ -85,7 +85,6 @@ public static class ServiceCollectionExtensions
|
||||
CentralCommunicationActor.Props(
|
||||
dbFactory,
|
||||
options,
|
||||
new DefaultMeshClusterClientFactory(),
|
||||
// Resolved lazily, per message, so this actor never depends on the order in
|
||||
// which Akka.Hosting materialises the singleton proxy relative to this block.
|
||||
() => registry.TryGet<ConfigPublishCoordinatorKey>(out var coordinator)
|
||||
|
||||
Reference in New Issue
Block a user