diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index 797c182..e5fcd9e 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -260,6 +260,36 @@ akka {{ mgmtHolder.ActorRef = mgmtActor; _logger.LogInformation("ManagementActor registered with ClusterClientReceptionist"); + // Notification Outbox — cluster singleton so exactly one node owns ingest, + // the dispatch sweep and the purge loop. Central actors run on the base + // "Central" role, so the singleton settings are NOT role-scoped (unlike the + // site singletons, which are scoped to a per-site role). + var outboxOptions = _serviceProvider + .GetRequiredService>().Value; + var outboxLogger = _serviceProvider.GetRequiredService() + .CreateLogger(); + + var outboxSingletonProps = ClusterSingletonManager.Props( + singletonProps: Props.Create(() => new ScadaLink.NotificationOutbox.NotificationOutboxActor( + _serviceProvider, + outboxOptions, + outboxLogger)), + terminationMessage: PoisonPill.Instance, + settings: ClusterSingletonManagerSettings.Create(_actorSystem!) + .WithSingletonName("notification-outbox")); + _actorSystem!.ActorOf(outboxSingletonProps, "notification-outbox-singleton"); + + var outboxProxyProps = ClusterSingletonProxy.Props( + singletonManagerPath: "/user/notification-outbox-singleton", + settings: ClusterSingletonProxySettings.Create(_actorSystem) + .WithSingletonName("notification-outbox")); + var outboxProxy = _actorSystem.ActorOf(outboxProxyProps, "notification-outbox-proxy"); + + // Hand the outbox proxy to the CentralCommunicationActor so forwarded + // NotificationSubmit messages from sites are routed to the outbox singleton. + centralCommActor.Tell(new RegisterNotificationOutbox(outboxProxy)); + _logger.LogInformation("NotificationOutbox singleton created and registered with CentralCommunicationActor"); + _logger.LogInformation("Central actors registered. CentralCommunicationActor created."); } diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs index 0dd4b2c..58d7ba7 100644 --- a/src/ScadaLink.Host/Program.cs +++ b/src/ScadaLink.Host/Program.cs @@ -12,6 +12,7 @@ using ScadaLink.Host.Actors; using ScadaLink.Host.Health; using ScadaLink.InboundAPI; using ScadaLink.ManagementService; +using ScadaLink.NotificationOutbox; using ScadaLink.NotificationService; using ScadaLink.Security; using ScadaLink.TemplateEngine; @@ -72,6 +73,10 @@ try builder.Services.AddNotificationService(); // Central-only components + // Notification Outbox: central owns SMTP delivery; the Email adapter reuses the + // AddNotificationService() SMTP machinery above. AddNotificationOutbox binds + // NotificationOutboxOptions via BindConfiguration, so no explicit Configure is needed. + builder.Services.AddNotificationOutbox(); builder.Services.AddTemplateEngine(); builder.Services.AddDeploymentManager(); builder.Services.AddSecurity(); diff --git a/src/ScadaLink.Host/ScadaLink.Host.csproj b/src/ScadaLink.Host/ScadaLink.Host.csproj index 8dcb401..71f7406 100644 --- a/src/ScadaLink.Host/ScadaLink.Host.csproj +++ b/src/ScadaLink.Host/ScadaLink.Host.csproj @@ -37,6 +37,7 @@ + diff --git a/src/ScadaLink.Host/SiteServiceRegistration.cs b/src/ScadaLink.Host/SiteServiceRegistration.cs index 9567723..5e9dc50 100644 --- a/src/ScadaLink.Host/SiteServiceRegistration.cs +++ b/src/ScadaLink.Host/SiteServiceRegistration.cs @@ -25,7 +25,12 @@ public static class SiteServiceRegistration services.AddCommunication(); services.AddSiteHealthMonitoring(); services.AddExternalSystemGateway(); - services.AddNotificationService(); + // AddNotificationService() is intentionally NOT registered on the site path. + // Sites no longer deliver notifications over SMTP — a buffered notification is + // forwarded to the central cluster (via NotificationForwarder / SiteCommunicationActor), + // and central owns SMTP delivery through the Notification Outbox. The SMTP machinery + // (OAuth2TokenService, ISmtpClientWrapper, INotificationDeliveryService) has no + // consumer on a site node. // Health report transport: sends SiteHealthReport to SiteCommunicationActor via Akka services.AddSingleton(); diff --git a/src/ScadaLink.Host/appsettings.Central.json b/src/ScadaLink.Host/appsettings.Central.json index 3f05f84..149782f 100644 --- a/src/ScadaLink.Host/appsettings.Central.json +++ b/src/ScadaLink.Host/appsettings.Central.json @@ -52,6 +52,14 @@ "AuthMode": "None", "FromAddress": "scada-notifications@company.com" }, + "NotificationOutbox": { + "DispatchInterval": "00:00:10", + "DispatchBatchSize": 100, + "StuckAgeThreshold": "00:10:00", + "TerminalRetention": "365.00:00:00", + "PurgeInterval": "1.00:00:00", + "DeliveredKpiWindow": "00:01:00" + }, "Logging": { "MinimumLevel": "Information" } diff --git a/tests/ScadaLink.Host.Tests/ActorPathTests.cs b/tests/ScadaLink.Host.Tests/ActorPathTests.cs index 3dd5c25..0b52945 100644 --- a/tests/ScadaLink.Host.Tests/ActorPathTests.cs +++ b/tests/ScadaLink.Host.Tests/ActorPathTests.cs @@ -97,6 +97,14 @@ public class CentralActorPathTests : IAsyncLifetime public async Task CentralActors_Management_Exists() => await AssertActorExists("/user/management"); + [Fact] + public async Task CentralActors_NotificationOutboxSingleton_Exists() + => await AssertActorExists("/user/notification-outbox-singleton"); + + [Fact] + public async Task CentralActors_NotificationOutboxProxy_Exists() + => await AssertActorExists("/user/notification-outbox-proxy"); + private async Task AssertActorExists(string path) { Assert.NotNull(_actorSystem); diff --git a/tests/ScadaLink.Host.Tests/CompositionRootTests.cs b/tests/ScadaLink.Host.Tests/CompositionRootTests.cs index 24782d6..92f2a8f 100644 --- a/tests/ScadaLink.Host.Tests/CompositionRootTests.cs +++ b/tests/ScadaLink.Host.Tests/CompositionRootTests.cs @@ -353,7 +353,6 @@ public class SiteCompositionRootTests : IDisposable new object[] { typeof(ReplicationService) }, new object[] { typeof(ISiteEventLogger) }, new object[] { typeof(IEventLogQueryService) }, - new object[] { typeof(OAuth2TokenService) }, new object[] { typeof(ISiteIdentityProvider) }, new object[] { typeof(IHealthReportTransport) }, }; @@ -377,8 +376,6 @@ public class SiteCompositionRootTests : IDisposable new object[] { typeof(IExternalSystemClient) }, new object[] { typeof(DatabaseGateway) }, new object[] { typeof(IDatabaseGateway) }, - new object[] { typeof(NotificationDeliveryService) }, - new object[] { typeof(INotificationDeliveryService) }, }; // --- Implementation type assertions --- diff --git a/tests/ScadaLink.Host.Tests/OptionsTests.cs b/tests/ScadaLink.Host.Tests/OptionsTests.cs index 58dcddb..89b70f5 100644 --- a/tests/ScadaLink.Host.Tests/OptionsTests.cs +++ b/tests/ScadaLink.Host.Tests/OptionsTests.cs @@ -21,6 +21,7 @@ public class OptionsTests typeof(HealthMonitoring.ServiceCollectionExtensions).Assembly, typeof(ExternalSystemGateway.ServiceCollectionExtensions).Assembly, typeof(NotificationService.ServiceCollectionExtensions).Assembly, + typeof(NotificationOutbox.ServiceCollectionExtensions).Assembly, typeof(TemplateEngine.ServiceCollectionExtensions).Assembly, typeof(DeploymentManager.ServiceCollectionExtensions).Assembly, typeof(Security.ServiceCollectionExtensions).Assembly,