refactor(deploy): retire cross-cluster DeployInstanceCommand wire path (config now fetched)

This commit is contained in:
Joseph Doherty
2026-06-26 14:32:47 -04:00
parent 5c2db9fe70
commit 3e64959582
7 changed files with 24 additions and 92 deletions
@@ -92,11 +92,6 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
Receive<RegisterLocalHandler>(HandleRegisterLocalHandler); Receive<RegisterLocalHandler>(HandleRegisterLocalHandler);
// Pattern 1: Instance Deployment — forward to Deployment Manager // Pattern 1: Instance Deployment — forward to Deployment Manager
Receive<DeployInstanceCommand>(msg =>
{
_log.Debug("Routing DeployInstanceCommand for {0} to DeploymentManager", msg.InstanceUniqueName);
_deploymentManagerProxy.Forward(msg);
});
Receive<RefreshDeploymentCommand>(msg => Receive<RefreshDeploymentCommand>(msg =>
{ {
_log.Debug("Routing RefreshDeploymentCommand for {0} to DeploymentManager", msg.InstanceUniqueName); _log.Debug("Routing RefreshDeploymentCommand for {0} to DeploymentManager", msg.InstanceUniqueName);
@@ -116,29 +116,9 @@ public class CommunicationService
// ── Pattern 1: Instance Deployment ── // ── Pattern 1: Instance Deployment ──
/// <summary> /// <summary>
/// Sends a deployment command for an instance to a site. /// Sends a small "refresh deployment" notify to a site (notify-and-fetch):
/// </summary> /// the site fetches the config over HTTP rather than receiving it inline.
/// <param name="siteId">The target site identifier.</param> /// Reply is the existing DeploymentStatusResponse, bounded by the deployment timeout.
/// <param name="command">The deployment command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deployment status response.</returns>
public async Task<DeploymentStatusResponse> DeployInstanceAsync(
string siteId, DeployInstanceCommand command, CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Sending DeployInstanceCommand to site {SiteId}, instance={Instance}, correlationId={DeploymentId}",
siteId, command.InstanceUniqueName, command.DeploymentId);
var envelope = new SiteEnvelope(siteId, command);
return await GetActor().Ask<DeploymentStatusResponse>(
envelope, _options.DeploymentTimeout, cancellationToken);
}
/// <summary>
/// Sends a small "refresh deployment" notify to a site (notify-and-fetch).
/// Replaces <see cref="DeployInstanceAsync"/> on the wire: the site fetches the
/// config over HTTP rather than receiving it inline. Reply is the existing
/// DeploymentStatusResponse, bounded by the deployment timeout.
/// </summary> /// </summary>
/// <param name="siteId">The target site identifier.</param> /// <param name="siteId">The target site identifier.</param>
/// <param name="command">The refresh-deployment notify.</param> /// <param name="command">The refresh-deployment notify.</param>
@@ -154,9 +154,9 @@ public class SiteReplicationActor : ReceiveActor
{ {
if (string.IsNullOrEmpty(msg.CentralFetchBaseUrl)) if (string.IsNullOrEmpty(msg.CentralFetchBaseUrl))
{ {
// The still-present direct DeployInstanceCommand wire path (retired in Task 14) // The direct DeployInstanceCommand cross-cluster wire path was retired (Task 14).
// replicates with empty coords; there is nothing to fetch. Skip quietly rather // This guard is a defensive fallback: skip quietly rather than calling FetchAsync("")
// than calling FetchAsync("") and logging an error T18 reconciliation backstops. // and logging a spurious error. T18 reconciliation backstops any missed writes.
_logger.LogDebug( _logger.LogDebug(
"No fetch coords for {Instance} (deployment {DeploymentId}) — skipping replicated fetch; T18 reconciliation is the backstop", "No fetch coords for {Instance} (deployment {DeploymentId}) — skipping replicated fetch; T18 reconciliation is the backstop",
msg.InstanceName, msg.DeploymentId); msg.InstanceName, msg.DeploymentId);
@@ -81,26 +81,6 @@ public class CentralCommunicationActorTests : TestKit
private Site CreateSite(string identifier, string? nodeAAddress, string? nodeBAddress = null) => private Site CreateSite(string identifier, string? nodeAAddress, string? nodeBAddress = null) =>
new("Test Site", identifier) { NodeAAddress = nodeAAddress, NodeBAddress = nodeBAddress }; new("Test Site", identifier) { NodeAAddress = nodeAAddress, NodeBAddress = nodeBAddress };
[Fact]
public void ClusterClientRouting_RoutesToConfiguredSite()
{
var site = CreateSite("site1", "akka.tcp://scadabridge@host:8082");
var (actor, _, siteProbes) = CreateActorWithMockRepo(new[] { site });
// Wait for auto-refresh (PreStart schedules with TimeSpan.Zero initial delay)
Thread.Sleep(1000);
var command = new DeployInstanceCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
actor.Tell(new SiteEnvelope("site1", command));
// The site1 probe (acting as ClusterClient) should receive a ClusterClient.Send
var msg = siteProbes["site1"].ExpectMsg<ClusterClient.Send>();
Assert.Equal("/user/site-communication", msg.Path);
Assert.IsType<DeployInstanceCommand>(msg.Message);
Assert.Equal("dep1", ((DeployInstanceCommand)msg.Message).DeploymentId);
}
[Fact] [Fact]
public void ClusterClientRouting_RefreshDeploymentCommand_RoutesToSite() public void ClusterClientRouting_RefreshDeploymentCommand_RoutesToSite()
{ {
@@ -128,8 +108,9 @@ public class CentralCommunicationActorTests : TestKit
// Wait for auto-refresh // Wait for auto-refresh
Thread.Sleep(1000); Thread.Sleep(1000);
var command = new DeployInstanceCommand( var command = new RefreshDeploymentCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); "dep1", "inst1", "hash1", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok1");
actor.Tell(new SiteEnvelope("unknown-site", command)); actor.Tell(new SiteEnvelope("unknown-site", command));
ExpectNoMsg(TimeSpan.FromMilliseconds(200)); ExpectNoMsg(TimeSpan.FromMilliseconds(200));
@@ -177,11 +158,12 @@ public class CentralCommunicationActorTests : TestKit
Thread.Sleep(1000); Thread.Sleep(1000);
// Verify routing to site1 works // Verify routing to site1 works
var cmd1 = new DeployInstanceCommand( var cmd1 = new RefreshDeploymentCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); "dep1", "inst1", "hash1", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok1");
actor.Tell(new SiteEnvelope("site1", cmd1)); actor.Tell(new SiteEnvelope("site1", cmd1));
var msg1 = siteProbes["site1"].ExpectMsg<ClusterClient.Send>(); var msg1 = siteProbes["site1"].ExpectMsg<ClusterClient.Send>();
Assert.Equal("dep1", ((DeployInstanceCommand)msg1.Message).DeploymentId); Assert.Equal("dep1", ((RefreshDeploymentCommand)msg1.Message).DeploymentId);
// Update mock repo to return both sites // Update mock repo to return both sites
var site2 = CreateSite("site2", "akka.tcp://scadabridge@host2:8082"); var site2 = CreateSite("site2", "akka.tcp://scadabridge@host2:8082");
@@ -193,11 +175,12 @@ public class CentralCommunicationActorTests : TestKit
Thread.Sleep(1000); Thread.Sleep(1000);
// Verify routing to site2 now works // Verify routing to site2 now works
var cmd2 = new DeployInstanceCommand( var cmd2 = new RefreshDeploymentCommand(
"dep2", "inst2", "hash2", "{}", "admin", DateTimeOffset.UtcNow); "dep2", "inst2", "hash2", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok2");
actor.Tell(new SiteEnvelope("site2", cmd2)); actor.Tell(new SiteEnvelope("site2", cmd2));
var msg2 = siteProbes["site2"].ExpectMsg<ClusterClient.Send>(); var msg2 = siteProbes["site2"].ExpectMsg<ClusterClient.Send>();
Assert.Equal("dep2", ((DeployInstanceCommand)msg2.Message).DeploymentId); Assert.Equal("dep2", ((RefreshDeploymentCommand)msg2.Message).DeploymentId);
} }
[Fact] [Fact]
@@ -244,14 +227,15 @@ public class CentralCommunicationActorTests : TestKit
Thread.Sleep(1000); Thread.Sleep(1000);
// good-site must still be registered and routable despite bad-site failing to parse. // good-site must still be registered and routable despite bad-site failing to parse.
var cmd = new DeployInstanceCommand( var cmd = new RefreshDeploymentCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow); "dep1", "inst1", "hash1", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok1");
actor.Tell(new SiteEnvelope("good-site", cmd)); actor.Tell(new SiteEnvelope("good-site", cmd));
Assert.True(siteProbes.ContainsKey("good-site"), Assert.True(siteProbes.ContainsKey("good-site"),
"good-site should have a ClusterClient even though bad-site's address is malformed"); "good-site should have a ClusterClient even though bad-site's address is malformed");
var msg = siteProbes["good-site"].ExpectMsg<ClusterClient.Send>(); var msg = siteProbes["good-site"].ExpectMsg<ClusterClient.Send>();
Assert.Equal("dep1", ((DeployInstanceCommand)msg.Message).DeploymentId); Assert.Equal("dep1", ((RefreshDeploymentCommand)msg.Message).DeploymentId);
} }
private NotificationSubmit CreateSubmit(string id = "notif1") => private NotificationSubmit CreateSubmit(string id = "notif1") =>
@@ -15,20 +15,6 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </summary> /// </summary>
public class CommunicationServiceTests : TestKit public class CommunicationServiceTests : TestKit
{ {
[Fact]
public async Task BeforeInitialization_ThrowsOnUsage()
{
var options = Options.Create(new CommunicationOptions());
var logger = NullLogger<CommunicationService>.Instance;
var service = new CommunicationService(options, logger);
// CommunicationService requires SetCommunicationActor before use
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.DeployInstanceAsync("site1",
new DeployInstanceCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow)));
}
[Fact] [Fact]
public void UnsubscribeDebugView_IsTellNotAsk() public void UnsubscribeDebugView_IsTellNotAsk()
{ {
@@ -25,20 +25,6 @@ public class SiteCommunicationActorTests : TestKit
{ {
} }
[Fact]
public void DeployCommand_ForwardedToDeploymentManager()
{
var dmProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
var command = new DeployInstanceCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
siteActor.Tell(command);
dmProbe.ExpectMsg<DeployInstanceCommand>(msg => msg.DeploymentId == "dep1");
}
[Fact] [Fact]
public void RefreshDeploymentCommand_ForwardedToDeploymentManager() public void RefreshDeploymentCommand_ForwardedToDeploymentManager()
{ {
@@ -142,8 +142,9 @@ akka {
[Fact] [Fact]
public async Task ApplyConfigDeploy_EmptyFetchCoords_SkipsFetchAndWrite() public async Task ApplyConfigDeploy_EmptyFetchCoords_SkipsFetchAndWrite()
{ {
// The direct DeployInstanceCommand wire path (retired in Task 14) replicates with // The direct DeployInstanceCommand cross-cluster wire path was retired in Task 14.
// empty coords; the guard must skip quietly — no FetchAsync("") call, no write. // This tests the defensive guard: if empty coords arrive, the actor must skip quietly
// — no FetchAsync("") call, no write — rather than erroring.
var fetcher = new FakeConfigFetcher(_ => Task.FromResult("never")); var fetcher = new FakeConfigFetcher(_ => Task.FromResult("never"));
var actor = CreateReplicationActor(fetcher); var actor = CreateReplicationActor(fetcher);