diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/AkkaSiteTransport.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/AkkaSiteTransport.cs
new file mode 100644
index 00000000..8dfcdc65
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/AkkaSiteTransport.cs
@@ -0,0 +1,141 @@
+using System.Collections.Immutable;
+using Akka.Actor;
+using Akka.Cluster.Tools.Client;
+using Akka.Event;
+
+namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
+
+///
+/// The default (and, until the Phase 1B cutover, the shipping) central→site transport: routes each
+/// through a per-site Akka , exactly as
+/// CentralCommunicationActor did inline before the seam was extracted.
+///
+///
+///
+/// Behaviour is identical to the pre-seam code: the per-site client map is (re)built from the DB
+/// refresh cache; a send to a site with no client is warned and dropped (the caller's Ask times
+/// out — central never buffers); a send preserves the reply-to sender so the site's reply routes
+/// straight back to the waiting Ask (or the debug-bridge actor).
+///
+///
+/// Both members run on the owning actor's thread, so the client map needs no synchronisation and
+/// the stored (used for Context.Stop and Context.System)
+/// is only ever touched there.
+///
+///
+public sealed class AkkaSiteTransport : ISiteCommandTransport
+{
+ private readonly ISiteClientFactory _siteClientFactory;
+ private readonly IActorContext _context;
+ private readonly ILoggingAdapter _log;
+
+ ///
+ /// Per-site ClusterClient instances and their contact addresses.
+ /// Maps SiteIdentifier → (ClusterClient actor, set of contact address strings).
+ /// Refreshed by .
+ ///
+ private readonly Dictionary ContactAddresses)> _siteClients = new();
+
+ /// Creates the Akka transport bound to the owning actor's context.
+ /// Factory that creates a ClusterClient per site.
+ /// The owning actor's context (for Stop/System); calls stay on the actor thread.
+ /// The owning actor's logger, so warnings keep the actor's log source.
+ public AkkaSiteTransport(ISiteClientFactory siteClientFactory, IActorContext context, ILoggingAdapter log)
+ {
+ _siteClientFactory = siteClientFactory ?? throw new ArgumentNullException(nameof(siteClientFactory));
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ _log = log ?? throw new ArgumentNullException(nameof(log));
+ }
+
+ ///
+ public void Send(SiteEnvelope envelope, IActorRef replyTo)
+ {
+ ArgumentNullException.ThrowIfNull(envelope);
+
+ if (!_siteClients.TryGetValue(envelope.SiteId, out var entry))
+ {
+ _log.Warning("No ClusterClient for site {0}, cannot route message {1}",
+ envelope.SiteId, envelope.Message.GetType().Name);
+
+ // The Ask will timeout on the caller side — no central buffering
+ return;
+ }
+
+ // Route via ClusterClient — replyTo is preserved for Ask response routing
+ entry.Client.Tell(
+ new ClusterClient.Send("/user/site-communication", envelope.Message),
+ replyTo);
+ }
+
+ ///
+ public void ReconcileSites(SiteAddressCacheLoaded cache)
+ {
+ ArgumentNullException.ThrowIfNull(cache);
+
+ var newSiteIds = cache.SiteContacts.Keys.ToHashSet();
+ var existingSiteIds = _siteClients.Keys.ToHashSet();
+
+ // Stop ClusterClients for removed sites
+ foreach (var removed in existingSiteIds.Except(newSiteIds))
+ {
+ _log.Info("Stopping ClusterClient for removed site {0}", removed);
+ _context.Stop(_siteClients[removed].Client);
+ _siteClients.Remove(removed);
+ }
+
+ // Add or update
+ foreach (var (siteId, addresses) in cache.SiteContacts)
+ {
+ // Parse all addresses up front inside a try/catch so a
+ // single malformed site row cannot abort the whole refresh loop and leave
+ // the cache half-updated. A bad site is logged and skipped; others proceed.
+ ImmutableHashSet contactPaths;
+ try
+ {
+ contactPaths = addresses
+ .Select(a => ActorPath.Parse($"{a}/system/receptionist"))
+ .ToImmutableHashSet();
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(ex,
+ "Malformed contact address for site {0}; skipping this site in the refresh "
+ + "(other sites are unaffected)", siteId);
+ continue;
+ }
+
+ var contactStrings = addresses.ToImmutableHashSet();
+
+ // Skip if unchanged
+ if (_siteClients.TryGetValue(siteId, out var existing) && existing.ContactAddresses.SetEquals(contactStrings))
+ continue;
+
+ // Stop old client if addresses changed
+ if (_siteClients.ContainsKey(siteId))
+ {
+ _log.Info("Updating ClusterClient for site {0} (addresses changed)", siteId);
+ _context.Stop(_siteClients[siteId].Client);
+ // Remove now: if the replacement create below fails, a stale entry
+ // would route envelopes to a stopping actor.
+ _siteClients.Remove(siteId);
+ }
+
+ IActorRef client;
+ try
+ {
+ client = _siteClientFactory.Create(_context.System, siteId, contactPaths);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex,
+ "Failed to create ClusterClient for site {0}; site is unroutable until the next refresh",
+ siteId);
+ continue;
+ }
+ _siteClients[siteId] = (client, contactStrings);
+ _log.Info("Created ClusterClient for site {0} with {1} contact(s)", siteId, addresses.Count);
+ }
+
+ _log.Info("Site ClusterClient cache refreshed with {0} site(s)", _siteClients.Count);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs
index 86a099a4..81adbf55 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs
@@ -4,6 +4,8 @@ using Akka.Cluster.Tools.Client;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
@@ -78,14 +80,15 @@ public class CentralCommunicationActor : ReceiveActor
{
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly IServiceProvider _serviceProvider;
- private readonly ISiteClientFactory _siteClientFactory;
///
- /// Per-site ClusterClient instances and their contact addresses.
- /// Maps SiteIdentifier → (ClusterClient actor, set of contact address strings).
- /// Refreshed periodically via RefreshSiteAddresses.
+ /// The active central→site command transport, chosen by
+ /// ScadaBridge:Communication:SiteTransport (default ).
+ /// The handler delegates every send here, and each DB refresh tick
+ /// reconciles its per-site resources (ClusterClients for Akka, channel pairs for gRPC). Assigned
+ /// in the public constructor body before any message can arrive.
///
- private readonly Dictionary ContactAddresses)> _siteClients = new();
+ private ISiteCommandTransport _transport = null!;
// The previous _debugSubscriptions / _inProgressDeployments
// dictionaries existed solely to support a documented "synchronous kill streams +
@@ -156,9 +159,15 @@ public class CentralCommunicationActor : ReceiveActor
///
private const string HealthReportTopic = "site-health-replica";
- /// Initializes the and wires all message handlers.
+ ///
+ /// Legacy constructor: builds the transport by reading
+ /// ScadaBridge:Communication:SiteTransport and wrapping
+ /// in an (default) or resolving the gRPC transport from
+ /// . Kept so the Host and the existing TestKit suites construct
+ /// the actor exactly as before (the factory is still the Akka seam).
+ ///
/// DI service provider for scoped repository and aggregator access.
- /// Factory used to create per-site ClusterClient actors.
+ /// Factory used to create per-site ClusterClient actors (Akka transport).
///
/// Optional override for the audit-ingest Ask timeout; defaults to
/// (30 s). Exists only so tests can
@@ -168,9 +177,38 @@ public class CentralCommunicationActor : ReceiveActor
IServiceProvider serviceProvider,
ISiteClientFactory siteClientFactory,
TimeSpan? auditIngestAskTimeout = null)
+ : this(serviceProvider, auditIngestAskTimeout)
+ {
+ ArgumentNullException.ThrowIfNull(siteClientFactory);
+ _transport = SelectTransport(serviceProvider, siteClientFactory);
+ }
+
+ ///
+ /// Primary constructor: takes the already-selected directly.
+ /// Used by tests that substitute the transport, and reachable via the legacy constructor.
+ ///
+ /// DI service provider for scoped repository and aggregator access.
+ /// The central→site command transport to route every through.
+ /// Optional override for the audit-ingest Ask timeout (test hook).
+ public CentralCommunicationActor(
+ IServiceProvider serviceProvider,
+ ISiteCommandTransport transport,
+ TimeSpan? auditIngestAskTimeout = null)
+ : this(serviceProvider, auditIngestAskTimeout)
+ {
+ _transport = transport ?? throw new ArgumentNullException(nameof(transport));
+ }
+
+ /// Shared wiring: sets scoped state and registers every message handler. The
+ /// is assigned by the delegating public constructor before any message
+ /// is dispatched.
+ /// DI service provider.
+ /// Optional audit-ingest Ask timeout override.
+ private CentralCommunicationActor(
+ IServiceProvider serviceProvider,
+ TimeSpan? auditIngestAskTimeout)
{
_serviceProvider = serviceProvider;
- _siteClientFactory = siteClientFactory;
_auditIngestAskTimeout = auditIngestAskTimeout ?? Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout;
// Site address cache loaded from database
@@ -451,19 +489,41 @@ public class CentralCommunicationActor : ReceiveActor
private void HandleSiteEnvelope(SiteEnvelope envelope)
{
- if (!_siteClients.TryGetValue(envelope.SiteId, out var entry))
- {
- _log.Warning("No ClusterClient for site {0}, cannot route message {1}",
- envelope.SiteId, envelope.Message.GetType().Name);
+ // Below-the-seam routing: the active transport (Akka ClusterClient or gRPC) owns the
+ // "no route for this site ⇒ warn + drop, caller's Ask times out" contract. Sender is the
+ // temporary Ask actor (or the debug-bridge actor) and is preserved for reply routing.
+ _transport.Send(envelope, Sender);
+ }
- // The Ask will timeout on the caller side — no central buffering
- return;
+ ///
+ /// Chooses the transport from ScadaBridge:Communication:SiteTransport (default
+ /// ). For Akka, wraps the injected
+ /// in an bound to this actor's
+ /// context; for gRPC, resolves the shared and options.
+ /// Runs in the constructor body, where is available.
+ ///
+ /// The DI provider carrying options and (for gRPC) the channel provider.
+ /// The ClusterClient factory used by the Akka transport.
+ /// The selected transport.
+ private ISiteCommandTransport SelectTransport(
+ IServiceProvider serviceProvider, ISiteClientFactory siteClientFactory)
+ {
+ var options = serviceProvider.GetService>()?.Value;
+ var kind = options?.SiteTransport ?? SiteTransportKind.Akka;
+
+ if (kind == SiteTransportKind.Grpc)
+ {
+ var channelProvider = serviceProvider.GetRequiredService();
+ var loggerFactory = serviceProvider.GetRequiredService();
+ _log.Info("central→site command transport: gRPC (SiteCommandService)");
+ return new Grpc.GrpcSiteTransport(
+ channelProvider,
+ options!,
+ loggerFactory.CreateLogger());
}
- // Route via ClusterClient — Sender is preserved for Ask response routing
- entry.Client.Tell(
- new ClusterClient.Send("/user/site-communication", envelope.Message),
- Sender);
+ _log.Info("central→site command transport: Akka ClusterClient");
+ return new AkkaSiteTransport(siteClientFactory, Context, _log);
}
private void LoadSiteAddressesFromDb()
@@ -491,6 +551,10 @@ public class CentralCommunicationActor : ReceiveActor
var sites = await repo.GetAllSitesAsync(ct).ConfigureAwait(false);
var contacts = new Dictionary>();
+ // Parallel gRPC-endpoint cache fed by the SAME DB read (the streaming path's
+ // GrpcNodeA/BAddress columns, NOT the Akka NodeA/BAddress ones). No second poll loop —
+ // the gRPC transport rides this one, and the Akka transport simply ignores the field.
+ var grpcContacts = new Dictionary();
foreach (var site in sites)
{
var addrs = new List();
@@ -511,6 +575,11 @@ public class CentralCommunicationActor : ReceiveActor
}
if (addrs.Count > 0)
contacts[site.SiteIdentifier] = addrs;
+
+ var grpcA = string.IsNullOrWhiteSpace(site.GrpcNodeAAddress) ? null : site.GrpcNodeAAddress;
+ var grpcB = string.IsNullOrWhiteSpace(site.GrpcNodeBAddress) ? null : site.GrpcNodeBAddress;
+ if (grpcA is not null || grpcB is not null)
+ grpcContacts[site.SiteIdentifier] = new SiteGrpcEndpoints(grpcA, grpcB);
}
// Freeze the cross-task payload before piping to
@@ -525,82 +594,20 @@ public class CentralCommunicationActor : ReceiveActor
// address-bearing subset in `frozen`) so the aggregator prunes only
// genuinely-deleted sites and never an addressless-but-configured one.
var knownSiteIds = sites.Select(s => s.SiteIdentifier).ToList();
- return new SiteAddressCacheLoaded(frozen, knownSiteIds);
+ return new SiteAddressCacheLoaded(frozen, knownSiteIds, grpcContacts);
}).PipeTo(self);
}
private void HandleSiteAddressCacheLoaded(SiteAddressCacheLoaded msg)
{
- var newSiteIds = msg.SiteContacts.Keys.ToHashSet();
- var existingSiteIds = _siteClients.Keys.ToHashSet();
-
- // Stop ClusterClients for removed sites
- foreach (var removed in existingSiteIds.Except(newSiteIds))
- {
- _log.Info("Stopping ClusterClient for removed site {0}", removed);
- Context.Stop(_siteClients[removed].Client);
- _siteClients.Remove(removed);
- }
-
- // Add or update
- foreach (var (siteId, addresses) in msg.SiteContacts)
- {
- // Parse all addresses up front inside a try/catch so a
- // single malformed site row cannot abort the whole refresh loop and leave
- // the cache half-updated. A bad site is logged and skipped; others proceed.
- ImmutableHashSet contactPaths;
- try
- {
- contactPaths = addresses
- .Select(a => ActorPath.Parse($"{a}/system/receptionist"))
- .ToImmutableHashSet();
- }
- catch (Exception ex)
- {
- _log.Warning(ex,
- "Malformed contact address for site {0}; skipping this site in the refresh "
- + "(other sites are unaffected)", siteId);
- continue;
- }
-
- var contactStrings = addresses.ToImmutableHashSet();
-
- // Skip if unchanged
- if (_siteClients.TryGetValue(siteId, out var existing) && existing.ContactAddresses.SetEquals(contactStrings))
- continue;
-
- // Stop old client if addresses changed
- if (_siteClients.ContainsKey(siteId))
- {
- _log.Info("Updating ClusterClient for site {0} (addresses changed)", siteId);
- Context.Stop(_siteClients[siteId].Client);
- // Remove now: if the replacement create below fails, a stale entry
- // would route envelopes to a stopping actor.
- _siteClients.Remove(siteId);
- }
-
- IActorRef client;
- try
- {
- client = _siteClientFactory.Create(Context.System, siteId, contactPaths);
- }
- catch (Exception ex)
- {
- _log.Error(ex,
- "Failed to create ClusterClient for site {0}; site is unroutable until the next refresh",
- siteId);
- continue;
- }
- _siteClients[siteId] = (client, contactStrings);
- _log.Info("Created ClusterClient for site {0} with {1} contact(s)", siteId, addresses.Count);
- }
-
- _log.Info("Site ClusterClient cache refreshed with {0} site(s)", _siteClients.Count);
+ // Per-transport per-site resource reconciliation (create/stop ClusterClients, or
+ // build/drop gRPC channel pairs). Runs on the actor thread each refresh tick.
+ _transport.ReconcileSites(msg);
// Self-healing eviction: a site deleted from configuration would otherwise
// linger in the aggregator as a permanently-offline tile (and live KPI
// sample source) forever. Prune on every refresh so it disappears within
- // one refresh interval without needing a dedicated deletion event.
+ // one refresh interval without needing a dedicated deletion event. Transport-agnostic.
_serviceProvider.GetService()?.PruneUnknownSites(msg.KnownSiteIds);
}
@@ -673,8 +680,9 @@ public class CentralCommunicationActor : ReceiveActor
public record RefreshSiteAddresses;
///
-/// Internal message carrying the loaded site contact data from the database.
-/// ClusterClient creation happens on the actor thread in HandleSiteAddressCacheLoaded.
+/// Message carrying the loaded site contact data from the database. Per-transport resource
+/// reconciliation happens on the actor thread in HandleSiteAddressCacheLoaded, which hands this
+/// straight to .
///
/// The payload is exposed as
/// of so the Akka.NET "messages are immutable"
@@ -682,9 +690,24 @@ public record RefreshSiteAddresses;
/// discipline. The producer wraps the constructed buckets with
/// List<T>.AsReadOnly() before piping to Self.
///
-internal record SiteAddressCacheLoaded(
+/// Akka ClusterClient contact addresses per site (from NodeA/NodeBAddress).
+/// Every configured site id, address-bearing or not, for aggregator pruning.
+///
+/// gRPC endpoint pairs per site (from GrpcNodeA/GrpcNodeBAddress) — the streaming path's columns,
+/// consumed by the gRPC transport and ignored by the Akka one.
+///
+public sealed record SiteAddressCacheLoaded(
IReadOnlyDictionary> SiteContacts,
- IReadOnlyCollection KnownSiteIds);
+ IReadOnlyCollection KnownSiteIds,
+ IReadOnlyDictionary GrpcContacts);
+
+///
+/// A site's gRPC node-pair endpoints, as loaded from Site.GrpcNodeAAddress/
+/// GrpcNodeBAddress. Either may be null when only one node has a gRPC address configured.
+///
+/// NodeA gRPC base address (e.g. http://scadabridge-site-a-node-a:8083), or null.
+/// NodeB gRPC base address, or null.
+public readonly record struct SiteGrpcEndpoints(string? NodeA, string? NodeB);
///
/// Peer-replication envelope for a site heartbeat, fanned out over the same
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/ISiteCommandTransport.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/ISiteCommandTransport.cs
new file mode 100644
index 00000000..189c6c4e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/ISiteCommandTransport.cs
@@ -0,0 +1,43 @@
+using Akka.Actor;
+
+namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
+
+///
+/// The central→site command-send seam, injected into
+/// below the handler. Exactly one implementation is active per node,
+/// chosen by ScadaBridge:Communication:SiteTransport:
+///
+/// — today's per-site ClusterClient path (default).
+/// — the site
+/// SiteCommandService gRPC plane.
+///
+/// The producers above the seam (CommunicationService's 27 commands, SiteCallAuditActor's
+/// 2 parked relays, and DebugStreamBridgeActor's subscribe/unsubscribe) are unchanged — they
+/// still Ask/Tell a to the actor, which delegates here.
+///
+///
+/// Both members run on the actor thread (from the and
+/// SiteAddressCacheLoaded handlers), so implementations need no internal synchronisation for
+/// their own per-site bookkeeping beyond what a background failback loop requires.
+///
+public interface ISiteCommandTransport
+{
+ ///
+ /// Routes 's message to its site. Any reply the site produces is
+ /// delivered to — for an Ask that is the temporary ask actor
+ /// (completing the caller's task); for a Tell-with-sender (the debug bridge) that is the
+ /// originating actor. A message with no route (an unknown site) is warned and dropped so the
+ /// caller's Ask times out, exactly as today's ClusterClient path behaves.
+ ///
+ /// The site-addressed command envelope.
+ /// Where a reply (or a ) is delivered.
+ void Send(SiteEnvelope envelope, IActorRef replyTo);
+
+ ///
+ /// Reconciles per-site transport resources (ClusterClients for Akka, channel pairs for gRPC)
+ /// against the freshly loaded site set. Called once per DB refresh tick with the same cache
+ /// message the actor already receives — the ONE DB-poll loop feeds both transports.
+ ///
+ /// The loaded site address cache (Akka contacts + gRPC endpoints + known ids).
+ void ReconcileSites(SiteAddressCacheLoaded cache);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs
index 410b3fda..0ac836e7 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs
@@ -1,6 +1,5 @@
namespace ZB.MOM.WW.ScadaBridge.Communication;
-///
/// Which transport carries the seven site→central control messages. Selected per node by
/// ScadaBridge:Communication:CentralTransport; the migration ships with
/// as the default so nothing flips until a node opts in.
@@ -14,6 +13,21 @@ public enum CentralTransportMode
Grpc = 1,
}
+///
+/// Selects the transport the central→site command plane rides on. The Akka
+/// per-site ClusterClient path is the default until the gRPC cutover
+/// (ClusterClient→gRPC migration, Phase 1B); flipping to is the
+/// rollback-by-flag switch.
+///
+public enum SiteTransportKind
+{
+ /// Route SiteEnvelopes through the per-site Akka ClusterClient (today's default).
+ Akka,
+
+ /// Route SiteEnvelopes over the site SiteCommandService gRPC plane.
+ Grpc
+}
+
///
/// Configuration options for central-site communication, including per-pattern
/// timeouts and transport heartbeat settings.
@@ -39,6 +53,15 @@ public class CommunicationOptions
///
public List CentralGrpcEndpoints { get; set; } = new();
+ ///
+ /// Which transport the central→site command plane uses. Default
+ /// (the per-site ClusterClient path) — flipping to moves
+ /// every SiteEnvelope onto the site SiteCommandService gRPC plane. Selected inside
+ /// CentralCommunicationActor; CommunicationService and SiteCallAuditActor
+ /// are unchanged either way. Rollback at any point = flip this back to Akka.
+ ///
+ public SiteTransportKind SiteTransport { get; set; } = SiteTransportKind.Akka;
+
/// Timeout for deployment commands (typically longest due to apply logic).
public TimeSpan DeploymentTimeout { get; set; } = TimeSpan.FromMinutes(2);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcSiteTransport.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcSiteTransport.cs
new file mode 100644
index 00000000..18bfef06
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcSiteTransport.cs
@@ -0,0 +1,244 @@
+using Akka.Actor;
+using Grpc.Net.Client;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
+using ZB.MOM.WW.ScadaBridge.Communication.Actors;
+
+namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
+
+///
+/// The gRPC central→site command transport: encodes each command with
+/// , dials the site's SiteCommandService through the sticky
+/// (PSK + x-scadabridge-site already on the channel),
+/// and routes the decoded reply back to the waiting Ask (or the debug-bridge actor).
+///
+///
+///
+/// Per-call deadlines match today's Ask timeouts exactly — see .
+/// Behaviour is otherwise unchanged from the Akka path: an unknown/unconfigured site is warned and
+/// dropped (the caller's Ask times out), and a transport fault surfaces to the caller as a
+/// , which the S&F/audit layers already treat as transient.
+///
+///
+/// Cross-node retry is the channel provider's job and happens only on
+/// Unavailable — never on DeadlineExceeded (a write/deploy/failover may have run).
+///
+///
+public sealed class GrpcSiteTransport : ISiteCommandTransport
+{
+ private readonly SitePairChannelProvider _channels;
+ private readonly CommunicationOptions _options;
+ private readonly ILogger _logger;
+
+ // Sites this transport has pushed into the channel provider — used to diff removals per refresh,
+ // the gRPC analogue of the Akka transport's _siteClients key set. Touched only on the actor thread.
+ private readonly HashSet _knownSites = new(StringComparer.Ordinal);
+
+ /// Creates the gRPC transport and ensures the provider's failback loop is running.
+ /// The shared per-site channel-pair provider.
+ /// Communication options supplying the per-command deadlines.
+ /// Logger for drop/fault diagnostics.
+ public GrpcSiteTransport(
+ SitePairChannelProvider channels,
+ CommunicationOptions options,
+ ILogger logger)
+ {
+ _channels = channels ?? throw new ArgumentNullException(nameof(channels));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _channels.EnsureFailbackLoop();
+ }
+
+ ///
+ public void Send(SiteEnvelope envelope, IActorRef replyTo)
+ {
+ ArgumentNullException.ThrowIfNull(envelope);
+
+ // UnsubscribeDebugView is the one fire-and-forget command: the site never acks it over Akka
+ // (the debug bridge Tells and expects nothing), so we run the RPC but do not deliver its
+ // synthetic ack — mirroring today's no-reply behaviour and avoiding a dead-lettered ack.
+ var fireAndForget = envelope.Message is UnsubscribeDebugViewRequest;
+
+ _ = RunAsync(envelope, replyTo, fireAndForget);
+ }
+
+ private async Task RunAsync(SiteEnvelope envelope, IActorRef replyTo, bool fireAndForget)
+ {
+ try
+ {
+ var reply = await SendCoreAsync(envelope, CancellationToken.None).ConfigureAwait(false);
+ if (!fireAndForget && !replyTo.IsNobody())
+ {
+ replyTo.Tell(reply, ActorRefs.NoSender);
+ }
+ }
+ catch (SiteChannelUnavailableException)
+ {
+ // Parity with the Akka "no ClusterClient for site" path: warn and drop, so the caller's
+ // Ask times out. Central never buffers.
+ _logger.LogWarning(
+ "No gRPC channel for site {SiteId}; dropping {Message} (caller's Ask will time out)",
+ envelope.SiteId, envelope.Message.GetType().Name);
+ }
+ catch (Exception ex)
+ {
+ if (!fireAndForget && !replyTo.IsNobody())
+ {
+ // A timeout or non-OK status faults the caller's Ask exactly as an Akka Ask timeout
+ // did — the S&F/audit layers already treat that as transient.
+ replyTo.Tell(new Status.Failure(ex), ActorRefs.NoSender);
+ }
+ else
+ {
+ _logger.LogWarning(ex,
+ "Fire-and-forget {Message} to site {SiteId} faulted",
+ envelope.Message.GetType().Name, envelope.SiteId);
+ }
+ }
+ }
+
+ private Task