feat(comm): T1B.3 — central-side gRPC site-command transport seam (default Akka)
Extract the central→site send path in CentralCommunicationActor behind a new ISiteCommandTransport, selected by ScadaBridge:Communication:SiteTransport (Akka | Grpc, default Akka — rollback = flip the flag). CommunicationService's 27 commands, SiteCallAuditActor's 2 parked relays and DebugStreamBridgeActor's subscribe/unsubscribe are untouched; the seam sits below SiteEnvelope. - AkkaSiteTransport: today's per-site ClusterClient path extracted verbatim (the _siteClients lookup + ClusterClient.Send with the reply-to sender preserved, and the "no client ⇒ warn + drop, caller's Ask times out" path). - GrpcSiteTransport: dials the site SiteCommandService (T1B.1 proto client) via SiteCommandDtoMapper, PSK + x-scadabridge-site on the channel through ControlPlaneCredentials, per-command deadlines set EQUAL to today's CommunicationService Ask timeouts (per-command, not per-group: DeploymentState query and TriggerSiteFailover use QueryTimeout; the two parked relays map to QueryTimeout so SiteCallAudit's inner RelayTimeout 10s < 30s ordering holds; WaitForAttribute keeps its dynamic Timeout + IntegrationTimeout). - SitePairChannelProvider: per-site A/B channel pair with sticky failover (flip only on Unavailable — NEVER on DeadlineExceeded, a write/deploy/failover may have run), background failback probe to the preferred node with 1s→60s doubling backoff, PSK invalidation on site removal. Fed by the SAME DB refresh loop (extended to carry GrpcNodeA/GrpcNodeBAddress) — no second poll. Tests: actor-with-substitute-transport (routing, Ask-reply plumbing, per-site lifecycle across refreshes), ResolveDeadline pinned to each command's current Ask timeout, and GrpcSiteTransport/SitePairChannelProvider over dual in-process TestServers (PSK+header+deadline attached, Unavailable failover + stickiness, failback to preferred, no-retry-on-DeadlineExceeded). Proto csproj untouched (no active <Protobuf> item). Full solution builds 0 warnings; Communication.Tests 607 green with Akka default.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The default (and, until the Phase 1B cutover, the shipping) central→site transport: routes each
|
||||
/// <see cref="SiteEnvelope"/> through a per-site Akka <see cref="ClusterClient"/>, exactly as
|
||||
/// <c>CentralCommunicationActor</c> did inline before the seam was extracted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both members run on the owning actor's thread, so the client map needs no synchronisation and
|
||||
/// the stored <see cref="IActorContext"/> (used for <c>Context.Stop</c> and <c>Context.System</c>)
|
||||
/// is only ever touched there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AkkaSiteTransport : ISiteCommandTransport
|
||||
{
|
||||
private readonly ISiteClientFactory _siteClientFactory;
|
||||
private readonly IActorContext _context;
|
||||
private readonly ILoggingAdapter _log;
|
||||
|
||||
/// <summary>
|
||||
/// Per-site ClusterClient instances and their contact addresses.
|
||||
/// Maps SiteIdentifier → (ClusterClient actor, set of contact address strings).
|
||||
/// Refreshed by <see cref="ReconcileSites"/>.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (IActorRef Client, ImmutableHashSet<string> ContactAddresses)> _siteClients = new();
|
||||
|
||||
/// <summary>Creates the Akka transport bound to the owning actor's context.</summary>
|
||||
/// <param name="siteClientFactory">Factory that creates a ClusterClient per site.</param>
|
||||
/// <param name="context">The owning actor's context (for <c>Stop</c>/<c>System</c>); calls stay on the actor thread.</param>
|
||||
/// <param name="log">The owning actor's logger, so warnings keep the actor's log source.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<ActorPath> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>ScadaBridge:Communication:SiteTransport</c> (default <see cref="SiteTransportKind.Akka"/>).
|
||||
/// The <see cref="SiteEnvelope"/> 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.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (IActorRef Client, ImmutableHashSet<string> 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
|
||||
/// </summary>
|
||||
private const string HealthReportTopic = "site-health-replica";
|
||||
|
||||
/// <summary>Initializes the <see cref="CentralCommunicationActor"/> and wires all message handlers.</summary>
|
||||
/// <summary>
|
||||
/// Legacy constructor: builds the transport by reading
|
||||
/// <c>ScadaBridge:Communication:SiteTransport</c> and wrapping <paramref name="siteClientFactory"/>
|
||||
/// in an <see cref="AkkaSiteTransport"/> (default) or resolving the gRPC transport from
|
||||
/// <paramref name="serviceProvider"/>. Kept so the Host and the existing TestKit suites construct
|
||||
/// the actor exactly as before (the factory is still the Akka seam).
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">DI service provider for scoped repository and aggregator access.</param>
|
||||
/// <param name="siteClientFactory">Factory used to create per-site ClusterClient actors.</param>
|
||||
/// <param name="siteClientFactory">Factory used to create per-site ClusterClient actors (Akka transport).</param>
|
||||
/// <param name="auditIngestAskTimeout">
|
||||
/// Optional override for the audit-ingest Ask timeout; defaults to
|
||||
/// <see cref="Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout"/> (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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Primary constructor: takes the already-selected <see cref="ISiteCommandTransport"/> directly.
|
||||
/// Used by tests that substitute the transport, and reachable via the legacy constructor.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">DI service provider for scoped repository and aggregator access.</param>
|
||||
/// <param name="transport">The central→site command transport to route every <see cref="SiteEnvelope"/> through.</param>
|
||||
/// <param name="auditIngestAskTimeout">Optional override for the audit-ingest Ask timeout (test hook).</param>
|
||||
public CentralCommunicationActor(
|
||||
IServiceProvider serviceProvider,
|
||||
ISiteCommandTransport transport,
|
||||
TimeSpan? auditIngestAskTimeout = null)
|
||||
: this(serviceProvider, auditIngestAskTimeout)
|
||||
{
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
}
|
||||
|
||||
/// <summary>Shared wiring: sets scoped state and registers every message handler. The
|
||||
/// <see cref="_transport"/> is assigned by the delegating public constructor before any message
|
||||
/// is dispatched.</summary>
|
||||
/// <param name="serviceProvider">DI service provider.</param>
|
||||
/// <param name="auditIngestAskTimeout">Optional audit-ingest Ask timeout override.</param>
|
||||
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;
|
||||
/// <summary>
|
||||
/// Chooses the transport from <c>ScadaBridge:Communication:SiteTransport</c> (default
|
||||
/// <see cref="SiteTransportKind.Akka"/>). For Akka, wraps the injected
|
||||
/// <see cref="ISiteClientFactory"/> in an <see cref="AkkaSiteTransport"/> bound to this actor's
|
||||
/// context; for gRPC, resolves the shared <see cref="Grpc.SitePairChannelProvider"/> and options.
|
||||
/// Runs in the constructor body, where <see cref="ActorBase.Context"/> is available.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The DI provider carrying options and (for gRPC) the channel provider.</param>
|
||||
/// <param name="siteClientFactory">The ClusterClient factory used by the Akka transport.</param>
|
||||
/// <returns>The selected transport.</returns>
|
||||
private ISiteCommandTransport SelectTransport(
|
||||
IServiceProvider serviceProvider, ISiteClientFactory siteClientFactory)
|
||||
{
|
||||
var options = serviceProvider.GetService<IOptions<CommunicationOptions>>()?.Value;
|
||||
var kind = options?.SiteTransport ?? SiteTransportKind.Akka;
|
||||
|
||||
if (kind == SiteTransportKind.Grpc)
|
||||
{
|
||||
var channelProvider = serviceProvider.GetRequiredService<Grpc.SitePairChannelProvider>();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
_log.Info("central→site command transport: gRPC (SiteCommandService)");
|
||||
return new Grpc.GrpcSiteTransport(
|
||||
channelProvider,
|
||||
options!,
|
||||
loggerFactory.CreateLogger<Grpc.GrpcSiteTransport>());
|
||||
}
|
||||
|
||||
// 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<string, List<string>>();
|
||||
// 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<string, SiteGrpcEndpoints>();
|
||||
foreach (var site in sites)
|
||||
{
|
||||
var addrs = new List<string>();
|
||||
@@ -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<ActorPath> 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<ICentralHealthAggregator>()?.PruneUnknownSites(msg.KnownSiteIds);
|
||||
}
|
||||
|
||||
@@ -673,8 +680,9 @@ public class CentralCommunicationActor : ReceiveActor
|
||||
public record RefreshSiteAddresses;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ISiteCommandTransport.ReconcileSites"/>.
|
||||
///
|
||||
/// The payload is exposed as <see cref="IReadOnlyDictionary{TKey,TValue}"/>
|
||||
/// of <see cref="IReadOnlyList{T}"/> so the Akka.NET "messages are immutable"
|
||||
@@ -682,9 +690,24 @@ public record RefreshSiteAddresses;
|
||||
/// discipline. The producer wraps the constructed buckets with
|
||||
/// <c>List<T>.AsReadOnly()</c> before piping to Self.
|
||||
/// </summary>
|
||||
internal record SiteAddressCacheLoaded(
|
||||
/// <param name="SiteContacts">Akka ClusterClient contact addresses per site (from NodeA/NodeBAddress).</param>
|
||||
/// <param name="KnownSiteIds">Every configured site id, address-bearing or not, for aggregator pruning.</param>
|
||||
/// <param name="GrpcContacts">
|
||||
/// gRPC endpoint pairs per site (from GrpcNodeA/GrpcNodeBAddress) — the streaming path's columns,
|
||||
/// consumed by the gRPC transport and ignored by the Akka one.
|
||||
/// </param>
|
||||
public sealed record SiteAddressCacheLoaded(
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> SiteContacts,
|
||||
IReadOnlyCollection<string> KnownSiteIds);
|
||||
IReadOnlyCollection<string> KnownSiteIds,
|
||||
IReadOnlyDictionary<string, SiteGrpcEndpoints> GrpcContacts);
|
||||
|
||||
/// <summary>
|
||||
/// A site's gRPC node-pair endpoints, as loaded from <c>Site.GrpcNodeAAddress</c>/
|
||||
/// <c>GrpcNodeBAddress</c>. Either may be null when only one node has a gRPC address configured.
|
||||
/// </summary>
|
||||
/// <param name="NodeA">NodeA gRPC base address (e.g. <c>http://scadabridge-site-a-node-a:8083</c>), or null.</param>
|
||||
/// <param name="NodeB">NodeB gRPC base address, or null.</param>
|
||||
public readonly record struct SiteGrpcEndpoints(string? NodeA, string? NodeB);
|
||||
|
||||
/// <summary>
|
||||
/// Peer-replication envelope for a site heartbeat, fanned out over the same
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Akka.Actor;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// The central→site command-send seam, injected into <see cref="CentralCommunicationActor"/>
|
||||
/// below the <see cref="SiteEnvelope"/> handler. Exactly one implementation is active per node,
|
||||
/// chosen by <c>ScadaBridge:Communication:SiteTransport</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="AkkaSiteTransport"/> — today's per-site <c>ClusterClient</c> path (default).</item>
|
||||
/// <item><see cref="ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcSiteTransport"/> — the site
|
||||
/// <c>SiteCommandService</c> gRPC plane.</item>
|
||||
/// </list>
|
||||
/// The producers above the seam (<c>CommunicationService</c>'s 27 commands, <c>SiteCallAuditActor</c>'s
|
||||
/// 2 parked relays, and <c>DebugStreamBridgeActor</c>'s subscribe/unsubscribe) are unchanged — they
|
||||
/// still <c>Ask</c>/<c>Tell</c> a <see cref="SiteEnvelope"/> to the actor, which delegates here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both members run on the actor thread (from the <see cref="SiteEnvelope"/> and
|
||||
/// <c>SiteAddressCacheLoaded</c> handlers), so implementations need no internal synchronisation for
|
||||
/// their own per-site bookkeeping beyond what a background failback loop requires.
|
||||
/// </remarks>
|
||||
public interface ISiteCommandTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Routes <paramref name="envelope"/>'s message to its site. Any reply the site produces is
|
||||
/// delivered to <paramref name="replyTo"/> — for an <c>Ask</c> that is the temporary ask actor
|
||||
/// (completing the caller's task); for a <c>Tell</c>-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 <c>Ask</c> times out, exactly as today's ClusterClient path behaves.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The site-addressed command envelope.</param>
|
||||
/// <param name="replyTo">Where a reply (or a <see cref="Status.Failure"/>) is delivered.</param>
|
||||
void Send(SiteEnvelope envelope, IActorRef replyTo);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="cache">The loaded site address cache (Akka contacts + gRPC endpoints + known ids).</param>
|
||||
void ReconcileSites(SiteAddressCacheLoaded cache);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// Which transport carries the seven site→central control messages. Selected per node by
|
||||
/// <c>ScadaBridge:Communication:CentralTransport</c>; the migration ships with
|
||||
/// <see cref="Akka"/> as the default so nothing flips until a node opts in.
|
||||
@@ -14,6 +13,21 @@ public enum CentralTransportMode
|
||||
Grpc = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the transport the central→site command plane rides on. The Akka
|
||||
/// per-site <c>ClusterClient</c> path is the default until the gRPC cutover
|
||||
/// (ClusterClient→gRPC migration, Phase 1B); flipping to <see cref="Grpc"/> is the
|
||||
/// rollback-by-flag switch.
|
||||
/// </summary>
|
||||
public enum SiteTransportKind
|
||||
{
|
||||
/// <summary>Route <c>SiteEnvelope</c>s through the per-site Akka <c>ClusterClient</c> (today's default).</summary>
|
||||
Akka,
|
||||
|
||||
/// <summary>Route <c>SiteEnvelope</c>s over the site <c>SiteCommandService</c> gRPC plane.</summary>
|
||||
Grpc
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for central-site communication, including per-pattern
|
||||
/// timeouts and transport heartbeat settings.
|
||||
@@ -39,6 +53,15 @@ public class CommunicationOptions
|
||||
/// </remarks>
|
||||
public List<string> CentralGrpcEndpoints { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Which transport the central→site command plane uses. Default <see cref="SiteTransportKind.Akka"/>
|
||||
/// (the per-site ClusterClient path) — flipping to <see cref="SiteTransportKind.Grpc"/> moves
|
||||
/// every <c>SiteEnvelope</c> onto the site <c>SiteCommandService</c> gRPC plane. Selected inside
|
||||
/// <c>CentralCommunicationActor</c>; <c>CommunicationService</c> and <c>SiteCallAuditActor</c>
|
||||
/// are unchanged either way. Rollback at any point = flip this back to <c>Akka</c>.
|
||||
/// </summary>
|
||||
public SiteTransportKind SiteTransport { get; set; } = SiteTransportKind.Akka;
|
||||
|
||||
/// <summary>Timeout for deployment commands (typically longest due to apply logic).</summary>
|
||||
public TimeSpan DeploymentTimeout { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The gRPC central→site command transport: encodes each <see cref="SiteEnvelope"/> command with
|
||||
/// <see cref="SiteCommandDtoMapper"/>, dials the site's <c>SiteCommandService</c> through the sticky
|
||||
/// <see cref="SitePairChannelProvider"/> (PSK + <c>x-scadabridge-site</c> already on the channel),
|
||||
/// and routes the decoded reply back to the waiting <c>Ask</c> (or the debug-bridge actor).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per-call deadlines match today's Ask timeouts exactly</b> — see <see cref="ResolveDeadline"/>.
|
||||
/// 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
|
||||
/// <see cref="Status.Failure"/>, which the S&F/audit layers already treat as transient.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cross-node retry is the channel provider's job</b> and happens only on
|
||||
/// <c>Unavailable</c> — never on <c>DeadlineExceeded</c> (a write/deploy/failover may have run).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GrpcSiteTransport : ISiteCommandTransport
|
||||
{
|
||||
private readonly SitePairChannelProvider _channels;
|
||||
private readonly CommunicationOptions _options;
|
||||
private readonly ILogger<GrpcSiteTransport> _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<string> _knownSites = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Creates the gRPC transport and ensures the provider's failback loop is running.</summary>
|
||||
/// <param name="channels">The shared per-site channel-pair provider.</param>
|
||||
/// <param name="options">Communication options supplying the per-command deadlines.</param>
|
||||
/// <param name="logger">Logger for drop/fault diagnostics.</param>
|
||||
public GrpcSiteTransport(
|
||||
SitePairChannelProvider channels,
|
||||
CommunicationOptions options,
|
||||
ILogger<GrpcSiteTransport> logger)
|
||||
{
|
||||
_channels = channels ?? throw new ArgumentNullException(nameof(channels));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_channels.EnsureFailbackLoop();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<object> SendCoreAsync(SiteEnvelope envelope, CancellationToken ct)
|
||||
{
|
||||
var message = envelope.Message;
|
||||
|
||||
// Compute the absolute deadline ONCE so a cross-node failover retry shares the overall
|
||||
// budget rather than restarting it.
|
||||
var deadline = DateTime.UtcNow + ResolveDeadline(message);
|
||||
var group = SiteCommandDtoMapper.GroupOf(message);
|
||||
|
||||
return _channels.ExecuteAsync(
|
||||
envelope.SiteId,
|
||||
(channel, callCt) => InvokeAsync(channel, group, message, deadline, callCt),
|
||||
ct);
|
||||
}
|
||||
|
||||
private static async Task<object> InvokeAsync(
|
||||
GrpcChannel channel, SiteCommandGroup group, object message, DateTime deadline, CancellationToken ct)
|
||||
{
|
||||
var client = new SiteCommandService.SiteCommandServiceClient(channel);
|
||||
|
||||
switch (group)
|
||||
{
|
||||
case SiteCommandGroup.Lifecycle:
|
||||
{
|
||||
var reply = await client.ExecuteLifecycleAsync(
|
||||
SiteCommandDtoMapper.ToLifecycleRequest(message),
|
||||
deadline: deadline, cancellationToken: ct);
|
||||
return SiteCommandDtoMapper.FromLifecycleReply(reply);
|
||||
}
|
||||
|
||||
case SiteCommandGroup.OpcUa:
|
||||
{
|
||||
var reply = await client.ExecuteOpcUaAsync(
|
||||
SiteCommandDtoMapper.ToOpcUaRequest(message),
|
||||
deadline: deadline, cancellationToken: ct);
|
||||
return SiteCommandDtoMapper.FromOpcUaReply(reply);
|
||||
}
|
||||
|
||||
case SiteCommandGroup.Query:
|
||||
{
|
||||
var reply = await client.ExecuteQueryAsync(
|
||||
SiteCommandDtoMapper.ToQueryRequest(message),
|
||||
deadline: deadline, cancellationToken: ct);
|
||||
return SiteCommandDtoMapper.FromQueryReply(reply);
|
||||
}
|
||||
|
||||
case SiteCommandGroup.Parked:
|
||||
{
|
||||
var reply = await client.ExecuteParkedAsync(
|
||||
SiteCommandDtoMapper.ToParkedRequest(message),
|
||||
deadline: deadline, cancellationToken: ct);
|
||||
return SiteCommandDtoMapper.FromParkedReply(reply);
|
||||
}
|
||||
|
||||
case SiteCommandGroup.Route:
|
||||
{
|
||||
var reply = await client.ExecuteRouteAsync(
|
||||
SiteCommandDtoMapper.ToRouteRequest(message),
|
||||
deadline: deadline, cancellationToken: ct);
|
||||
return SiteCommandDtoMapper.FromRouteReply(reply);
|
||||
}
|
||||
|
||||
case SiteCommandGroup.Failover:
|
||||
{
|
||||
var ack = await client.TriggerFailoverAsync(
|
||||
SiteCommandDtoMapper.ToProto((TriggerSiteFailover)message),
|
||||
deadline: deadline, cancellationToken: ct);
|
||||
return SiteCommandDtoMapper.FromProto(ack);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(group), group, "Unknown site command group.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-command deadline, set EQUAL to the Ask timeout <c>CommunicationService</c> uses for
|
||||
/// that command today, so flipping the transport changes nothing about how long a call waits.
|
||||
/// Note the group is NOT a uniform deadline class: within Lifecycle, <c>DeploymentStateQuery</c>
|
||||
/// uses <c>QueryTimeout</c> (not <c>LifecycleTimeout</c>), and <c>TriggerSiteFailover</c> uses
|
||||
/// <c>QueryTimeout</c> (not <c>LifecycleTimeout</c>) — matching the real Ask sites, not the
|
||||
/// plan's per-group table. The two parked relays (<c>RetryParkedOperation</c>/
|
||||
/// <c>DiscardParkedOperation</c>) map to <c>QueryTimeout</c> (30s), preserving the
|
||||
/// <c>SiteCallAuditActor</c> inner <c>RelayTimeout</c> (10s) < 30s ordering. WaitForAttribute
|
||||
/// keeps its dynamic <c>request.Timeout + IntegrationTimeout</c> budget.
|
||||
/// </summary>
|
||||
/// <param name="message">The command being sent.</param>
|
||||
/// <returns>The deadline duration for that command.</returns>
|
||||
internal TimeSpan ResolveDeadline(object message) => message switch
|
||||
{
|
||||
RefreshDeploymentCommand => _options.DeploymentTimeout,
|
||||
EnableInstanceCommand or DisableInstanceCommand or DeleteInstanceCommand => _options.LifecycleTimeout,
|
||||
DeploymentStateQueryRequest => _options.QueryTimeout,
|
||||
DeployArtifactsCommand => _options.ArtifactDeploymentTimeout,
|
||||
|
||||
BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand or VerifyEndpointCommand
|
||||
or TrustServerCertCommand or ListServerCertsCommand or RemoveServerCertCommand or WriteTagRequest
|
||||
=> _options.QueryTimeout,
|
||||
|
||||
EventLogQueryRequest or DebugSnapshotRequest => _options.QueryTimeout,
|
||||
SubscribeDebugViewRequest or UnsubscribeDebugViewRequest => _options.DebugViewTimeout,
|
||||
|
||||
ParkedMessageQueryRequest or ParkedMessageRetryRequest or ParkedMessageDiscardRequest
|
||||
or RetryParkedOperation or DiscardParkedOperation
|
||||
=> _options.QueryTimeout,
|
||||
|
||||
RouteToCallRequest or RouteToGetAttributesRequest or RouteToSetAttributesRequest
|
||||
=> _options.IntegrationTimeout,
|
||||
RouteToWaitForAttributeRequest r => r.Timeout + _options.IntegrationTimeout,
|
||||
|
||||
TriggerSiteFailover => _options.QueryTimeout,
|
||||
|
||||
_ => throw new ArgumentException(
|
||||
$"'{message.GetType().Name}' is not a migrated site command.", nameof(message))
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReconcileSites(SiteAddressCacheLoaded cache)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
|
||||
var desired = cache.GrpcContacts;
|
||||
|
||||
// Drop sites that lost their gRPC endpoints (or were deleted).
|
||||
foreach (var removed in _knownSites.Where(s => !desired.ContainsKey(s)).ToList())
|
||||
{
|
||||
_channels.RemoveSite(removed);
|
||||
_knownSites.Remove(removed);
|
||||
}
|
||||
|
||||
// Create/refresh the rest.
|
||||
foreach (var (siteId, endpoints) in desired)
|
||||
{
|
||||
_channels.UpdateSite(siteId, endpoints.NodeA, endpoints.NodeB);
|
||||
_knownSites.Add(siteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a site has no usable gRPC channel — an unknown site, or a site with neither
|
||||
/// <c>GrpcNodeAAddress</c> nor <c>GrpcNodeBAddress</c> configured. The gRPC transport treats this
|
||||
/// the way the Akka path treats "no ClusterClient for site": warn and drop, so the caller's Ask
|
||||
/// times out (central never buffers).
|
||||
/// </summary>
|
||||
public sealed class SiteChannelUnavailableException(string siteId)
|
||||
: Exception($"No gRPC channel is configured for site '{siteId}'.")
|
||||
{
|
||||
/// <summary>The site with no configured channel.</summary>
|
||||
public string SiteId { get; } = siteId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared, per-site gRPC channel PAIR provider with sticky failover + failback (design §3.5).
|
||||
/// Central holds one <see cref="GrpcChannel"/> per site node (NodeA/NodeB) rather than one channel
|
||||
/// with re-dial logic; NodeA is the preferred node (config order). Every call goes to the current
|
||||
/// sticky channel; on connect failure / <see cref="StatusCode.Unavailable"/> / readiness rejection
|
||||
/// it flips to the other node and stays there. A background probe returns to the preferred node
|
||||
/// once it answers again. Reconnect probing backs off 1 s → doubling → 60 s cap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Retry safety.</b> <see cref="ExecuteAsync{T}"/> retries on the other node ONLY when the first
|
||||
/// attempt failed with <see cref="StatusCode.Unavailable"/> (provably never reached a server —
|
||||
/// connect refused or readiness-rejected before response headers). A
|
||||
/// <see cref="StatusCode.DeadlineExceeded"/>, or any other status, is rethrown WITHOUT a cross-node
|
||||
/// retry: a <c>WriteTag</c>/<c>DeployArtifacts</c>/<c>TriggerSiteFailover</c> may already have
|
||||
/// executed, and the layers above tolerate the one-shot ambiguity exactly as ClusterClient's Ask
|
||||
/// did — the migration must not turn one write into two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Addresses.</b> Fed by <see cref="UpdateSite"/>/<see cref="RemoveSite"/> from the single DB
|
||||
/// refresh loop (the <c>GrpcNodeAAddress</c>/<c>GrpcNodeBAddress</c> columns). PSK credentials and
|
||||
/// the <c>x-scadabridge-site</c> header ride every channel via
|
||||
/// <see cref="ControlPlaneCredentials.WithSiteCredentials"/>; removing a site invalidates its
|
||||
/// cached key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SitePairChannelProvider : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan InitialBackoff = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan MaxBackoff = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan FailbackSweepInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly ISitePskProvider _pskProvider;
|
||||
private readonly CommunicationOptions _options;
|
||||
private readonly ILogger<SitePairChannelProvider> _logger;
|
||||
private readonly Func<string, HttpMessageHandler>? _handlerFactory;
|
||||
private readonly Func<GrpcChannel, CancellationToken, Task<bool>> _reachabilityProbe;
|
||||
|
||||
private readonly ConcurrentDictionary<string, SitePair> _sites = new(StringComparer.Ordinal);
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private Task? _failbackLoop;
|
||||
private readonly object _loopGate = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Creates the provider.</summary>
|
||||
/// <param name="pskProvider">Resolves each site's preshared key for channel credentials.</param>
|
||||
/// <param name="options">Communication options (keepalive settings applied to production channels).</param>
|
||||
/// <param name="logger">Logger for failover/failback diagnostics.</param>
|
||||
/// <param name="handlerFactory">
|
||||
/// Test seam mapping an endpoint to the <see cref="HttpMessageHandler"/> its channel should use
|
||||
/// (e.g. an in-process <c>TestServer</c> handler). Null in production, where each channel builds
|
||||
/// a keepalive-configured <see cref="SocketsHttpHandler"/>.
|
||||
/// </param>
|
||||
/// <param name="reachabilityProbe">
|
||||
/// Test seam deciding whether a preferred node is reachable during a failback sweep. Null in
|
||||
/// production, where <see cref="GrpcChannel.ConnectAsync"/> is used.
|
||||
/// </param>
|
||||
public SitePairChannelProvider(
|
||||
ISitePskProvider pskProvider,
|
||||
IOptions<CommunicationOptions> options,
|
||||
ILogger<SitePairChannelProvider> logger,
|
||||
Func<string, HttpMessageHandler>? handlerFactory = null,
|
||||
Func<GrpcChannel, CancellationToken, Task<bool>>? reachabilityProbe = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pskProvider);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_pskProvider = pskProvider;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_handlerFactory = handlerFactory;
|
||||
_reachabilityProbe = reachabilityProbe ?? DefaultReachabilityProbe;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Re)builds a site's channel pair from its gRPC node endpoints. A no-op when neither endpoint
|
||||
/// changed. Disposes and rebuilds a channel whose endpoint changed, and resets stickiness to the
|
||||
/// preferred node when the pair is (re)created. Called on the DB refresh tick.
|
||||
/// </summary>
|
||||
/// <param name="siteId">Site identifier.</param>
|
||||
/// <param name="nodeAEndpoint">NodeA gRPC base address, or null when unconfigured.</param>
|
||||
/// <param name="nodeBEndpoint">NodeB gRPC base address, or null when unconfigured.</param>
|
||||
public void UpdateSite(string siteId, string? nodeAEndpoint, string? nodeBEndpoint)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
|
||||
var pair = _sites.GetOrAdd(siteId, id => new SitePair(id));
|
||||
lock (pair.Gate)
|
||||
{
|
||||
var changedA = !string.Equals(pair.NodeAEndpoint, nodeAEndpoint, StringComparison.Ordinal);
|
||||
var changedB = !string.Equals(pair.NodeBEndpoint, nodeBEndpoint, StringComparison.Ordinal);
|
||||
if (!changedA && !changedB)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedA)
|
||||
{
|
||||
pair.ChannelA?.Dispose();
|
||||
pair.ChannelA = string.IsNullOrWhiteSpace(nodeAEndpoint) ? null : BuildChannel(nodeAEndpoint, siteId);
|
||||
pair.NodeAEndpoint = nodeAEndpoint;
|
||||
}
|
||||
if (changedB)
|
||||
{
|
||||
pair.ChannelB?.Dispose();
|
||||
pair.ChannelB = string.IsNullOrWhiteSpace(nodeBEndpoint) ? null : BuildChannel(nodeBEndpoint, siteId);
|
||||
pair.NodeBEndpoint = nodeBEndpoint;
|
||||
}
|
||||
|
||||
// Addresses changed — return to the preferred node and reset failback backoff.
|
||||
pair.CurrentIsA = pair.ChannelA is not null;
|
||||
pair.ResetFailback();
|
||||
_logger.LogInformation(
|
||||
"gRPC channel pair for site {SiteId} refreshed (nodeA={HasA}, nodeB={HasB})",
|
||||
siteId, pair.ChannelA is not null, pair.ChannelB is not null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes a removed site's channels and invalidates its cached preshared key.</summary>
|
||||
/// <param name="siteId">Site identifier to remove.</param>
|
||||
public void RemoveSite(string siteId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(siteId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sites.TryRemove(siteId, out var pair))
|
||||
{
|
||||
lock (pair.Gate)
|
||||
{
|
||||
pair.ChannelA?.Dispose();
|
||||
pair.ChannelB?.Dispose();
|
||||
pair.ChannelA = null;
|
||||
pair.ChannelB = null;
|
||||
}
|
||||
_pskProvider.Invalidate(siteId);
|
||||
_logger.LogInformation("gRPC channel pair for site {SiteId} removed", siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="call"/> against the site's current sticky channel, failing over to the
|
||||
/// other node once on <see cref="StatusCode.Unavailable"/> (never on
|
||||
/// <see cref="StatusCode.DeadlineExceeded"/>). Ensures the background failback loop is running.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The RPC reply type.</typeparam>
|
||||
/// <param name="siteId">Target site.</param>
|
||||
/// <param name="call">The RPC to run against a given channel.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The RPC reply.</returns>
|
||||
/// <exception cref="SiteChannelUnavailableException">The site has no configured channel.</exception>
|
||||
public async Task<T> ExecuteAsync<T>(
|
||||
string siteId, Func<GrpcChannel, CancellationToken, Task<T>> call, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
ArgumentNullException.ThrowIfNull(call);
|
||||
EnsureFailbackLoop();
|
||||
|
||||
if (!_sites.TryGetValue(siteId, out var pair))
|
||||
{
|
||||
throw new SiteChannelUnavailableException(siteId);
|
||||
}
|
||||
|
||||
GrpcChannel primary;
|
||||
GrpcChannel? secondary;
|
||||
bool primaryIsA;
|
||||
lock (pair.Gate)
|
||||
{
|
||||
primaryIsA = pair.CurrentIsA;
|
||||
primary = (primaryIsA ? pair.ChannelA : pair.ChannelB)
|
||||
?? pair.ChannelA ?? pair.ChannelB
|
||||
?? throw new SiteChannelUnavailableException(siteId);
|
||||
// Recompute which node `primary` actually is (the current side may be null).
|
||||
primaryIsA = ReferenceEquals(primary, pair.ChannelA);
|
||||
secondary = primaryIsA ? pair.ChannelB : pair.ChannelA;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await call(primary, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable && secondary is not null)
|
||||
{
|
||||
// Provably never reached a server → safe to try the other node, even for a write.
|
||||
_logger.LogWarning(
|
||||
"Site {SiteId} node {FromNode} unavailable; failing over to node {ToNode}",
|
||||
siteId, primaryIsA ? "A" : "B", primaryIsA ? "B" : "A");
|
||||
FlipTo(pair, toIsA: !primaryIsA);
|
||||
return await call(secondary, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probes a site's preferred node (NodeA) and, if reachable, returns stickiness to it. The
|
||||
/// background loop calls this per due site; tests call it to drive failback deterministically.
|
||||
/// </summary>
|
||||
/// <param name="siteId">Site identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True when the site is now (or already) pointed at its preferred node.</returns>
|
||||
internal async Task<bool> TryFailbackAsync(string siteId, CancellationToken ct)
|
||||
{
|
||||
if (!_sites.TryGetValue(siteId, out var pair))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
GrpcChannel? preferred;
|
||||
lock (pair.Gate)
|
||||
{
|
||||
if (pair.CurrentIsA || pair.ChannelA is null)
|
||||
{
|
||||
return true; // already on preferred (or no preferred channel to fail back to)
|
||||
}
|
||||
preferred = pair.ChannelA;
|
||||
}
|
||||
|
||||
var reachable = await _reachabilityProbe(preferred, ct).ConfigureAwait(false);
|
||||
lock (pair.Gate)
|
||||
{
|
||||
if (reachable)
|
||||
{
|
||||
pair.CurrentIsA = true;
|
||||
pair.ResetFailback();
|
||||
_logger.LogInformation("Site {SiteId} preferred node A reachable again; failing back", siteId);
|
||||
return true;
|
||||
}
|
||||
|
||||
pair.BumpFailbackBackoff();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test/diagnostic accessor: true when the site currently targets its preferred node (A).</summary>
|
||||
/// <param name="siteId">Site identifier.</param>
|
||||
/// <returns>True when on NodeA (or when the site is unknown).</returns>
|
||||
internal bool IsOnPreferredNode(string siteId)
|
||||
=> !_sites.TryGetValue(siteId, out var pair) || pair.CurrentIsA;
|
||||
|
||||
/// <summary>Starts the background failback loop if not already running. Idempotent.</summary>
|
||||
public void EnsureFailbackLoop()
|
||||
{
|
||||
if (_failbackLoop is not null || _disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock (_loopGate)
|
||||
{
|
||||
if (_failbackLoop is null && !_disposed)
|
||||
{
|
||||
_failbackLoop = Task.Run(() => FailbackLoopAsync(_shutdown.Token));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FailbackLoopAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(FailbackSweepInterval);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var kvp in _sites)
|
||||
{
|
||||
var pair = kvp.Value;
|
||||
bool due;
|
||||
lock (pair.Gate)
|
||||
{
|
||||
due = !pair.CurrentIsA && pair.ChannelA is not null && now >= pair.NextProbeUtc;
|
||||
}
|
||||
if (due)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TryFailbackAsync(kvp.Key, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failback probe for site {SiteId} faulted", kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutting down.
|
||||
}
|
||||
}
|
||||
|
||||
private void FlipTo(SitePair pair, bool toIsA)
|
||||
{
|
||||
lock (pair.Gate)
|
||||
{
|
||||
pair.CurrentIsA = toIsA;
|
||||
if (!toIsA)
|
||||
{
|
||||
// Failed over off the preferred node → schedule the first failback probe.
|
||||
pair.ScheduleFailback(InitialBackoff);
|
||||
}
|
||||
else
|
||||
{
|
||||
pair.ResetFailback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GrpcChannel BuildChannel(string endpoint, string siteId)
|
||||
{
|
||||
var channelOptions = new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = _handlerFactory is not null
|
||||
? _handlerFactory(endpoint)
|
||||
: new SocketsHttpHandler
|
||||
{
|
||||
KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
|
||||
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
EnableMultipleHttp2Connections = true
|
||||
}
|
||||
}.WithSiteCredentials(_pskProvider, siteId);
|
||||
|
||||
return GrpcChannel.ForAddress(endpoint, channelOptions);
|
||||
}
|
||||
|
||||
private static async Task<bool> DefaultReachabilityProbe(GrpcChannel channel, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
await channel.ConnectAsync(timeout.Token).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
|
||||
_shutdown.Cancel();
|
||||
try
|
||||
{
|
||||
_failbackLoop?.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort loop drain on shutdown.
|
||||
}
|
||||
_shutdown.Dispose();
|
||||
|
||||
foreach (var pair in _sites.Values)
|
||||
{
|
||||
lock (pair.Gate)
|
||||
{
|
||||
pair.ChannelA?.Dispose();
|
||||
pair.ChannelB?.Dispose();
|
||||
}
|
||||
}
|
||||
_sites.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Per-site mutable channel state, guarded by <see cref="Gate"/>.</summary>
|
||||
private sealed class SitePair(string siteId)
|
||||
{
|
||||
public string SiteId { get; } = siteId;
|
||||
public readonly object Gate = new();
|
||||
public string? NodeAEndpoint;
|
||||
public string? NodeBEndpoint;
|
||||
public GrpcChannel? ChannelA;
|
||||
public GrpcChannel? ChannelB;
|
||||
|
||||
/// <summary>Sticky current node; NodeA is preferred. True = pointing at NodeA.</summary>
|
||||
public bool CurrentIsA = true;
|
||||
|
||||
/// <summary>Next time a failback probe is due (while failed over). MaxValue = not scheduled.</summary>
|
||||
public DateTime NextProbeUtc = DateTime.MaxValue;
|
||||
|
||||
private TimeSpan _backoff = InitialBackoff;
|
||||
|
||||
public void ScheduleFailback(TimeSpan delay)
|
||||
{
|
||||
_backoff = delay;
|
||||
NextProbeUtc = DateTime.UtcNow + _backoff;
|
||||
}
|
||||
|
||||
public void BumpFailbackBackoff()
|
||||
{
|
||||
var next = TimeSpan.FromTicks(Math.Min(_backoff.Ticks * 2, MaxBackoff.Ticks));
|
||||
_backoff = next;
|
||||
NextProbeUtc = DateTime.UtcNow + _backoff;
|
||||
}
|
||||
|
||||
public void ResetFailback()
|
||||
{
|
||||
_backoff = InitialBackoff;
|
||||
NextProbeUtc = DateTime.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.Communication.Tests" />
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.IntegrationTests" />
|
||||
<!-- GrpcSiteTransport failover/failback TestServer coverage (T1B.3) lives in Host.Tests,
|
||||
which owns the ASP.NET TestHost stack; it drives the provider's internal failback seams. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.Host.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -167,6 +167,14 @@ try
|
||||
});
|
||||
builder.Services.AddSingleton<
|
||||
ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlGrpcService>();
|
||||
|
||||
// Shared per-site gRPC channel-pair provider (sticky failover/failback) backing the
|
||||
// GrpcSiteTransport when ScadaBridge:Communication:SiteTransport=Grpc. Central-only, and a
|
||||
// no-op until CentralCommunicationActor builds the gRPC transport (its failback loop starts
|
||||
// on first use), so registering it unconditionally is harmless under the default Akka path.
|
||||
builder.Services.AddSingleton<
|
||||
ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitePairChannelProvider>();
|
||||
|
||||
builder.Services.AddHealthMonitoring();
|
||||
builder.Services.AddCentralHealthAggregation();
|
||||
builder.Services.AddExternalSystemGateway();
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ public class CentralCommunicationActorClientLifecycleTests : TestKit
|
||||
private static SiteAddressCacheLoaded Load(string siteId, params string[] addrs) =>
|
||||
new(new Dictionary<string, IReadOnlyList<string>>
|
||||
{ [siteId] = addrs.ToList().AsReadOnly() },
|
||||
new[] { siteId });
|
||||
new[] { siteId },
|
||||
new Dictionary<string, SiteGrpcEndpoints>());
|
||||
|
||||
[Fact]
|
||||
public void PeriodicRefresh_PrunesDeletedSites_FromHealthAggregator()
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// T1B.3 seam tests: <see cref="CentralCommunicationActor"/> routing a <see cref="SiteEnvelope"/>
|
||||
/// through an injected <see cref="ISiteCommandTransport"/> (transport-agnostic), proving the
|
||||
/// envelope reaches the transport, the reply routes back to the waiting Ask, and the DB refresh
|
||||
/// tick reconciles per-site transport resources.
|
||||
/// </summary>
|
||||
public class CentralCommunicationActorTransportTests : TestKit
|
||||
{
|
||||
public CentralCommunicationActorTransportTests() : base(@"akka.loglevel = WARNING") { }
|
||||
|
||||
private static Site GrpcSite(string id, string? grpcA = "http://a:8083", string? grpcB = "http://b:8083") =>
|
||||
new("Test " + id, id)
|
||||
{
|
||||
NodeAAddress = $"akka.tcp://scadabridge@{id}-a:8081",
|
||||
GrpcNodeAAddress = grpcA,
|
||||
GrpcNodeBAddress = grpcB
|
||||
};
|
||||
|
||||
private (IActorRef actor, ISiteCommandTransport transport, ISiteRepository repo) CreateActor(
|
||||
IEnumerable<Site>? sites = null)
|
||||
{
|
||||
var repo = Substitute.For<ISiteRepository>();
|
||||
repo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites?.ToList() ?? new List<Site>());
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => repo);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var transport = Substitute.For<ISiteCommandTransport>();
|
||||
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null)));
|
||||
return (actor, transport, repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteEnvelope_IsRoutedToTheTransport_WithTheSenderAsReplyTo()
|
||||
{
|
||||
var (actor, transport, _) = CreateActor();
|
||||
var probe = CreateTestProbe();
|
||||
|
||||
var cmd = new EnableInstanceCommand("cmd-1", "Site1.Pump1", DateTimeOffset.UtcNow);
|
||||
actor.Tell(new SiteEnvelope("site-a", cmd), probe.Ref);
|
||||
|
||||
AwaitAssert(() => transport.Received(1).Send(
|
||||
Arg.Is<SiteEnvelope>(e => e.SiteId == "site-a" && ReferenceEquals(e.Message, cmd)),
|
||||
probe.Ref));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AskReply_FromTransport_RoutesBackToTheWaitingAsk()
|
||||
{
|
||||
var (actor, transport, _) = CreateActor();
|
||||
|
||||
// The transport substitute stands in for the site: when handed the envelope it delivers a
|
||||
// reply to the captured replyTo, exactly as a real transport pipes the site's reply back.
|
||||
var reply = new DeploymentStatusResponse(
|
||||
"dep-1", "Site1.Pump1", DeploymentStatus.Success, null, DateTimeOffset.UtcNow);
|
||||
transport
|
||||
.When(t => t.Send(Arg.Any<SiteEnvelope>(), Arg.Any<IActorRef>()))
|
||||
.Do(ci => ci.Arg<IActorRef>().Tell(reply));
|
||||
|
||||
var cmd = new RefreshDeploymentCommand(
|
||||
"dep-1", "Site1.Pump1", "hash", "multi-role", DateTimeOffset.UtcNow, "http://c:5000", "tok");
|
||||
var result = await actor.Ask<DeploymentStatusResponse>(
|
||||
new SiteEnvelope("site-a", cmd), TimeSpan.FromSeconds(3));
|
||||
|
||||
Assert.Equal("dep-1", result.DeploymentId);
|
||||
Assert.Equal(DeploymentStatus.Success, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DbRefresh_ReconcilesTheTransport_WithTheLoadedGrpcEndpoints()
|
||||
{
|
||||
var (_, transport, _) = CreateActor(new[] { GrpcSite("site-a") });
|
||||
|
||||
// PreStart fires the refresh at Zero; the loaded cache is handed to the transport.
|
||||
AwaitAssert(() => transport.Received().ReconcileSites(
|
||||
Arg.Is<SiteAddressCacheLoaded>(c =>
|
||||
c.KnownSiteIds.Contains("site-a")
|
||||
&& c.GrpcContacts.ContainsKey("site-a")
|
||||
&& c.GrpcContacts["site-a"].NodeA == "http://a:8083"
|
||||
&& c.GrpcContacts["site-a"].NodeB == "http://b:8083")),
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteAddedAndRemoved_AcrossRefreshes_FlowsThroughToTheTransport()
|
||||
{
|
||||
var (actor, transport, repo) = CreateActor(new[] { GrpcSite("site-a") });
|
||||
|
||||
AwaitAssert(() => transport.Received().ReconcileSites(
|
||||
Arg.Is<SiteAddressCacheLoaded>(c => c.GrpcContacts.ContainsKey("site-a"))),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// site-a removed, site-b added.
|
||||
repo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { GrpcSite("site-b") });
|
||||
actor.Tell(new RefreshSiteAddresses());
|
||||
|
||||
AwaitAssert(() => transport.Received().ReconcileSites(
|
||||
Arg.Is<SiteAddressCacheLoaded>(c =>
|
||||
c.GrpcContacts.ContainsKey("site-b")
|
||||
&& !c.GrpcContacts.ContainsKey("site-a")
|
||||
&& c.KnownSiteIds.Contains("site-b")
|
||||
&& !c.KnownSiteIds.Contains("site-a"))),
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteWithoutGrpcAddresses_IsAbsentFromGrpcContacts_ButStillKnown()
|
||||
{
|
||||
// A site with only Akka addresses (no gRPC columns) is a known site but carries no gRPC
|
||||
// endpoint — the gRPC transport must not try to dial it, the Akka one still can.
|
||||
var akkaOnly = new Site("Akka only", "site-x")
|
||||
{
|
||||
NodeAAddress = "akka.tcp://scadabridge@site-x-a:8081"
|
||||
};
|
||||
var (_, transport, _) = CreateActor(new[] { akkaOnly });
|
||||
|
||||
AwaitAssert(() => transport.Received().ReconcileSites(
|
||||
Arg.Is<SiteAddressCacheLoaded>(c =>
|
||||
c.KnownSiteIds.Contains("site-x") && !c.GrpcContacts.ContainsKey("site-x"))),
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
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.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="GrpcSiteTransport.ResolveDeadline"/> to the EXACT Ask timeout
|
||||
/// <see cref="CommunicationService"/> uses for each command today, so flipping the transport cannot
|
||||
/// change how long any call waits. Every timeout is given a distinct value so a wrong mapping is
|
||||
/// caught, not masked by two defaults that happen to be equal (QueryTimeout == IntegrationTimeout in
|
||||
/// production).
|
||||
/// </summary>
|
||||
public class GrpcSiteTransportDeadlineTests
|
||||
{
|
||||
private static readonly CommunicationOptions Opts = new()
|
||||
{
|
||||
DeploymentTimeout = TimeSpan.FromSeconds(120),
|
||||
LifecycleTimeout = TimeSpan.FromSeconds(30),
|
||||
ArtifactDeploymentTimeout = TimeSpan.FromSeconds(60),
|
||||
QueryTimeout = TimeSpan.FromSeconds(31),
|
||||
IntegrationTimeout = TimeSpan.FromSeconds(32),
|
||||
DebugViewTimeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
private static readonly GrpcSiteTransport Transport = new(
|
||||
new SitePairChannelProvider(
|
||||
new NoKeyProvider(), Options.Create(Opts), NullLogger<SitePairChannelProvider>.Instance),
|
||||
Opts,
|
||||
NullLogger<GrpcSiteTransport>.Instance);
|
||||
|
||||
private static readonly DateTimeOffset T = DateTimeOffset.UtcNow;
|
||||
|
||||
public static IEnumerable<object[]> Cases()
|
||||
{
|
||||
// Lifecycle group — note it is NOT one deadline class.
|
||||
yield return [new RefreshDeploymentCommand("d", "i", "h", "by", T, "u", "t"), Opts.DeploymentTimeout];
|
||||
yield return [new EnableInstanceCommand("c", "i", T), Opts.LifecycleTimeout];
|
||||
yield return [new DisableInstanceCommand("c", "i", T), Opts.LifecycleTimeout];
|
||||
yield return [new DeleteInstanceCommand("c", "i", T), Opts.LifecycleTimeout];
|
||||
// DeploymentStateQuery uses QueryTimeout in CommunicationService, NOT LifecycleTimeout.
|
||||
yield return [new DeploymentStateQueryRequest("c", "i", T), Opts.QueryTimeout];
|
||||
yield return [new DeployArtifactsCommand("d", null, null, null, null, null, null, T), Opts.ArtifactDeploymentTimeout];
|
||||
|
||||
// OPC UA group — all QueryTimeout.
|
||||
yield return [new BrowseNodeCommand("conn", null, null, null), Opts.QueryTimeout];
|
||||
yield return [new SearchAddressSpaceCommand("conn", "q", 1, 1, null), Opts.QueryTimeout];
|
||||
yield return [new ReadTagValuesCommand("conn", []), Opts.QueryTimeout];
|
||||
yield return [new VerifyEndpointCommand("conn", "OpcUa", "{}", null), Opts.QueryTimeout];
|
||||
yield return [new TrustServerCertCommand("conn", "ZGVy", "AA", null), Opts.QueryTimeout];
|
||||
yield return [new ListServerCertsCommand(null), Opts.QueryTimeout];
|
||||
yield return [new RemoveServerCertCommand("AA", null), Opts.QueryTimeout];
|
||||
yield return [new WriteTagRequest("c", "conn", "tag", 1, T), Opts.QueryTimeout];
|
||||
|
||||
// Query group.
|
||||
yield return [new EventLogQueryRequest("c", "s", null, null, null, null, null, null, null, 10, T), Opts.QueryTimeout];
|
||||
yield return [new DebugSnapshotRequest("i", "c"), Opts.QueryTimeout];
|
||||
yield return [new SubscribeDebugViewRequest("i", "c"), Opts.DebugViewTimeout];
|
||||
yield return [new UnsubscribeDebugViewRequest("i", "c"), Opts.DebugViewTimeout];
|
||||
|
||||
// Parked group — the two relays keep QueryTimeout (30s) so SiteCallAudit's inner
|
||||
// RelayTimeout (10s) still expires first.
|
||||
yield return [new ParkedMessageQueryRequest("c", "s", 1, 10, T), Opts.QueryTimeout];
|
||||
yield return [new ParkedMessageRetryRequest("c", "s", "m", T), Opts.QueryTimeout];
|
||||
yield return [new ParkedMessageDiscardRequest("c", "s", "m", T), Opts.QueryTimeout];
|
||||
yield return [new RetryParkedOperation("c", new TrackedOperationId(Guid.NewGuid())), Opts.QueryTimeout];
|
||||
yield return [new DiscardParkedOperation("c", new TrackedOperationId(Guid.NewGuid())), Opts.QueryTimeout];
|
||||
|
||||
// Route group.
|
||||
yield return [new RouteToCallRequest("c", "i", "m", null, T), Opts.IntegrationTimeout];
|
||||
yield return [new RouteToGetAttributesRequest("c", "i", [], T), Opts.IntegrationTimeout];
|
||||
yield return [new RouteToSetAttributesRequest("c", "i", new Dictionary<string, string>(), T), Opts.IntegrationTimeout];
|
||||
|
||||
// Failover — QueryTimeout in CommunicationService, NOT LifecycleTimeout.
|
||||
yield return [new TriggerSiteFailover("c", "s"), Opts.QueryTimeout];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public void ResolveDeadline_MatchesTodaysAskTimeout(object command, TimeSpan expected)
|
||||
{
|
||||
Assert.Equal(expected, Transport.ResolveDeadline(command));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitForAttribute_UsesItsDynamicTimeoutPlusIntegrationSlack()
|
||||
{
|
||||
// CommunicationService.RouteToWaitForAttributeAsync uses request.Timeout + IntegrationTimeout.
|
||||
var wait = new RouteToWaitForAttributeRequest("c", "i", "attr", "10", TimeSpan.FromSeconds(45), T);
|
||||
Assert.Equal(TimeSpan.FromSeconds(45) + Opts.IntegrationTimeout, Transport.ResolveDeadline(wait));
|
||||
}
|
||||
|
||||
private sealed class NoKeyProvider : ISitePskProvider
|
||||
{
|
||||
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k");
|
||||
public void Invalidate(string siteId) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// T1B.3: <see cref="SitePairChannelProvider"/> failover/failback + credential/deadline proof over
|
||||
/// two in-process gRPC <see cref="TestServer"/>s (NodeA preferred, NodeB standby). Exercises the
|
||||
/// real client stack — PSK call credentials, per-call deadline, sticky failover, no-retry on
|
||||
/// <see cref="StatusCode.DeadlineExceeded"/>, and failback to the preferred node.
|
||||
/// </summary>
|
||||
public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
|
||||
{
|
||||
private const string SiteId = "site-1";
|
||||
private const string SiteKey = "the-site-1-key";
|
||||
private const string EndpointA = "http://node-a";
|
||||
private const string EndpointB = "http://node-b";
|
||||
|
||||
private IHost _hostA = null!;
|
||||
private IHost _hostB = null!;
|
||||
private StubSiteCommandService _stubA = null!;
|
||||
private StubSiteCommandService _stubB = null!;
|
||||
private SitePairChannelProvider _provider = null!;
|
||||
private volatile bool _preferredReachable;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
(_hostA, _stubA) = await StartServerAsync();
|
||||
(_hostB, _stubB) = await StartServerAsync();
|
||||
|
||||
var handlerA = _hostA.GetTestServer().CreateHandler();
|
||||
var handlerB = _hostB.GetTestServer().CreateHandler();
|
||||
|
||||
_provider = new SitePairChannelProvider(
|
||||
new FixedPskProvider(SiteKey),
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<SitePairChannelProvider>.Instance,
|
||||
handlerFactory: endpoint => endpoint == EndpointA ? handlerA : handlerB,
|
||||
reachabilityProbe: (_, _) => Task.FromResult(_preferredReachable));
|
||||
|
||||
_provider.UpdateSite(SiteId, EndpointA, EndpointB);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_provider.Dispose();
|
||||
await _hostA.StopAsync();
|
||||
await _hostB.StopAsync();
|
||||
_hostA.Dispose();
|
||||
_hostB.Dispose();
|
||||
}
|
||||
|
||||
private static async Task<(IHost, StubSiteCommandService)> StartServerAsync()
|
||||
{
|
||||
var stub = new StubSiteCommandService();
|
||||
var host = await new HostBuilder()
|
||||
.ConfigureWebHost(web => web
|
||||
.UseTestServer()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddGrpc();
|
||||
services.AddSingleton(stub);
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(e => e.MapGrpcService<StubSiteCommandService>());
|
||||
}))
|
||||
.StartAsync();
|
||||
return (host, stub);
|
||||
}
|
||||
|
||||
private static LifecycleRequest EnableReq() =>
|
||||
SiteCommandDtoMapper.ToLifecycleRequest(
|
||||
new EnableInstanceCommand("cmd-1", "Site1.Pump1", DateTimeOffset.UtcNow));
|
||||
|
||||
private Task<LifecycleReply> CallAsync(TimeSpan? deadline = null) =>
|
||||
_provider.ExecuteAsync(
|
||||
SiteId,
|
||||
(channel, ct) =>
|
||||
{
|
||||
var client = new SiteCommandService.SiteCommandServiceClient(channel);
|
||||
return client.ExecuteLifecycleAsync(
|
||||
EnableReq(),
|
||||
deadline: DateTime.UtcNow + (deadline ?? TimeSpan.FromSeconds(10)),
|
||||
cancellationToken: ct).ResponseAsync;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
[Fact]
|
||||
public async Task HealthyCall_HitsPreferredNodeA_WithPskAndSiteHeaderAndDeadline()
|
||||
{
|
||||
var reply = await CallAsync();
|
||||
|
||||
Assert.Equal(LifecycleReply.ReplyOneofCase.InstanceLifecycle, reply.ReplyCase);
|
||||
Assert.Equal(1, _stubA.LifecycleCalls);
|
||||
Assert.Equal(0, _stubB.LifecycleCalls);
|
||||
Assert.Equal($"Bearer {SiteKey}", _stubA.LastAuthHeader);
|
||||
Assert.Equal(SiteId, _stubA.LastSiteHeader);
|
||||
Assert.True(_stubA.DeadlineWasSet, "the client must set a per-call deadline");
|
||||
Assert.True(_provider.IsOnPreferredNode(SiteId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unavailable_OnNodeA_FailsOverToNodeB_AndStaysSticky()
|
||||
{
|
||||
_stubA.ThrowUnavailable = true;
|
||||
|
||||
var reply = await CallAsync();
|
||||
|
||||
// Failed over: A was tried (and threw), B answered.
|
||||
Assert.Equal(LifecycleReply.ReplyOneofCase.InstanceLifecycle, reply.ReplyCase);
|
||||
Assert.Equal(1, _stubA.LifecycleCalls);
|
||||
Assert.Equal(1, _stubB.LifecycleCalls);
|
||||
Assert.False(_provider.IsOnPreferredNode(SiteId));
|
||||
|
||||
// Sticky: the next call goes straight to B without re-touching A.
|
||||
await CallAsync();
|
||||
Assert.Equal(1, _stubA.LifecycleCalls);
|
||||
Assert.Equal(2, _stubB.LifecycleCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failback_ReturnsToPreferredNodeA_OncePreferredIsReachableAgain()
|
||||
{
|
||||
_stubA.ThrowUnavailable = true;
|
||||
await CallAsync(); // flips to B
|
||||
Assert.False(_provider.IsOnPreferredNode(SiteId));
|
||||
|
||||
// NodeA recovers; the failback probe reports it reachable.
|
||||
_stubA.ThrowUnavailable = false;
|
||||
_preferredReachable = true;
|
||||
var back = await _provider.TryFailbackAsync(SiteId, CancellationToken.None);
|
||||
|
||||
Assert.True(back);
|
||||
Assert.True(_provider.IsOnPreferredNode(SiteId));
|
||||
|
||||
var beforeA = _stubA.LifecycleCalls;
|
||||
await CallAsync();
|
||||
Assert.Equal(beforeA + 1, _stubA.LifecycleCalls); // next call back on A
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadlineExceeded_OnNodeA_IsNotRetriedOnNodeB()
|
||||
{
|
||||
// A DeadlineExceeded is ambiguous (a WriteTag/Deploy/Failover may already have executed), so
|
||||
// — unlike Unavailable — it must NOT fail over to B. Modelled by A returning the status
|
||||
// directly, isolating the retry-decision from TestServer's own timeout mechanics.
|
||||
_stubA.StatusToThrow = StatusCode.DeadlineExceeded;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => CallAsync());
|
||||
|
||||
Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode);
|
||||
Assert.Equal(1, _stubA.LifecycleCalls);
|
||||
Assert.Equal(0, _stubB.LifecycleCalls); // B was never tried
|
||||
Assert.True(_provider.IsOnPreferredNode(SiteId), "a deadline must not flip stickiness");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownSite_Throws_SiteChannelUnavailable()
|
||||
{
|
||||
await Assert.ThrowsAsync<SiteChannelUnavailableException>(
|
||||
() => _provider.ExecuteAsync<LifecycleReply>(
|
||||
"not-configured", (_, _) => Task.FromResult(new LifecycleReply()), CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class FixedPskProvider(string key) : ISitePskProvider
|
||||
{
|
||||
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
|
||||
public void Invalidate(string siteId) { }
|
||||
}
|
||||
|
||||
/// <summary>Stub SiteCommandService recording what the client sent and letting a test steer faults.</summary>
|
||||
private sealed class StubSiteCommandService : SiteCommandService.SiteCommandServiceBase
|
||||
{
|
||||
private int _lifecycleCalls;
|
||||
public int LifecycleCalls => Volatile.Read(ref _lifecycleCalls);
|
||||
public string? LastAuthHeader { get; private set; }
|
||||
public string? LastSiteHeader { get; private set; }
|
||||
public bool DeadlineWasSet { get; private set; }
|
||||
public volatile bool ThrowUnavailable;
|
||||
public StatusCode? StatusToThrow;
|
||||
|
||||
public override Task<LifecycleReply> ExecuteLifecycle(LifecycleRequest request, ServerCallContext context)
|
||||
{
|
||||
Interlocked.Increment(ref _lifecycleCalls);
|
||||
LastAuthHeader = context.RequestHeaders
|
||||
.FirstOrDefault(h => h.Key == ControlPlaneCredentials.AuthorizationHeader)?.Value;
|
||||
LastSiteHeader = context.RequestHeaders
|
||||
.FirstOrDefault(h => h.Key == ControlPlaneCredentials.SiteHeader)?.Value;
|
||||
DeadlineWasSet = context.Deadline != DateTime.MaxValue;
|
||||
|
||||
if (ThrowUnavailable)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.Unavailable, "node down"));
|
||||
}
|
||||
if (StatusToThrow is { } status)
|
||||
{
|
||||
throw new RpcException(new Status(status, "modelled fault"));
|
||||
}
|
||||
|
||||
return Task.FromResult(SiteCommandDtoMapper.ToLifecycleReply(
|
||||
new InstanceLifecycleResponse("cmd-1", "Site1.Pump1", true, null, DateTimeOffset.UtcNow)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user