diff --git a/docs/requirements/Component-ClusterInfrastructure.md b/docs/requirements/Component-ClusterInfrastructure.md index da57e3e4..7bbf201e 100644 --- a/docs/requirements/Component-ClusterInfrastructure.md +++ b/docs/requirements/Component-ClusterInfrastructure.md @@ -197,6 +197,8 @@ Differences from central failover, all deliberate: `akka.cluster.min-nr-of-members` must be set to **1**. After failover, only one node is running. If set to 2, the surviving node waits for a second member before allowing the Cluster Singleton (Site Runtime Deployment Manager) to start — blocking all data collection and script execution indefinitely. +`ScadaBridge:Cluster:AllowSingleNodeCluster` is **validation-only**: it merely relaxes the seed-count check (one seed instead of two) in `ClusterOptionsValidator`. It changes no runtime behaviour — not `min-nr-of-members` (always 1), not downing, not the bootstrap guard; a lone node forms its cluster because every node lists itself first in its seed list. The name is retained to avoid breaking deployed configs. + ## Failure Detection Timing Configurable defaults for heartbeat and failure detection: diff --git a/docs/requirements/Component-Host.md b/docs/requirements/Component-Host.md index 3768047b..4ad0a194 100644 --- a/docs/requirements/Component-Host.md +++ b/docs/requirements/Component-Host.md @@ -345,3 +345,10 @@ built)* section for where the shipped code deviates from the original design). - **CentralUI / InboundAPI**: The Host maps their web endpoints into the ASP.NET Core pipeline on central nodes. - **ManagementService**: The Host registers the ManagementActor on central nodes; the CLI reaches it over the HTTP `/management` endpoints (in-process via `ManagementActorHolder`), not via any cluster transport. No ClusterClientReceptionist is configured (removed in Phase 4). - **HealthMonitoring**: The Host's startup validation and logging configuration provide the foundation for health reporting. + +## Review 2026-10-21 notes (Host edge) + +- **Port options come from the bound `NodeOptions`.** `Program.cs` binds `ScadaBridge:Node` once and reads `GrpcPort` / `CentralGrpcPort` / `MetricsPort` from it; the 8083/8084 defaults live only in `NodeOptions`. Central's dedicated metrics listener remains an explicit *presence* check on the raw key (a defaulted read would bind a new port on every existing central node). +- **Inbound method warm-up is a hosted service** (`InboundMethodWarmupService`, registered after `AkkaHostedService`), not a pre-`RunAsync` loop — see Component-InboundAPI.md. +- **`SitePskProvider` distinguishes a secret-store fault from a missing secret.** A store fault logs a Warning with the exception and throws `SecretStoreUnavailableException` (not cached); only a genuinely absent/empty/tombstoned secret produces the "seed `SB-GRPC-PSK-{site}`" message. +- Central registers `ExternalSystemGatewayOptions.DefinitionCacheTtl = 10 s` ahead of `AddExternalSystemGateway()` so an explicit config value wins; sites leave it off. diff --git a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs index 8c47fcee..b104bed1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs @@ -112,6 +112,13 @@ public class ClusterOptions /// node (normally ≥2 are required so either node can start first). Without this /// flag a single-node install must list a phantom second seed, which the node /// dials forever — log noise and a defeated validation intent (review 01). + /// + /// Validation-only. This flag changes nothing at runtime: it only relaxes + /// ClusterOptionsValidator's seed-count check. It does not touch + /// min-nr-of-members, the downing strategy or the bootstrap guard — a lone node + /// forms its cluster because it lists ITSELF first (self-first seed ordering), not because + /// of this flag. The name is kept (renaming would break deployed configs) (review 2026-10-21 08/O4). + /// /// public bool AllowSingleNodeCluster { get; set; } diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 4bf0a7ba..bd73e44f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -131,6 +131,11 @@ await using (var secretsProvider = expanderServices.BuildServiceProvider()) // Full startup validation — fail fast before any DI or actor system setup StartupValidator.Validate(configuration); +// Bound node options: the single home of port defaults (NodeOptions), so the Kestrel setup below +// never repeats literal 8083/8084 defaults at the call sites (review 2026-10-21 08/O5). +var nodeOptions = configuration.GetSection("ScadaBridge:Node").Get() + ?? new ZB.MOM.WW.ScadaBridge.Host.NodeOptions(); + // Read node options for Serilog enrichment var nodeRole = configuration["ScadaBridge:Node:Role"]!; var nodeHostname = configuration["ScadaBridge:Node:NodeHostname"] ?? "unknown"; @@ -188,7 +193,7 @@ try // The site branch has the same ConfigureKestrel shape but no ASPNETCORE_URLS surface // to lose (it binds every port it needs — gRPC + metrics — explicitly). Central must // therefore RE-BIND its HTTP port(s) here alongside the gRPC port. - var centralGrpcPort = configuration.GetValue("ScadaBridge:Node:CentralGrpcPort", 8083); + var centralGrpcPort = nodeOptions.CentralGrpcPort; // "urls" is WebHostDefaults.ServerUrlsKey — the host setting ASPNETCORE_URLS/--urls // populate. Read it as a literal so this needs no extra Hosting using. var httpUrls = configuration["ASPNETCORE_URLS"] @@ -296,6 +301,12 @@ try builder.Services.AddHealthMonitoring(); builder.Services.AddCentralHealthAggregation(); + // Central resolves definitions from SQL Server on every call; cache them briefly + // (review 2026-10-21 05/P2). Registered BEFORE AddExternalSystemGateway so an explicit + // ScadaBridge:ExternalSystemGateway:DefinitionCacheTtl binding still wins. Sites keep it + // off — their repository is already a deploy-invalidated snapshot. + builder.Services.Configure( + o => o.DefinitionCacheTtl = TimeSpan.FromSeconds(10)); builder.Services.AddExternalSystemGateway(); builder.Services.AddNotificationService(); @@ -593,6 +604,12 @@ try // routes against agree on which node is active. builder.Services.AddSingleton(); + // Warm the inbound method compile cache in the BACKGROUND once the host is running and + // this node is the active one (review 2026-10-21 05/U3). Registered after + // AkkaHostedService so it never sits in front of the cluster join; the eager compile that + // used to block here before app.RunAsync() is gone. + builder.Services.AddHostedService(); + // Admin-triggered manual failover of the central pair (Health page control, // decision 2026-07-22). Central-only: the seam is declared in CentralUI so that // project stays Akka-free, and only this branch has a cluster to act on. @@ -745,17 +762,8 @@ try app.MapDeploymentConfigAPI(); app.MapHub("/hubs/debug-stream"); - // Compile and register all Inbound API method scripts at startup - using (var scope = app.Services.CreateScope()) - { - var apiRepo = scope.ServiceProvider.GetRequiredService(); - var executor = app.Services.GetRequiredService(); - var methods = await apiRepo.GetAllApiMethodsAsync(); - // Parallel: CompileAndRegister is safe for concurrent callers (ConcurrentDictionary - // caches, no shared mutable compile state); serial Roslyn compiles stretch the - // failover-recovery window at hundreds of methods. - Parallel.ForEach(methods, method => executor.CompileAndRegister(method)); - } + // Inbound API method scripts are pre-compiled by InboundMethodWarmupService after startup + // (background, active node only); the per-request lazy compile guarantees correctness. await app.RunAsync(); } @@ -778,15 +786,15 @@ try // Windows Service support (no-op when not running as a Windows Service) builder.Host.UseWindowsService(); - // Read GrpcPort from config (NodeOptions already has default 8083) - var grpcPort = configuration.GetValue("ScadaBridge:Node:GrpcPort", 8083); + // GrpcPort from the bound NodeOptions (default 8083) + var grpcPort = nodeOptions.GrpcPort; - // Read MetricsPort from config (NodeOptions already has default 8084). + // MetricsPort from the bound NodeOptions (default 8084). // Separate HTTP/1.1 listener so a standard HTTP/1.1 Prometheus scraper can // reach /metrics; the gRPC port stays HTTP/2-only below. The default is // 8084 — distinct from RemotingPort (8082, Akka) and GrpcPort (8083) so the // metrics listener never collides with the Akka remoting port on site nodes. - var metricsPort = configuration.GetValue("ScadaBridge:Node:MetricsPort", 8084); + var metricsPort = nodeOptions.MetricsPort; // Configure Kestrel for HTTP/2 only on the gRPC port (plus the h2 keepalive that // makes a half-open inbound stream detectable) — see SiteKestrelSetup.