f1c188114b
#542 filed FleetStatusBroadcaster as blind to site nodes since the Phase 6 per-cluster mesh split: it derives "the fleet" from central's own Akka gossip ring, which after the split is just central's own pair. Scoping the cross-mesh rewrite found the deeper truth — the whole chain is dead end to end, so this deletes it rather than reviving it. - Zero consumers. FleetStatusBroadcaster published FleetStatusChanged on the DPS "fleet-status" topic; FleetStatusSignalRBridge forwarded it to FleetStatusHub ("/hubs/fleet-status"); nothing anywhere connected. The AdminUI bans self-connecting HubConnections, no browser JS subscribes, and /fleet (Fleet.razor) reads NodeDeploymentState from the ConfigDb plus IClusterRoleInfo on a 5 s poll instead. - Its second input never existed. DriverHostStatusHeartbeat had no production sender — only the broadcaster's own test told it — so CurrentRevision was always null and the 30 s staleness -> Degraded flip could never fire. - Post-Phase-6 DPS is pair-local, so even the central-only view it did produce was an accident of topology rather than a design. The fleet visibility that actually works is unchanged and already cross-mesh-correct: /fleet over NodeDeploymentState, and the four Phase 5 per-node telemetry dials. Precedent for deleting inert machinery rather than activating it: #522 (driver stability tiers), #507 (discovered-node injection), #525 (GetMemoryFootprint). Deleted: FleetStatusBroadcaster (+ its nested DriverHostStatusHeartbeat), its admin singleton registration and FleetStatusBroadcasterKey, FleetStatusSignalRBridge and its key, FleetStatusHub and its route mapping, and the FleetStatusChanged / FleetNodeStatus / FleetNodeHealth messages. Both Fleet/ directories survive — ControlPlane keeps ClusterNodeAddressReconciler, Commons keeps GetDiagnostics. Docs swept where they claimed a fleet-status channel exists (Telemetry.md's Phase 5 non-migrated channels, Configuration.md, CLAUDE.md); completed-program plan docs under docs/plans and docs/v2 are left as the historical record they are. Build 0 errors, 868 warnings — byte-identical to master's rebuild baseline. ControlPlane.Tests 231/231, AdminUI.Tests 1109/1109. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
183 lines
8.6 KiB
C#
183 lines
8.6 KiB
C#
using Akka.Actor;
|
|
using Akka.Cluster.Hosting;
|
|
using Akka.Hosting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
/// <summary>
|
|
/// Guards Phase 6's re-homing of <c>RedundancyStateActor</c> off the fleet-wide admin singleton onto
|
|
/// a per-node <c>cluster-{ClusterId}</c> singleton (see
|
|
/// <see cref="ServiceCollectionExtensions.WithOtOpcUaClusterRedundancySingleton"/>). After the mesh
|
|
/// split a driver-only site pair has NO admin node to elect its Primary, so the singleton must be
|
|
/// scoped to the node's own cluster role and spawned on every driver node — one per mesh.
|
|
///
|
|
/// <para>The role scope + the missing-role guard are asserted against the extracted
|
|
/// <see cref="ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions"/> helper directly —
|
|
/// the same pattern <c>SplitBrainResolverActivationTests</c> uses for <c>BuildDowningHocon</c> /
|
|
/// <c>BuildClusterOptions</c>: it pins the exact <see cref="ClusterSingletonOptions.Role"/> without
|
|
/// booting a cluster, which no builder-introspection API can otherwise reach. The admin-removal claim
|
|
/// is behavioral (a booted admin node's <see cref="ActorRegistry"/> must no longer carry the
|
|
/// redundancy key) so it needs a real host.</para>
|
|
/// </summary>
|
|
public sealed class RedundancyStateSingletonRehomeTests
|
|
{
|
|
/// <summary>The singleton options scope to the node's OWN cluster role, not the fixed admin role.</summary>
|
|
[Fact]
|
|
public void Options_scope_the_singleton_to_the_nodes_cluster_role()
|
|
{
|
|
var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions(
|
|
new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } });
|
|
|
|
options.Role.ShouldBe("cluster-SITE-A");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A node with no <c>cluster-{ClusterId}</c> role (a legacy single-mesh node, or the
|
|
/// <c>TwoNodeClusterHarness</c> with roles <c>admin,driver</c>) falls back to the fixed
|
|
/// <c>driver</c> role rather than throwing — so legacy/not-yet-migrated deployments keep booting.
|
|
/// On a split 2-node mesh the <c>driver</c> role is already pair-local (the mesh IS the pair).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Missing_cluster_role_falls_back_to_the_driver_role()
|
|
{
|
|
var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions(
|
|
new AkkaClusterOptions { Roles = new[] { "admin", "driver" } });
|
|
|
|
options.Role.ShouldBe(RoleParser.Driver);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The admin singleton set no longer registers <c>redundancy-state</c>: a booted admin node's
|
|
/// registry carries the OTHER admin singletons (proxy) but NOT the redundancy key.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Admin_singletons_no_longer_register_the_redundancy_singleton()
|
|
{
|
|
var registry = await BootAndGetRegistryAsync(
|
|
roles: new[] { "admin" },
|
|
configure: ab => ab.WithOtOpcUaControlPlaneSingletons());
|
|
|
|
registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeFalse(
|
|
"the redundancy singleton was re-homed off the admin node onto every driver node (Phase 6)");
|
|
registry.TryGet<AuditWriterActorKey>(out _).ShouldBeTrue(
|
|
"the remaining admin singletons must still register");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The re-home target: a driver node carrying a <c>cluster-{ClusterId}</c> role registers the
|
|
/// redundancy singleton (proxy) via the new extension.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Cluster_redundancy_extension_registers_the_singleton_on_a_driver_node()
|
|
{
|
|
var registry = await BootAndGetRegistryAsync(
|
|
roles: new[] { "driver", "cluster-SITE-A" },
|
|
configure: ab => ab.WithOtOpcUaClusterRedundancySingleton(
|
|
new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } }));
|
|
|
|
registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue(
|
|
"every driver node hosts its own mesh's redundancy singleton (Phase 6)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The driver-role FALLBACK end-to-end: a node carrying only <c>driver</c> (no cluster role — a
|
|
/// legacy single-mesh node or the <c>TwoNodeClusterHarness</c>) still boots and registers the
|
|
/// redundancy singleton via the new extension. Proves the fallback works at the host/registry
|
|
/// level, not just in the pure helper — the boot that the earlier throw-based version would have
|
|
/// aborted.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Driver_role_fallback_registers_the_singleton_end_to_end()
|
|
{
|
|
var registry = await BootAndGetRegistryAsync(
|
|
roles: new[] { "driver" },
|
|
configure: ab => ab.WithOtOpcUaClusterRedundancySingleton(
|
|
new AkkaClusterOptions { Roles = new[] { "driver" } }));
|
|
|
|
registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue(
|
|
"a driver node with no cluster role falls back to the driver role and still boots + registers");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Boots a real host through the production cluster bootstrap (Port=0, no seeds, so it never forms
|
|
/// a cluster and no singleton props factory runs — only the proxies register), applies the
|
|
/// supplied singleton registration, and returns the <see cref="ActorRegistry"/> after teardown.
|
|
/// Start/stop live here rather than the test bodies to keep the CancellationToken-less calls out
|
|
/// of the way (mirrors <c>ClusterRoleInfoTests.ResolveAsync</c>).
|
|
/// </summary>
|
|
private static async Task<ActorRegistry> BootAndGetRegistryAsync(
|
|
string[] roles, Action<AkkaConfigurationBuilder> configure)
|
|
{
|
|
var options = new AkkaClusterOptions
|
|
{
|
|
Port = 0,
|
|
Hostname = "127.0.0.1",
|
|
PublicHostname = "127.0.0.1",
|
|
SeedNodes = Array.Empty<string>(),
|
|
Roles = roles,
|
|
};
|
|
|
|
var builder = Host.CreateDefaultBuilder();
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
services.AddSingleton<IOptions<AkkaClusterOptions>>(Options.Create(options));
|
|
// The admin WithActors block reads IOptions<MeshTransportOptions>.Value at spawn time.
|
|
services.AddSingleton<IOptions<MeshTransportOptions>>(Options.Create(new MeshTransportOptions()));
|
|
// Akka.Hosting invokes every singleton's props factory eagerly at StartAsync; the admin
|
|
// singletons (coordinator/audit/reconciler) read IDbContextFactory<OtOpcUaConfigDbContext>,
|
|
// so supply an in-memory one or those factories NRE. The driver-only boots don't touch it.
|
|
services.AddDbContextFactory<OtOpcUaConfigDbContext>(
|
|
o => o.UseInMemoryDatabase($"rehome-test-{Guid.NewGuid():N}"));
|
|
// The admin ClusterNodeAddressReconciler props factory resolves IClusterRoleInfo (for its
|
|
// per-cluster reconcile scope). A null cluster role ⇒ full-fleet reconcile, which is all
|
|
// this test needs — it never asserts on the reconciler, only on the singleton registry.
|
|
services.AddSingleton<IClusterRoleInfo>(new FakeClusterRoleInfo(clusterRole: null, clusterId: null));
|
|
services.AddAkka("otopcua-rehome-test", (ab, sp) =>
|
|
{
|
|
ab.WithOtOpcUaClusterBootstrap(sp);
|
|
configure(ab);
|
|
});
|
|
});
|
|
|
|
using var host = builder.Build();
|
|
await host.StartAsync();
|
|
try
|
|
{
|
|
return host.Services.GetRequiredService<ActorRegistry>();
|
|
}
|
|
finally
|
|
{
|
|
await host.StopAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>Minimal <see cref="IClusterRoleInfo"/> stub exposing only the cluster-role identity.</summary>
|
|
private sealed class FakeClusterRoleInfo : IClusterRoleInfo
|
|
{
|
|
public FakeClusterRoleInfo(string? clusterRole, string? clusterId)
|
|
{
|
|
ClusterRole = clusterRole;
|
|
ClusterId = clusterId;
|
|
}
|
|
|
|
public string? ClusterRole { get; }
|
|
public string? ClusterId { get; }
|
|
public NodeId LocalNode => NodeId.Parse("127.0.0.1:0");
|
|
public IReadOnlySet<string> LocalRoles => new HashSet<string>();
|
|
public bool HasRole(string role) => false;
|
|
public IReadOnlyList<NodeId> MembersWithRole(string role) => Array.Empty<NodeId>();
|
|
public NodeId? RoleLeader(string role) => null;
|
|
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged { add { } remove { } }
|
|
}
|
|
}
|