diff --git a/docker-env2/central-node-a/appsettings.Central.json b/docker-env2/central-node-a/appsettings.Central.json index e5e85c86..ad92cccf 100644 --- a/docker-env2/central-node-a/appsettings.Central.json +++ b/docker-env2/central-node-a/appsettings.Central.json @@ -61,6 +61,12 @@ "DispatchInterval": "00:00:05", "DispatchBatchSize": 1000 }, + "KpiHistory": { + "SampleInterval": "00:01:00", + "RetentionDays": 90, + "PurgeInterval": "1.00:00:00", + "DefaultMaxSeriesPoints": 200 + }, "Transport": { "SourceEnvironment": "docker-cluster-env2" }, diff --git a/docker-env2/central-node-b/appsettings.Central.json b/docker-env2/central-node-b/appsettings.Central.json index 82611580..a93da81c 100644 --- a/docker-env2/central-node-b/appsettings.Central.json +++ b/docker-env2/central-node-b/appsettings.Central.json @@ -61,6 +61,12 @@ "DispatchInterval": "00:00:05", "DispatchBatchSize": 1000 }, + "KpiHistory": { + "SampleInterval": "00:01:00", + "RetentionDays": 90, + "PurgeInterval": "1.00:00:00", + "DefaultMaxSeriesPoints": 200 + }, "Transport": { "SourceEnvironment": "docker-cluster-env2" }, diff --git a/docker/central-node-a/appsettings.Central.json b/docker/central-node-a/appsettings.Central.json index 0ec94080..23c3dbbd 100644 --- a/docker/central-node-a/appsettings.Central.json +++ b/docker/central-node-a/appsettings.Central.json @@ -64,6 +64,12 @@ "DispatchInterval": "00:00:05", "DispatchBatchSize": 1000 }, + "KpiHistory": { + "SampleInterval": "00:01:00", + "RetentionDays": 90, + "PurgeInterval": "1.00:00:00", + "DefaultMaxSeriesPoints": 200 + }, "Transport": { "SourceEnvironment": "docker-cluster" }, diff --git a/docker/central-node-b/appsettings.Central.json b/docker/central-node-b/appsettings.Central.json index a83e26f2..156f9c2e 100644 --- a/docker/central-node-b/appsettings.Central.json +++ b/docker/central-node-b/appsettings.Central.json @@ -64,6 +64,12 @@ "DispatchInterval": "00:00:05", "DispatchBatchSize": 1000 }, + "KpiHistory": { + "SampleInterval": "00:01:00", + "RetentionDays": 90, + "PurgeInterval": "1.00:00:00", + "DefaultMaxSeriesPoints": 200 + }, "Transport": { "SourceEnvironment": "docker-cluster" }, diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index 2c45478f..90461c9c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -699,6 +699,63 @@ akka {{ _actorSystem.ActorOf(auditReconProxyProps, "site-audit-reconciliation-proxy"); _logger.LogInformation("SiteAuditReconciliationActor singleton created"); + // KPI History (#26, M6) — central singleton that periodically samples the + // Notification Outbox / Site Call Audit point-in-time KPIs into the + // KpiHistorySamples table and runs the daily retention purge. Mirrors the + // audit-log-purge singleton pattern above: a ClusterSingletonManager pins + // the recorder to the active central node, a ClusterSingletonProxy gives a + // stable address, and a PhaseClusterLeave graceful-stop task drains the + // in-flight tick before handover. The recorder's sample + purge timers + // self-schedule in PreStart. Options come from AddKpiHistory (central + // composition root only). The actor takes the root IServiceProvider and + // opens its own per-tick DI scope (the KPI repository is a scoped EF Core + // service), so the 3 ctor args (IServiceProvider, KpiHistoryOptions, + // ILogger) are resolved here from DI exactly like the other singletons. + // NOT readiness-gated by design: KPI history is observability/best-effort + // (it must never gate /health/ready), so kpi-history-recorder is + // deliberately absent from RequiredSingletonsHealthCheck. + var kpiHistoryOptions = _serviceProvider + .GetRequiredService>().Value; + var kpiHistoryLogger = _serviceProvider.GetRequiredService() + .CreateLogger(); + + var kpiHistorySingletonProps = ClusterSingletonManager.Props( + singletonProps: Props.Create(() => new ZB.MOM.WW.ScadaBridge.KpiHistory.KpiHistoryRecorderActor( + _serviceProvider, + kpiHistoryOptions, + kpiHistoryLogger)), + terminationMessage: PoisonPill.Instance, + settings: ClusterSingletonManagerSettings.Create(_actorSystem!) + .WithSingletonName("kpi-history-recorder")); + var kpiHistorySingletonManager = + _actorSystem!.ActorOf(kpiHistorySingletonProps, "kpi-history-recorder-singleton"); + + var kpiHistoryShutdown = Akka.Actor.CoordinatedShutdown.Get(_actorSystem); + kpiHistoryShutdown.AddTask( + Akka.Actor.CoordinatedShutdown.PhaseClusterLeave, + "drain-kpi-history-recorder-singleton", + async () => + { + try + { + await kpiHistorySingletonManager.GracefulStop(TimeSpan.FromSeconds(10)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "KpiHistoryRecorder singleton did not drain within the graceful-stop " + + "timeout; falling through to PoisonPill handover"); + } + return Akka.Done.Instance; + }); + + var kpiHistoryProxyProps = ClusterSingletonProxy.Props( + singletonManagerPath: "/user/kpi-history-recorder-singleton", + settings: ClusterSingletonProxySettings.Create(_actorSystem) + .WithSingletonName("kpi-history-recorder")); + _actorSystem.ActorOf(kpiHistoryProxyProps, "kpi-history-recorder-proxy"); + _logger.LogInformation("KpiHistoryRecorderActor singleton created (not readiness-gated)"); + _logger.LogInformation("Central actors registered. CentralCommunicationActor created."); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 3f52a85e..66c736f9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -16,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors; using ZB.MOM.WW.ScadaBridge.Host.Health; using ZB.MOM.WW.ScadaBridge.InboundAPI; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; +using ZB.MOM.WW.ScadaBridge.KpiHistory; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.NotificationOutbox; using ZB.MOM.WW.ScadaBridge.NotificationService; @@ -110,6 +111,11 @@ try // but the call is here for symmetry with the other audit composition // roots so future per-actor DI lands without touching Program.cs. builder.Services.AddSiteCallAudit(); + // KPI History (#26, M6) — central-only. Binds KpiHistoryOptions from + // ScadaBridge:KpiHistory and registers the validated options consumed by + // the KpiHistoryRecorderActor cluster singleton (started in + // AkkaHostedService). Observability/best-effort: NOT readiness-gated. + builder.Services.AddKpiHistory(builder.Configuration); builder.Services.AddTemplateEngine(); builder.Services.AddDeploymentManager(); // Host is the composition root and owns config-coupled wiring: register the diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json index 32992628..1ce14c5d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json +++ b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json @@ -64,6 +64,12 @@ "PurgeInterval": "1.00:00:00", "DeliveredKpiWindow": "00:01:00" }, + "KpiHistory": { + "SampleInterval": "00:01:00", + "RetentionDays": 90, + "PurgeInterval": "1.00:00:00", + "DefaultMaxSeriesPoints": 200 + }, "Transport": { "BundleSessionTtlMinutes": 30, "MaxBundleSizeMb": 100,