docs(archreview): add architecture-review fix plans (P0 initiative baseline)
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
# Architecture Review 01 — Cluster Infrastructure, Host, Failover, Health Monitoring, Deployment Topology
|
||||
|
||||
Reviewer domain: `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure`, `src/ZB.MOM.WW.ScadaBridge.Host`,
|
||||
`src/ZB.MOM.WW.ScadaBridge.HealthMonitoring`, `docker/`, `docker-env2/`, `deploy/wonder-app-vd03/`,
|
||||
Traefik config, and the corresponding design docs (`Component-ClusterInfrastructure.md`,
|
||||
`Component-Host.md`, `Component-HealthMonitoring.md`, `Component-TraefikProxy.md`) and test projects.
|
||||
|
||||
## Scope
|
||||
|
||||
Read in full: `AkkaHostedService.cs` (1,203 lines — HOCON builder, singleton wiring, coordinated
|
||||
shutdown), `Program.cs` (both role branches), `StartupValidator.cs`, `StartupRetry.cs`,
|
||||
`SiteServiceRegistration.cs`, `NodeOptions.cs`, `ClusterOptions.cs` + validator +
|
||||
`ServiceCollectionExtensions.cs`, all Host `Health/` checks (`RequiredSingletonsHealthCheck`,
|
||||
`ActiveNodeGate`, `AkkaClusterNodeProvider`), `DeadLetterMonitorActor.cs`,
|
||||
`AkkaHealthReportTransport.cs`, the entire HealthMonitoring project (`HealthReportSender`,
|
||||
`CentralHealthAggregator`, `CentralHealthReportLoop`, `SiteHealthCollector`,
|
||||
`SiteEventLogFailureCountReporter`, `SiteHealthKpiSampleSource`, options + validator), the docker
|
||||
compose topologies + per-node appsettings + Traefik static/dynamic config + Dockerfile, the
|
||||
on-host deploy artifacts (`deploy/wonder-app-vd03/appsettings.*.json`), the four design docs, the
|
||||
repo's own Akka.NET reference notes (`AkkaDotNet/03-Cluster.md`), and the relevant test projects
|
||||
(`Host.Tests`, `ClusterInfrastructure.Tests`, `HealthMonitoring.Tests`, `IntegrationTests`
|
||||
failover/readiness suites). Health-report ingestion on the central side was cross-checked in
|
||||
`Communication/Actors/CentralCommunicationActor.cs:349-392`.
|
||||
|
||||
## Maturity Verdict
|
||||
|
||||
This slice of the codebase is unusually well-commented, defensively coded at the micro level
|
||||
(options validators with `ValidateOnStart`, CAS loops in the aggregator, sequence-number seeding
|
||||
for failover, HOCON escaping, bounded startup retry), and clearly the product of many careful
|
||||
review passes — but it has **one macro-level defect that undoes the entire failover design: the
|
||||
split-brain resolver is configured but never enabled** (no `akka.cluster.downing-provider-class`),
|
||||
so unreachable nodes are never automatically downed and cluster singletons never migrate on a hard
|
||||
crash. Every document, comment, and validator in the domain reasons meticulously about keep-oldest
|
||||
semantics that are inert at runtime, and the test suite asserts the HOCON *strings* rather than the
|
||||
*behavior*, which is exactly how the gap survived. Secondary structural issues cluster around the
|
||||
ambiguous definition of "active node" (cluster *leader* vs. singleton-hosting *oldest* member,
|
||||
which diverge after restarts) and dev-vs-prod configuration drift. Verdict: **high polish, one
|
||||
critical foundation crack; not production-ready for unattended failover until the downing provider
|
||||
is fixed and verified with a real two-node test.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Stability
|
||||
|
||||
### [Critical] Split-brain resolver is never enabled — automatic failover does not work for crashes/partitions
|
||||
|
||||
`AkkaHostedService.BuildHocon` (src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:225-270)
|
||||
emits:
|
||||
|
||||
```hocon
|
||||
cluster {
|
||||
...
|
||||
split-brain-resolver {
|
||||
active-strategy = "keep-oldest"
|
||||
stable-after = 15000ms
|
||||
keep-oldest { down-if-alone = on }
|
||||
}
|
||||
failure-detector { ... }
|
||||
run-coordinated-shutdown-when-down = on
|
||||
}
|
||||
```
|
||||
|
||||
but never sets `akka.cluster.downing-provider-class = "Akka.Cluster.SBR.SplitBrainResolverProvider,
|
||||
Akka.Cluster"`. In Akka.NET (1.5.62 per `Directory.Packages.props:8-17`), the default downing
|
||||
provider is **NoDowning**; the whole `split-brain-resolver` section is only read by
|
||||
`SplitBrainResolverProvider`, which must be opted into via `downing-provider-class`. A repo-wide
|
||||
grep confirms the string `SplitBrainResolverProvider` appears **nowhere in `src/`** — only in the
|
||||
project's own reference notes, which show the required line
|
||||
(AkkaDotNet/03-Cluster.md:56, also 15-Coordination.md:69 and 18-MultiNodeTestRunner.md:100).
|
||||
|
||||
Failure scenario: the active site node loses power. The standby's failure detector marks it
|
||||
unreachable (~10s), but **nothing ever downs it**. The unreachable member stays in the membership
|
||||
forever, the `ClusterSingletonManager` cannot hand over (singleton migration requires the oldest
|
||||
member's *removal*, not mere unreachability), and the site's DeploymentManager singleton — and with
|
||||
it all data collection, script execution, and S&F delivery — never restarts on the survivor. The
|
||||
same applies to all six central singletons (notification-outbox, audit-log-ingest, site-call-audit,
|
||||
audit-log-purge, site-audit-reconciliation, kpi-history-recorder). The documented "~25 seconds
|
||||
total failover" (docs/requirements/Component-ClusterInfrastructure.md:110-119) is only achievable
|
||||
today for a **graceful** stop (CoordinatedShutdown leave); crash and partition scenarios hang
|
||||
indefinitely until an operator downs the node or restarts the survivor.
|
||||
|
||||
Corroborating evidence that this was never exercised:
|
||||
|
||||
- `HoconBuilderTests.cs:45,112,155` assert only that the SBR *strings* appear in the config.
|
||||
- `CoordinatedShutdownTests.cs:11-43` are source-text greps (`Assert.Contains("run-by-clr-shutdown-hook = on", content)`).
|
||||
- The "failover" integration tests are single-process simulations — e.g.
|
||||
`CentralFailoverTests.cs:27-47` validates a JWT across two `JwtTokenService` instances; no test
|
||||
forms a two-node cluster and kills the oldest node.
|
||||
- `run-coordinated-shutdown-when-down = on` (AkkaHostedService.cs:265) is dead config: a node is
|
||||
never transitioned to Down by anything.
|
||||
- `ClusterOptionsValidator` (src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs:35-63)
|
||||
rigorously rejects any strategy other than `keep-oldest` and demands `DownIfAlone == true` —
|
||||
validating knobs on a resolver that is not installed.
|
||||
|
||||
Fix: add the `downing-provider-class` line to `BuildHocon` (one line), or better, migrate the
|
||||
bootstrap to `Akka.Cluster.Hosting`'s typed options (already referenced,
|
||||
ZB.MOM.WW.ScadaBridge.Host.csproj:15) whose `SplitBrainResolverOption` wires the provider
|
||||
automatically — and add a real two-node in-process cluster test that kills the oldest member and
|
||||
asserts the singleton re-hosts within the expected window.
|
||||
|
||||
### [High] "Active node" means two different things — cluster leader vs. oldest member — and they diverge
|
||||
|
||||
Two distinct node-selection concepts are used interchangeably:
|
||||
|
||||
- **Leader-based** (address ordering, *not* age): `ActiveNodeGate.IsActiveNode`
|
||||
(src/ZB.MOM.WW.ScadaBridge.Host/Health/ActiveNodeGate.cs:36-52),
|
||||
`AkkaClusterNodeProvider.SelfIsPrimary` and the "Primary"/"Standby" labels
|
||||
(src/ZB.MOM.WW.ScadaBridge.Host/Health/AkkaClusterNodeProvider.cs:29-40,61), the `active-node`
|
||||
health check Traefik routes on (Program.cs:231-234, docker/traefik/dynamic.yml:12-18), the
|
||||
`CentralHealthReportLoop` leader gate (CentralHealthReportLoop.cs:93-97), and the site
|
||||
event-log purge gate (`SiteEventLogActiveNodeCheck` → `SelfIsPrimary`,
|
||||
SiteServiceRegistration.cs:116-120).
|
||||
- **Oldest-based** (singleton placement): every `ClusterSingletonManager` (AkkaHostedService.cs:410+,
|
||||
906-925), and the site health "IsActiveNode" flag, which is set by the DeploymentManager
|
||||
*singleton* on start/stop (src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:243,258).
|
||||
|
||||
In Akka the leader is the lowest-address `Up` member; the singleton host is the *oldest* member.
|
||||
They coincide at first boot but diverge permanently after the original first node is restarted and
|
||||
rejoins (it regains leadership by address order while the peer remains oldest). Concrete
|
||||
consequences once diverged:
|
||||
|
||||
1. **Site event-log retention is unenforced on the node that writes events.** Events are recorded
|
||||
by the singleton hierarchy on the oldest node; the purge (`EventLogPurgeService.cs:21,36`)
|
||||
early-exits unless `SelfIsPrimary` (leader). The active/oldest node's SQLite grows past the
|
||||
30-day/1GB cap while the leader purges an idle database.
|
||||
2. **Health dashboard labels the wrong node "Primary"** (AkkaClusterNodeProvider.cs:61): the node
|
||||
named Primary hosts no singletons; the node doing all the work shows Standby.
|
||||
3. **Central self-report is emitted by the leader** (CentralHealthReportLoop.cs:93) while the
|
||||
NotificationOutbox/audit singletons run on the other node — the `$central` health card describes
|
||||
the wrong node.
|
||||
4. **Traefik routes UI/API to the leader** while every singleton `Ask` then hops to the peer —
|
||||
functionally correct via the proxies, but it contradicts the design docs' "one active node runs
|
||||
all central components" model (Component-ClusterInfrastructure.md:44-47) and doubles
|
||||
intra-cluster latency on every outbox/audit page load.
|
||||
|
||||
Recommendation: pick one definition. Simplest coherent choice: derive "active" from singleton-host
|
||||
status (e.g. compare self against the oldest `Up` member, or gate on reachability of a local
|
||||
singleton), and use it consistently for `ActiveNodeGate`, `SelfIsPrimary`, the purge gate, and the
|
||||
`/health/active` check — leadership is an Akka-internal concept that only accidentally matches the
|
||||
product's active/standby model.
|
||||
|
||||
### [High] During a central network partition, both nodes report `/health/active = 200` and Traefik serves both
|
||||
|
||||
`ActiveNodeGate`/the active-node check compute `cluster.State.Leader` from each node's own gossip
|
||||
view. When central-a and central-b cannot see each other but Traefik can reach both (a plausible
|
||||
partial partition), each side sees the peer as unreachable and computes **itself** as leader — both
|
||||
return 200 on `/health/active`, and Traefik's load balancer (docker/traefik/dynamic.yml:10-18)
|
||||
round-robins requests between two nodes that each believe they are active. Blazor circuits and the
|
||||
Inbound API's active-node 503 gate (Program.cs:256-260) are then split across both. Because the SBR
|
||||
is not enabled (Critical finding above), nothing ever resolves the partition — this state persists
|
||||
until manual intervention. Singletons do *not* duplicate (that requires member removal), but every
|
||||
leader-gated behavior does. Enabling keep-oldest + `down-if-alone` fixes the persistence; consider
|
||||
also making `/health/active` require `Ready` (DB reachable) in addition to leadership so a
|
||||
DB-dead leader is pulled from rotation.
|
||||
|
||||
### [Medium] Cluster-leave drain tasks are budgeted 10s inside a 5s CoordinatedShutdown phase
|
||||
|
||||
Five singletons add a `PhaseClusterLeave` task that runs `GracefulStop(TimeSpan.FromSeconds(10))`
|
||||
(AkkaHostedService.cs:551-567 site-call-audit, 624-641 audit-log-purge, 676-693
|
||||
site-audit-reconciliation, 733-750 kpi-history-recorder, 787-804 pending-deployment-purge). Akka's
|
||||
`cluster-leave` phase uses the default phase timeout of **5 seconds**; the phase abandons the task
|
||||
at 5s (default `recover = on`) and proceeds, so the 10s graceful-stop can never complete its full
|
||||
window and the `catch` logging in those tasks never observes the overrun (the task is orphaned, not
|
||||
faulted). The intended "let the in-flight EF upsert drain before handover" guarantee is silently
|
||||
halved. Fix: set `akka.coordinated-shutdown.phases.cluster-leave.timeout` in `BuildHocon` to
|
||||
exceed the graceful-stop budget, or shrink the `GracefulStop` timeout below 5s.
|
||||
|
||||
### [Medium] The two busiest singletons have no drain task at all
|
||||
|
||||
The `NotificationOutboxActor` and `AuditLogIngestActor` singletons are terminated by bare
|
||||
`PoisonPill` with no cluster-leave drain (AkkaHostedService.cs:410-419, 451-458); the comment at
|
||||
AkkaHostedService.cs:544-549 explicitly notes the pattern "is suitable for the NotificationOutbox
|
||||
singleton; not added here to keep this change minimal". These are precisely the actors with
|
||||
in-flight EF transactions on every message (dispatch sweeps, dual-write ingest). The idempotent
|
||||
DB design (insert-if-not-exists on `NotificationId`/`EventId`) makes this safe-but-lossy rather
|
||||
than corrupting, but the inconsistency (drain for the low-traffic maintenance singletons, none for
|
||||
the hot ones) should be resolved.
|
||||
|
||||
### [Medium] Health-report loss recovery is dead code on the production transport
|
||||
|
||||
`HealthReportSender` carefully restores interval counters if `_transport.Send` throws
|
||||
(HealthReportSender.cs:158-175), and `SiteHealthCollector.AddIntervalCounters`
|
||||
(SiteHealthCollector.cs:153-171) exists to support it. But the only production transport is
|
||||
`AkkaHealthReportTransport.Send` (src/ZB.MOM.WW.ScadaBridge.Host/AkkaHealthReportTransport.cs:26-33)
|
||||
— a fire-and-forget `Tell` to an `ActorSelection`, which **never throws** and silently returns when
|
||||
the actor system is null. Any downstream loss (central unreachable, ClusterClient reconnecting,
|
||||
dead-lettered selection) drops the report *and* the interval error counters it carried — the
|
||||
scenario the restore logic was built for is exactly the one it can't see. Either make the transport
|
||||
Ask-with-ack (the site→central notification forward already uses this pattern over the same actor)
|
||||
or accept the loss and delete the restore machinery; the current shape gives false confidence.
|
||||
|
||||
### [Medium] Offline detection deviates from the spec and can mask a dead metrics pipeline
|
||||
|
||||
`Component-HealthMonitoring.md:43-46` defines offline as "no *report* within 60s".
|
||||
`CentralHealthAggregator.CheckForOfflineSites` (CentralHealthAggregator.cs:224-263) instead keys
|
||||
off `LastHeartbeatAt`, which is also advanced by the ~5s transport heartbeats
|
||||
(`CentralCommunicationActor.HandleHeartbeat` → `MarkHeartbeat`, CentralCommunicationActor.cs:349-353).
|
||||
A site whose `HealthReportSender` loop has died (or whose DeploymentManager singleton is stuck, so
|
||||
`IsActiveNode` is false on both nodes and reports are skipped — HealthReportSender.cs:85) keeps
|
||||
heartbeating and shows **online with silently frozen metrics forever**; nothing surfaces
|
||||
`LastReportReceivedAt` staleness. The heartbeat-based liveness choice is defensible (the code
|
||||
comment argues it), but then the dashboard needs a distinct "metrics stale" signal, and the design
|
||||
doc should be updated — currently spec and code disagree.
|
||||
|
||||
### [Medium] Second seed node in the checked-in site dev config targets the metrics port; validator has a gap
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json:14-17` seeds
|
||||
`akka.tcp://scadabridge@localhost:8082` and `akka.tcp://scadabridge@localhost:8084`, while the same
|
||||
file sets `MetricsPort: 8084` (line 10). The second seed points at the Kestrel HTTP/1.1 metrics
|
||||
listener — a node attempting to join via that seed performs a doomed Akka.Remote association
|
||||
against Kestrel (handshake noise, retry loops). `StartupValidator` explicitly guards seed-vs-GrpcPort
|
||||
(StartupValidator.cs:121-127) but not seed-vs-MetricsPort, so this config validates cleanly. Fix
|
||||
the dev config (8084 → the intended second node's remoting port) and extend the seed-port loop to
|
||||
also reject `MetricsPort`.
|
||||
|
||||
### [Medium] Docker stop grace period is shorter than a full CoordinatedShutdown
|
||||
|
||||
`docker/docker-compose.yml` sets no `stop_grace_period` (default 10s SIGTERM→SIGKILL) and the
|
||||
Dockerfile runs `dotnet` as PID 1 (docker/Dockerfile, final stage). A graceful shutdown runs
|
||||
cluster-leave (5s budget) + cluster-exiting (10s) + actor-system-terminate plus Serilog flush —
|
||||
easily exceeding 10s on a loaded node, so `bash docker/deploy.sh` recreates can SIGKILL mid-drain,
|
||||
turning every "graceful" redeploy into the crash path (which, per the Critical finding, currently
|
||||
has no automatic recovery on the surviving node). Add `stop_grace_period: 30s` to the app services.
|
||||
|
||||
### [Low] Aggregator never forgets sites
|
||||
|
||||
`CentralHealthAggregator._siteStates` (CentralHealthAggregator.cs:16) only grows: a site deleted
|
||||
from configuration remains a permanently-offline dashboard tile (and continues to be enumerated by
|
||||
`SiteHealthKpiSampleSource.CollectAsync`, SiteHealthKpiSampleSource.cs:74-85) until process
|
||||
restart. Needs an eviction path wired to site deletion.
|
||||
|
||||
### [Low] Dead-letter monitor: unbounded warning volume and restart-reset count
|
||||
|
||||
`DeadLetterMonitorActor` logs one Warning per dead letter with no sampling/rate-limiting and keeps
|
||||
its count in actor state that resets on restart
|
||||
(src/ZB.MOM.WW.ScadaBridge.Host/Actors/DeadLetterMonitorActor.cs:14,26-35). A dead-letter storm
|
||||
(mass undeploy, failover, shutdown) floods the log; the health metric path
|
||||
(`IncrementDeadLetter`) is fine because the collector counter is interval-based.
|
||||
|
||||
### [Low] Mandatory two-seed rule forces phantom seeds in single-node installs
|
||||
|
||||
`ClusterOptionsValidator` requires ≥2 seed nodes
|
||||
(ClusterOptionsValidator.cs:29-33); the shipped single-node production artifact satisfies it with
|
||||
a documented placeholder (`deploy/wonder-app-vd03/appsettings.Central.json:9-19`, second seed
|
||||
`localhost:8091` "harmless while absent"). The node dials the phantom seed forever (log noise,
|
||||
periodic connect attempts), and the rule's intent — no startup-ordering dependency — is defeated
|
||||
by fiction. Consider allowing 1 seed with a warning, or a `SingleNode` acknowledgement flag.
|
||||
|
||||
## 2. Performance
|
||||
|
||||
This domain is largely cold-path and correctly engineered for it; nothing here touches the
|
||||
data-plane hot path. Observations:
|
||||
|
||||
- **[Low] Readiness probes fan out five cluster Asks per poll.**
|
||||
`RequiredSingletonsHealthCheck` sends 5 concurrent `Identify` Asks through singleton proxies
|
||||
every `/health/ready` poll (RequiredSingletonsHealthCheck.cs:66-78,111-117). When the singletons
|
||||
live on the peer node each probe is a remoting round trip. Bounded (2s, concurrent) and cheap at
|
||||
typical poll rates, but a tight orchestration probe interval multiplies cluster chatter.
|
||||
- **[Low] Startup compiles every inbound API method sequentially before serving**
|
||||
(Program.cs:379-388). Startup latency scales linearly with method count; a slow Roslyn compile of
|
||||
many methods delays readiness on the *standby* too (it runs the same branch). Acceptable now;
|
||||
parallelize or defer-compile if method counts grow.
|
||||
- **[Low] Per-tick allocations in health collection** (dictionary snapshots per 30s report,
|
||||
`GetAllSiteStates` copy per dashboard poll — CentralHealthAggregator.cs:173-176) are trivial at
|
||||
this cadence. No action.
|
||||
- **Positive:** the site audit telemetry actor is pinned to a dedicated dispatcher
|
||||
(`audit-telemetry-dispatcher`, AkkaHostedService.cs:226-232,1150-1158) so batch SQLite/gRPC work
|
||||
never contends with hot-path actors; `StoreAndForwardService.StartAsync` is awaited rather than
|
||||
sync-blocked (AkkaHostedService.cs:976-981); the ActorSystem DI bridge is a root singleton
|
||||
precisely to avoid per-scope disposal (Program.cs:240-250) — all correct.
|
||||
|
||||
## 3. Conventions
|
||||
|
||||
- **[Medium] REQ-HOST-6 says "Akka.NET bootstrap via Akka.Hosting"; the code hand-rolls HOCON.**
|
||||
`Component-Host.md` (REQ-HOST-6, line 106-114) mandates Akka.Hosting; the actual bootstrap is
|
||||
string-interpolated HOCON + `ActorSystem.Create` (AkkaHostedService.cs:171-176,214-271) with
|
||||
`Akka.Hosting`/`Akka.Cluster.Hosting`/`Akka.Remote.Hosting` referenced but unused for cluster
|
||||
bring-up (ZB.MOM.WW.ScadaBridge.Host.csproj:15-18). This is not just doc drift: Akka.Cluster.Hosting's
|
||||
typed `SplitBrainResolverOption` sets `downing-provider-class` for you — using the mandated API
|
||||
would have prevented the Critical finding. Either implement REQ-HOST-6 or amend it and drop the
|
||||
unused packages.
|
||||
- **[High] Production deploy artifact ships with authentication disabled.**
|
||||
`deploy/wonder-app-vd03/appsettings.Central.json:39-42` sets `"DisableLogin": true` — every
|
||||
request auto-authenticates as `multi-role` with all roles, and the management API on that host is
|
||||
unauthenticated. The base config documents this as "DEV/TEST ONLY — never enable in production"
|
||||
with "no environment guard; a startup warning is the only protection"
|
||||
(src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json:6). A `deploy/` artifact with an install
|
||||
runbook is a production surface; at minimum gate `DisableLogin` on
|
||||
`Environment.IsDevelopment()` or require an explicit `I-understand` companion flag.
|
||||
- **[Medium] LDAP transport is plaintext everywhere, contradicting the security spec.** The design
|
||||
requires "LDAPS/StartTLS required" (CLAUDE.md Security & Auth; Component-Security), but every
|
||||
shipped config — docker (docker/central-node-a/appsettings.Central.json:26-33) and the on-host
|
||||
deploy (deploy/wonder-app-vd03/appsettings.Central.json:26-34) — sets `"Transport": "None",
|
||||
"AllowInsecure": true` and sends the service-account bind password in clear. Dev GLAuth is a fair
|
||||
excuse for docker; the deploy artifact should not normalize it.
|
||||
- **[Medium] Secrets in checked-in dev configs.** SQL credentials, LDAP service-account password
|
||||
and the JWT signing key are committed in `docker/central-node-a/appsettings.Central.json:21-38`
|
||||
(labelled dev-only; the compose pepper is likewise flagged, docker-compose.yml:9-14). The
|
||||
wonder-app-vd03 artifact handles this correctly via `${...}` placeholders + env injection
|
||||
(appsettings.Central.json:20-24) — good pattern, worth backporting a scrubbed variant to docker
|
||||
if these ever leave a lab network. Also note `ConfigSecretsTests.cs` exists in Host.Tests, so the
|
||||
team is tracking this.
|
||||
- **[Low] Stale doc references.** `Component-TraefikProxy.md:121` locates `ActiveNodeHealthCheck`
|
||||
at `src/ZB.MOM.WW.ScadaBridge.Host/Health/ActiveNodeHealthCheck.cs`; the check now comes from the
|
||||
shared `ZB.MOM.WW.Health.Akka` package (Program.cs:214-234) and no such file exists.
|
||||
`Component-TraefikProxy.md:115` also still describes `/health/ready` as "database + Akka
|
||||
cluster" — it now includes `required-singletons` (Program.cs:227-230; Component-Host.md REQ-HOST-4a
|
||||
*is* current). Keep the Traefik doc in sync.
|
||||
- **[Low] Five copy-pasted ~60-line singleton registration blocks.**
|
||||
AkkaHostedService.RegisterCentralActors repeats the manager/drain-task/proxy triple five times
|
||||
(AkkaHostedService.cs:399-811) with only names and Props varying. Extract a
|
||||
`StartCentralSingleton(name, props, drainTimeout)` helper — the copy-paste already caused drift
|
||||
(outbox/ingest lack drain tasks).
|
||||
- **Positive conventions compliance:** Options pattern is followed rigorously (component-owned
|
||||
options classes, `ValidateOnStart`, dedicated validators with key-naming messages —
|
||||
ClusterOptionsValidator, HealthMonitoringOptionsValidator); HOCON interpolation is
|
||||
injection-safe (`QuoteHocon`/`DurationHocon`, AkkaHostedService.cs:280-295); Serilog enrichment
|
||||
matches REQ-HOST-8; startup validation matches REQ-HOST-4 including cross-field port-collision
|
||||
rules; single-binary role dispatch matches REQ-HOST-1/2; the `/health/ready` vs `/health/active`
|
||||
tier separation (leadership deliberately excluded from readiness) is well-reasoned and
|
||||
documented in place (Program.cs:346-356).
|
||||
|
||||
## 4. Underdeveloped Areas
|
||||
|
||||
1. **No real multi-node failover test anywhere.** `CentralFailoverTests` are single-process
|
||||
simulations (JWT across two service instances, CentralFailoverTests.cs:27-47);
|
||||
`SiteFailoverTests`/`DualNodeRecoveryTests` similarly exercise component state recovery, not
|
||||
cluster membership; `CoordinatedShutdownTests` grep source text (CoordinatedShutdownTests.cs:11-43);
|
||||
`HoconBuilderTests` assert config strings. The repo's own notes describe the MultiNode test
|
||||
runner (AkkaDotNet/18-MultiNodeTestRunner.md) but it is unused. This is the direct cause of the
|
||||
Critical finding surviving: nothing ever killed the oldest node and waited for the singleton to
|
||||
move. Even a plain xunit test spinning two `ActorSystem`s in-process and calling
|
||||
`cluster.Down()`/terminating one would have caught it.
|
||||
2. **Windows Service lifecycle management is thin.** `Component-ClusterInfrastructure.md:19`
|
||||
claims "Manage Windows service lifecycle (start, stop, restart)"; the implementation is
|
||||
`UseWindowsService()` (Program.cs:74,401) plus install scripts. Service recovery actions
|
||||
(restart-on-failure), the watchdog implied by `down-if-alone` ("an external mechanism must
|
||||
restart both nodes" — AkkaDotNet/03-Cluster.md:64) do not exist in the repo; after a
|
||||
down-if-alone self-down the process presumably exits (or worse, idles) with nothing to restart
|
||||
it. The down-if-alone recovery story needs an explicit design (does the process exit? does the
|
||||
service restart it? does it rejoin cleanly?) and a drill.
|
||||
3. **TLS is absent at every layer of this domain**: Traefik terminates plain HTTP with an
|
||||
unauthenticated dashboard (docker/traefik/traefik.yml:5-7 — documented as dev-only in
|
||||
Component-TraefikProxy.md:131-138), Akka remoting is unencrypted TCP, site gRPC is `http://`.
|
||||
Fine for the lab; there is no production TLS profile or documented path yet.
|
||||
4. **Dockerfile has no `HEALTHCHECK`** and compose has no health-based dependency ordering;
|
||||
liveness `/healthz` runs zero checks (Program.cs:353-355) so a wedged actor system still
|
||||
reports live. Acceptable minimalism, but an orchestrator migration will need both.
|
||||
5. **`NodeName` is missing from the wonder-app-vd03 deploy overlays**
|
||||
(deploy/wonder-app-vd03/appsettings.Central.json has no `Node.NodeName`), so audit rows from
|
||||
that install get a NULL `SourceNode` (the base file documents the normalisation,
|
||||
src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json:3) — undermines the per-node audit/KPI
|
||||
features (`IX_AuditLog_Node_Occurred`, per-node stuck KPIs) on the one real deployment.
|
||||
6. **Known-but-open follow-ups acknowledged in code/docs:** SecuredWrite audit rows leave
|
||||
`SourceNode` NULL (CLAUDE.md notes it as logged); no central persistence of OPC UA cert trust;
|
||||
`docker-env2` mirrors the main topology (verified: same Traefik config shape, same
|
||||
seed/port conventions) so it inherits every finding above.
|
||||
7. **Health monitoring has no history by design** (Component-HealthMonitoring.md:93-97) — now
|
||||
partially superseded by KPI History (#26), which the docs handle; but online/offline
|
||||
*transitions* are still unrecorded anywhere (doc line 97 defers it), so post-incident "when did
|
||||
the site drop" questions remain unanswerable beyond log scraping.
|
||||
|
||||
## Prioritized Recommendations
|
||||
|
||||
1. **[Critical, one line + tests] Enable the split-brain resolver.** Add
|
||||
`downing-provider-class = "Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster"` to
|
||||
`BuildHocon` (AkkaHostedService.cs:250) — or move the bootstrap to Akka.Cluster.Hosting per
|
||||
REQ-HOST-6, which sets it via typed options. Then add a two-node in-process failover test
|
||||
(kill oldest → assert singleton re-hosted, member removed within stable-after + margin) and a
|
||||
documented manual drill against the docker cluster (`docker kill scadabridge-central-a`, watch
|
||||
Traefik + singleton logs).
|
||||
2. **[High] Unify the "active node" definition** on singleton-host (oldest) semantics for
|
||||
`ActiveNodeGate`, `AkkaClusterNodeProvider.SelfIsPrimary`, the event-log purge gate, and the
|
||||
`/health/active` check; today's leader-based checks purge the wrong node's event log and label
|
||||
the wrong node Primary once leader ≠ oldest.
|
||||
3. **[High] Remove `DisableLogin: true` from `deploy/wonder-app-vd03`** or add a hard environment
|
||||
guard around the flag.
|
||||
4. **[Medium] Fix the CoordinatedShutdown drain budget** (`cluster-leave` phase timeout ≥
|
||||
graceful-stop timeout) and add drain tasks for the notification-outbox and audit-log-ingest
|
||||
singletons — or delete all five drains if the idempotent-DB argument makes them unnecessary.
|
||||
5. **[Medium] Fix `appsettings.Site.json` seed `localhost:8084`** and extend `StartupValidator`'s
|
||||
seed-port loop to reject `MetricsPort` collisions.
|
||||
6. **[Medium] Make health-report delivery observable**: switch `AkkaHealthReportTransport` to an
|
||||
acked Ask (reusing the existing site→central forward pattern) so the counter-restore logic in
|
||||
`HealthReportSender` actually fires, and surface `LastReportReceivedAt` staleness on the
|
||||
dashboard so heartbeat-alive/metrics-dead sites are visible.
|
||||
7. **[Medium] Add `stop_grace_period: 30s`** to the app services in both compose files.
|
||||
8. **[Low] Housekeeping:** evict deleted sites from `CentralHealthAggregator`; rate-limit
|
||||
dead-letter warnings; extract the singleton-registration helper; sync
|
||||
`Component-TraefikProxy.md` with the shared health-check library; add `NodeName` to the
|
||||
wonder-app-vd03 overlays; reconsider the mandatory-two-seeds rule for single-node installs.
|
||||
Reference in New Issue
Block a user