86ad4d5c8e
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.
115 lines
5.3 KiB
C#
115 lines
5.3 KiB
C#
using System.Collections.Immutable;
|
|
using Akka.Actor;
|
|
using Akka.Cluster.Tools.Client;
|
|
using Akka.Configuration;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
using Xunit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
|
|
|
public class CentralCommunicationActorClientLifecycleTests : TestKit
|
|
{
|
|
private static readonly Config TestConfig = ConfigurationFactory.ParseString(@"
|
|
akka.actor.provider = cluster
|
|
akka.remote.dot-netty.tcp.port = 0
|
|
akka.remote.dot-netty.tcp.hostname = localhost")
|
|
.WithFallback(ClusterClientReceptionist.DefaultConfig());
|
|
|
|
public CentralCommunicationActorClientLifecycleTests() : base(TestConfig) { }
|
|
|
|
// Empty ServiceProvider with a no-op ISiteRepository so the actor's periodic
|
|
// db-refresh (fired at PreStart) resolves and returns no sites, keeping the
|
|
// logs clean; the tests drive SiteAddressCacheLoaded directly.
|
|
private static IServiceProvider EmptyProvider()
|
|
{
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>());
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => siteRepo);
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
private static SiteAddressCacheLoaded Load(string siteId, params string[] addrs) =>
|
|
new(new Dictionary<string, IReadOnlyList<string>>
|
|
{ [siteId] = addrs.ToList().AsReadOnly() },
|
|
new[] { siteId },
|
|
new Dictionary<string, SiteGrpcEndpoints>());
|
|
|
|
[Fact]
|
|
public void PeriodicRefresh_PrunesDeletedSites_FromHealthAggregator()
|
|
{
|
|
// PLAN-01 Task 18: the periodic site refresh feeds the currently-configured
|
|
// site ids to the aggregator so a deleted site is evicted within one
|
|
// refresh interval. Repo returns only "site-a".
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Site> { new("Site A", "site-a") });
|
|
var aggregator = Substitute.For<ICentralHealthAggregator>();
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => siteRepo);
|
|
services.AddSingleton(aggregator);
|
|
var provider = services.BuildServiceProvider();
|
|
|
|
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
|
|
provider, new DefaultSiteClientFactory(), null)));
|
|
|
|
// Trigger the refresh (also fires at PreStart, but drive it explicitly so
|
|
// the assertion is deterministic). The load runs on a detached task and
|
|
// pipes SiteAddressCacheLoaded back to the actor, which then prunes.
|
|
actor.Tell(new RefreshSiteAddresses());
|
|
|
|
AwaitAssert(
|
|
() => aggregator.Received().PruneUnknownSites(
|
|
Arg.Is<IReadOnlyCollection<string>>(ids => ids.Contains("site-a"))),
|
|
TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
[Fact]
|
|
public void AddressEdit_RecreatesClient_WithoutRestartLoop()
|
|
{
|
|
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
|
|
EmptyProvider(), new DefaultSiteClientFactory(), null)));
|
|
|
|
// First load creates the client; second load (edited NodeA address) stops
|
|
// the old one and creates a replacement in the same message handling.
|
|
// Pre-fix this throws InvalidActorNameException and restarts the actor.
|
|
EventFilter.Exception<InvalidActorNameException>().Expect(0, () =>
|
|
{
|
|
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081"));
|
|
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a-edited:8081"));
|
|
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081")); // and back — third generation
|
|
});
|
|
|
|
// The actor must still be alive and routing (not crash-looping):
|
|
// an envelope for an unknown site produces the "No ClusterClient" warning,
|
|
// proving the Receive pipeline is healthy.
|
|
EventFilter.Warning(contains: "No ClusterClient for site").ExpectOne(() =>
|
|
actor.Tell(new SiteEnvelope("unknown-site", new object())));
|
|
}
|
|
|
|
[Fact]
|
|
public void FactoryThrow_SkipsSite_DoesNotCrashActor()
|
|
{
|
|
var throwingFactory = Substitute.For<ISiteClientFactory>();
|
|
throwingFactory.Create(Arg.Any<ActorSystem>(), Arg.Any<string>(), Arg.Any<ImmutableHashSet<ActorPath>>())
|
|
.Returns(_ => throw new InvalidOperationException("boom"));
|
|
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
|
|
EmptyProvider(), throwingFactory, null)));
|
|
|
|
EventFilter.Error(contains: "Failed to create ClusterClient").ExpectOne(() =>
|
|
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081")));
|
|
|
|
// Actor survived — a subsequent envelope for that (unrouted) site still
|
|
// produces the healthy "No ClusterClient" warning rather than a dead actor.
|
|
EventFilter.Warning(contains: "No ClusterClient for site").ExpectOne(() =>
|
|
actor.Tell(new SiteEnvelope("site-a", new object())));
|
|
}
|
|
}
|