refactor(fleet): #542 delete the dead fleet-status chain (broadcaster, SignalR bridge, hub, messages)
#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
This commit is contained in:
@@ -1,168 +0,0 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-role cluster singleton that maintains an in-memory <see cref="FleetStatusChanged"/>
|
||||
/// snapshot from cluster membership + reachability events plus per-node heartbeats, and pushes
|
||||
/// diffs on the <c>fleet-status</c> DistributedPubSub topic. The SignalR hub layer
|
||||
/// subscribes to that topic and forwards to browser clients.
|
||||
///
|
||||
/// Heartbeat staleness: a node we haven't heard from in <see cref="HeartbeatTimeout"/> flips
|
||||
/// from <see cref="FleetNodeHealth.Healthy"/> to <see cref="FleetNodeHealth.Degraded"/>. A
|
||||
/// cluster <c>Unreachable</c> event flips it straight to <see cref="FleetNodeHealth.Unreachable"/>.
|
||||
/// </summary>
|
||||
public sealed class FleetStatusBroadcaster : ReceiveActor, IWithTimers
|
||||
{
|
||||
public const string Topic = "fleet-status";
|
||||
public static readonly TimeSpan BroadcastInterval = TimeSpan.FromSeconds(5);
|
||||
public static readonly TimeSpan HeartbeatTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly Akka.Cluster.Cluster _cluster;
|
||||
private readonly Action<object> _broadcast;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly Dictionary<NodeId, NodeRecord> _nodes = new();
|
||||
|
||||
/// <summary>Gets or sets the timer scheduler for this actor.</summary>
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node heartbeat carrying the node's currently-applied revision hash, sent by
|
||||
/// <c>DriverHostActor</c> periodically.
|
||||
/// </summary>
|
||||
public sealed record DriverHostStatusHeartbeat(NodeId NodeId, RevisionHash? CurrentRevision);
|
||||
|
||||
/// <summary>Creates actor props for the fleet status broadcaster.</summary>
|
||||
/// <param name="broadcast">Optional custom broadcast delegate; defaults to distributed pub-sub mediator.</param>
|
||||
/// <returns>Props configured to create a FleetStatusBroadcaster.</returns>
|
||||
public static Props Props(Action<object>? broadcast = null) =>
|
||||
Akka.Actor.Props.Create(() => new FleetStatusBroadcaster(broadcast));
|
||||
|
||||
/// <summary>Initializes a new instance of the FleetStatusBroadcaster actor.</summary>
|
||||
/// <param name="broadcast">Optional custom broadcast delegate; if null, uses distributed pub-sub mediator.</param>
|
||||
public FleetStatusBroadcaster(Action<object>? broadcast = null)
|
||||
{
|
||||
_cluster = Akka.Cluster.Cluster.Get(Context.System);
|
||||
_broadcast = broadcast ?? (msg =>
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(Topic, msg)));
|
||||
|
||||
Receive<ClusterEvent.MemberUp>(e => OnMemberUp(e.Member));
|
||||
Receive<ClusterEvent.MemberRemoved>(e => OnMemberRemoved(e.Member));
|
||||
Receive<ClusterEvent.UnreachableMember>(e => OnUnreachable(e.Member));
|
||||
Receive<ClusterEvent.ReachableMember>(e => OnReachable(e.Member));
|
||||
Receive<ClusterEvent.LeaderChanged>(_ => PublishSnapshot());
|
||||
Receive<ClusterEvent.RoleLeaderChanged>(_ => PublishSnapshot());
|
||||
Receive<DriverHostStatusHeartbeat>(OnHeartbeat);
|
||||
Receive<Tick>(_ => OnTick());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
_cluster.Subscribe(
|
||||
Self,
|
||||
ClusterEvent.InitialStateAsEvents,
|
||||
typeof(ClusterEvent.IMemberEvent),
|
||||
typeof(ClusterEvent.LeaderChanged),
|
||||
typeof(ClusterEvent.RoleLeaderChanged),
|
||||
typeof(ClusterEvent.ReachabilityEvent));
|
||||
Timers.StartPeriodicTimer("tick", Tick.Instance, BroadcastInterval);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop() => _cluster.Unsubscribe(Self);
|
||||
|
||||
private void OnMemberUp(Member m)
|
||||
{
|
||||
if (!TryNode(m, out var nodeId)) return;
|
||||
_nodes[nodeId] = _nodes.TryGetValue(nodeId, out var prev)
|
||||
? prev with { Health = FleetNodeHealth.Healthy, LastSeenUtc = DateTime.UtcNow }
|
||||
: new NodeRecord(nodeId, FleetNodeHealth.Healthy, CurrentRevision: null, DateTime.UtcNow);
|
||||
PublishSnapshot();
|
||||
}
|
||||
|
||||
private void OnMemberRemoved(Member m)
|
||||
{
|
||||
if (!TryNode(m, out var nodeId)) return;
|
||||
_nodes.Remove(nodeId);
|
||||
PublishSnapshot();
|
||||
}
|
||||
|
||||
private void OnUnreachable(Member m)
|
||||
{
|
||||
if (!TryNode(m, out var nodeId) || !_nodes.TryGetValue(nodeId, out var rec)) return;
|
||||
_nodes[nodeId] = rec with { Health = FleetNodeHealth.Unreachable };
|
||||
PublishSnapshot();
|
||||
}
|
||||
|
||||
private void OnReachable(Member m)
|
||||
{
|
||||
if (!TryNode(m, out var nodeId) || !_nodes.TryGetValue(nodeId, out var rec)) return;
|
||||
_nodes[nodeId] = rec with { Health = FleetNodeHealth.Healthy, LastSeenUtc = DateTime.UtcNow };
|
||||
PublishSnapshot();
|
||||
}
|
||||
|
||||
private void OnHeartbeat(DriverHostStatusHeartbeat hb)
|
||||
{
|
||||
var rec = _nodes.TryGetValue(hb.NodeId, out var prev)
|
||||
? prev with
|
||||
{
|
||||
CurrentRevision = hb.CurrentRevision,
|
||||
LastSeenUtc = DateTime.UtcNow,
|
||||
Health = prev.Health == FleetNodeHealth.Unreachable ? prev.Health : FleetNodeHealth.Healthy,
|
||||
}
|
||||
: new NodeRecord(hb.NodeId, FleetNodeHealth.Healthy, hb.CurrentRevision, DateTime.UtcNow);
|
||||
_nodes[hb.NodeId] = rec;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodic tick — flips heartbeat-stale nodes to <see cref="FleetNodeHealth.Degraded"/> and
|
||||
/// rebroadcasts the snapshot regardless so SignalR subscribers always have a recent baseline.
|
||||
/// </summary>
|
||||
private void OnTick()
|
||||
{
|
||||
var stale = DateTime.UtcNow - HeartbeatTimeout;
|
||||
var flippedCount = 0;
|
||||
foreach (var kv in _nodes.ToList())
|
||||
{
|
||||
if (kv.Value.LastSeenUtc < stale && kv.Value.Health == FleetNodeHealth.Healthy)
|
||||
{
|
||||
_nodes[kv.Key] = kv.Value with { Health = FleetNodeHealth.Degraded };
|
||||
flippedCount++;
|
||||
}
|
||||
}
|
||||
if (flippedCount > 0) _log.Debug("FleetStatusBroadcaster flipped {Count} nodes to Degraded", flippedCount);
|
||||
PublishSnapshot();
|
||||
}
|
||||
|
||||
private void PublishSnapshot()
|
||||
{
|
||||
var statuses = _nodes.Values
|
||||
.Select(r => new FleetNodeStatus(r.NodeId, r.Health, r.CurrentRevision, r.LastSeenUtc))
|
||||
.ToList();
|
||||
var msg = new FleetStatusChanged(statuses, CurrentDeployment: null, DateTime.UtcNow, CorrelationId.NewId());
|
||||
_broadcast(msg);
|
||||
}
|
||||
|
||||
private static bool TryNode(Member m, out NodeId nodeId)
|
||||
{
|
||||
nodeId = default;
|
||||
var host = m.Address.Host;
|
||||
if (string.IsNullOrWhiteSpace(host)) return false;
|
||||
nodeId = NodeId.Parse(host);
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record NodeRecord(NodeId NodeId, FleetNodeHealth Health, RevisionHash? CurrentRevision, DateTime LastSeenUtc);
|
||||
|
||||
public sealed class Tick
|
||||
{
|
||||
public static readonly Tick Instance = new();
|
||||
private Tick() { }
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,11 @@ public static class ServiceCollectionExtensions
|
||||
public const string ConfigPublishSingletonName = "config-publish";
|
||||
public const string AdminOperationsSingletonName = "admin-operations";
|
||||
public const string AuditWriterSingletonName = "audit-writer";
|
||||
public const string FleetStatusSingletonName = "fleet-status";
|
||||
public const string RedundancyStateSingletonName = "redundancy-state";
|
||||
public const string ClusterNodeAddressReconcilerSingletonName = "cluster-node-address-reconciler";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the admin-role cluster singletons on the AkkaConfigurationBuilder — five with
|
||||
/// Registers the admin-role cluster singletons on the AkkaConfigurationBuilder — three with
|
||||
/// registry proxies, plus the address reconciler, which is proxy-less because nothing addresses it.
|
||||
/// Must be called against the same builder used by <c>AkkaHostedService</c> so the singletons
|
||||
/// share the host's ActorSystem.
|
||||
@@ -139,10 +138,11 @@ public static class ServiceCollectionExtensions
|
||||
},
|
||||
singletonOptions);
|
||||
|
||||
builder.WithSingleton<FleetStatusBroadcasterKey>(
|
||||
FleetStatusSingletonName,
|
||||
(system, registry, resolver) => FleetStatusBroadcaster.Props(),
|
||||
singletonOptions);
|
||||
// Gitea #542 (2026-08-06): the fleet-status singleton is NO LONGER registered here — the
|
||||
// whole FleetStatusBroadcaster → DPS → SignalR bridge → FleetStatusHub chain was DELETED.
|
||||
// It had zero clients end-to-end (no browser JS, no HubConnection — /fleet reads
|
||||
// NodeDeploymentState from the ConfigDb on a poll), its DriverHostStatusHeartbeat input had
|
||||
// no production sender, and after mesh Phase 6 its "fleet" was only central's own pair.
|
||||
|
||||
// Per-cluster mesh Phase 6: the redundancy-state singleton is NO LONGER registered here.
|
||||
// It was an admin-scoped, fleet-wide singleton — one Primary elected across the whole mesh.
|
||||
@@ -253,7 +253,6 @@ public static class ServiceCollectionExtensions
|
||||
public sealed class ConfigPublishCoordinatorKey { }
|
||||
public sealed class AdminOperationsActorKey { }
|
||||
public sealed class AuditWriterActorKey { }
|
||||
public sealed class FleetStatusBroadcasterKey { }
|
||||
public sealed class RedundancyStateActorKey { }
|
||||
public sealed class ClusterNodeAddressReconcilerKey { }
|
||||
public sealed class CentralCommunicationActorKey { }
|
||||
|
||||
Reference in New Issue
Block a user