diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index 7a7d8517..623414df 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -456,14 +456,14 @@ akka {{ var outboxAuditWriter = _serviceProvider .GetRequiredService(); - // All central singletons below are created through CentralSingletonRegistrar.Start, + // All central singletons below are created through SingletonRegistrar.Start, // which pins the actor to the active central node (ClusterSingletonManager), exposes a // stable address (ClusterSingletonProxy), and — crucially — registers a PhaseClusterLeave // GracefulStop drain task so an in-flight EF write drains before handover (review 01 // [Medium]: notification-outbox and audit-log-ingest previously had NO drain task). Names - // are unchanged ({name}-singleton / {name}-proxy). The two SITE singletons stay - // hand-rolled below because they are role-scoped (.WithRole(siteRole)). - var outbox = CentralSingletonRegistrar.Start( + // are unchanged ({name}-singleton / {name}-proxy). Site singletons use the same + // registrar with `role: siteRole` (Task 11 / round-2 N5). + var outbox = SingletonRegistrar.Start( _actorSystem!, "notification-outbox", Props.Create(() => new ZB.MOM.WW.ScadaBridge.NotificationOutbox.NotificationOutboxActor( _serviceProvider, @@ -496,7 +496,7 @@ akka {{ var auditIngestLogger = _serviceProvider.GetRequiredService() .CreateLogger(); - var auditIngest = CentralSingletonRegistrar.Start( + var auditIngest = SingletonRegistrar.Start( _actorSystem!, "audit-log-ingest", Props.Create(() => new ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogIngestActor( _serviceProvider, @@ -561,7 +561,7 @@ akka {{ var siteCallAuditOptions = _serviceProvider .GetRequiredService>().Value; - var siteCallAudit = CentralSingletonRegistrar.Start( + var siteCallAudit = SingletonRegistrar.Start( _actorSystem!, "site-call-audit", Props.Create(() => new ZB.MOM.WW.ScadaBridge.SiteCallAudit.SiteCallAuditActor( _serviceProvider, @@ -606,7 +606,7 @@ akka {{ var auditLogOptions = _serviceProvider .GetRequiredService>(); - CentralSingletonRegistrar.Start( + SingletonRegistrar.Start( _actorSystem!, "audit-log-purge", Props.Create(() => new ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeActor( _serviceProvider, @@ -629,7 +629,7 @@ akka {{ var auditReconClient = _serviceProvider .GetRequiredService(); - CentralSingletonRegistrar.Start( + SingletonRegistrar.Start( _actorSystem!, "site-audit-reconciliation", Props.Create(() => new ZB.MOM.WW.ScadaBridge.AuditLog.Central.SiteAuditReconciliationActor( auditReconSites, @@ -660,7 +660,7 @@ akka {{ var kpiHistoryLogger = _serviceProvider.GetRequiredService() .CreateLogger(); - CentralSingletonRegistrar.Start( + SingletonRegistrar.Start( _actorSystem!, "kpi-history-recorder", Props.Create(() => new ZB.MOM.WW.ScadaBridge.KpiHistory.KpiHistoryRecorderActor( _serviceProvider, @@ -686,7 +686,7 @@ akka {{ var pendingPurgeCommunicationOptions = _serviceProvider.GetRequiredService>(); - CentralSingletonRegistrar.Start( + SingletonRegistrar.Start( _actorSystem!, "pending-deployment-purge", Props.Create(() => new PendingDeploymentPurgeActor( _serviceProvider, diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/CentralSingletonRegistrar.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/SingletonRegistrar.cs similarity index 65% rename from src/ZB.MOM.WW.ScadaBridge.Host/Actors/CentralSingletonRegistrar.cs rename to src/ZB.MOM.WW.ScadaBridge.Host/Actors/SingletonRegistrar.cs index 9d38e9ff..e793938f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/CentralSingletonRegistrar.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/SingletonRegistrar.cs @@ -5,21 +5,24 @@ using Microsoft.Extensions.Logging; namespace ZB.MOM.WW.ScadaBridge.Host.Actors; /// -/// Registers a central cluster singleton with the canonical naming scheme +/// Registers a cluster singleton with the canonical naming scheme /// ({name}-singleton / {name}-proxy), a PoisonPill termination /// message, and a PhaseClusterLeave drain task that GracefulStops the manager -/// so in-flight EF work completes before handover. Extracted from five -/// copy-pasted ~60-line blocks (review 01 [Low]) whose drift left the two -/// busiest singletons (notification-outbox, audit-log-ingest) without drain -/// tasks (review 01 [Medium]). +/// so in-flight EF (central) or SQLite (site) work completes before handover. +/// Extracted from copy-pasted ~60-line blocks (review 01 [Low]) whose drift +/// left the two busiest singletons (notification-outbox, audit-log-ingest) +/// without drain tasks (review 01 [Medium]). The optional role maps to +/// / +/// .WithRole, so role-scoped site singletons get the same drain +/// (review 01 round-2 N5). /// -internal static class CentralSingletonRegistrar +internal static class SingletonRegistrar { internal sealed record Handle(IActorRef Manager, IActorRef Proxy); /// - /// Creates the and proxy for a central - /// cluster singleton under the canonical naming scheme, and registers a + /// Creates the and proxy for a cluster + /// singleton under the canonical naming scheme, and registers a /// PhaseClusterLeave drain task that GracefulStops the manager on shutdown. /// /// The actor system to register the singleton on. @@ -27,19 +30,24 @@ internal static class CentralSingletonRegistrar /// The used to create the singleton's managed actor. /// Logger used to report drain progress/failures. /// Maximum time to wait for the drain task to complete; defaults to 10 seconds. + /// Optional cluster role to scope the singleton to (applied to both the manager and proxy settings). /// A carrying the singleton manager and proxy actor refs. internal static Handle Start( ActorSystem system, string name, Props singletonProps, ILogger logger, - TimeSpan? drainTimeout = null) + TimeSpan? drainTimeout = null, + string? role = null) { + var managerSettings = ClusterSingletonManagerSettings.Create(system).WithSingletonName(name); + if (role is not null) managerSettings = managerSettings.WithRole(role); + var manager = system.ActorOf( ClusterSingletonManager.Props( singletonProps, PoisonPill.Instance, - ClusterSingletonManagerSettings.Create(system).WithSingletonName(name)), + managerSettings), $"{name}-singleton"); var timeout = drainTimeout ?? TimeSpan.FromSeconds(10); @@ -61,10 +69,13 @@ internal static class CentralSingletonRegistrar return Akka.Done.Instance; }); + var proxySettings = ClusterSingletonProxySettings.Create(system).WithSingletonName(name); + if (role is not null) proxySettings = proxySettings.WithRole(role); + var proxy = system.ActorOf( ClusterSingletonProxy.Props( $"/user/{name}-singleton", - ClusterSingletonProxySettings.Create(system).WithSingletonName(name)), + proxySettings), $"{name}-proxy"); return new Handle(manager, proxy); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs index b5943cfe..3e0fe0fb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CoordinatedShutdownTests.cs @@ -51,9 +51,9 @@ public class CoordinatedShutdownTests // Review 01 [Medium]: notification-outbox and audit-log-ingest were the // only central singletons WITHOUT a cluster-leave drain task. All seven - // now go through CentralSingletonRegistrar.Start, which always adds the + // now go through SingletonRegistrar.Start, which always adds the // PhaseClusterLeave GracefulStop drain. - Assert.Contains("CentralSingletonRegistrar.Start(", content); + Assert.Contains("SingletonRegistrar.Start(", content); foreach (var name in new[] { "notification-outbox", "audit-log-ingest", "site-call-audit", "audit-log-purge", "site-audit-reconciliation", "kpi-history-recorder", "pending-deployment-purge" }) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralSingletonRegistrarTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs similarity index 67% rename from tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralSingletonRegistrarTests.cs rename to tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs index 21ceb8be..938af66b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralSingletonRegistrarTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SingletonRegistrarTests.cs @@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; -public class CentralSingletonRegistrarTests +public class SingletonRegistrarTests { private sealed class EchoActor : ReceiveActor { @@ -16,7 +16,7 @@ public class CentralSingletonRegistrarTests public async Task Start_CreatesManagerAndProxy_WithCanonicalNames_AndDrainTask() { using var system = CreateSingleNodeClusterSystem(); - var handle = CentralSingletonRegistrar.Start( + var handle = SingletonRegistrar.Start( system, "test-widget", Props.Create(() => new EchoActor()), NullLogger.Instance, drainTimeout: TimeSpan.FromSeconds(2)); @@ -36,6 +36,25 @@ public class CentralSingletonRegistrarTests Assert.True(handle.Manager.IsNobody() || system.WhenTerminated.IsCompleted); } + [Fact] + public async Task Start_WithRoleScope_CreatesRoleScopedSingleton_ThatAnswers() + { + // Round-2 N5: role scoping is what kept the two SITE singletons + // (deployment-manager, event-log-handler) hand-rolled and drain-less. + using var system = CreateSingleNodeClusterSystem(); // roles = ["Central"] + var handle = SingletonRegistrar.Start( + system, "role-widget", Props.Create(() => new EchoActor()), + NullLogger.Instance, drainTimeout: TimeSpan.FromSeconds(2), role: "Central"); + + Assert.EndsWith("/user/role-widget-singleton", handle.Manager.Path.ToString()); + Assert.EndsWith("/user/role-widget-proxy", handle.Proxy.Path.ToString()); + + // The node holds the role => manager instantiates the singleton and + // the role-scoped proxy resolves it. + var echo = await handle.Proxy.Ask("hi", TimeSpan.FromSeconds(20)); + Assert.Equal("hi", echo); + } + private static ActorSystem CreateSingleNodeClusterSystem() { var port = FreePort();