docs(health): re-verify per-app probe coverage against the SPEC; refresh GAPS and current-state (scadaproj#11)

Audited the registration and mapping code of each app at its origin default branch: OtOpcUa
c904472a, MxAccessGateway de86f8b8, ScadaBridge c7d868c5, HistorianGateway 4d6a9ad (all on Health
0.3.0, all MapZbHealth with default paths: /healthz runs nothing, /health/ready and /health/active
by tag). GAPS.md now carries the verified per-endpoint coverage matrix and a SPEC-by-app table;
each current-state doc lists exactly what each endpoint runs, and HistorianGateway gets its first
current-state doc.

Recorded: mxaccessgw#206 (worker-executable + galaxy-sql readiness checks, on /health/ready) is
merged; historiangw#87 (session-pool check latches 503 until restart) is still open at 4d6a9ad.
Deferred P2 downstream probes are now per-app; ScadaBridge IActiveNodeGate unification stays
deferred (shared rule already used, only the interface is local).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:00:44 -05:00
parent f21f2e551d
commit 9cbe65c844
7 changed files with 346 additions and 630 deletions
+4 -3
View File
@@ -2,7 +2,7 @@
Health-check libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central health process or network hop; probes run in-process alongside the application.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #11 (re-verify per-app probe coverage vs the spec). The history below is a record, not a status tracker; file new follow-ups as issues.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — #11 (re-verify per-app probe coverage vs the spec) was done 2026-11-09; remaining per-app gaps live in the app repos' trackers (see `components/health/GAPS.md`). The history below is a record, not a status tracker; file new follow-ups as issues.
The library normalizes the three-tier health endpoint convention (`/health/ready`, `/health/active`, `/healthz`) and provides reusable probe implementations so the three sister projects share a common surface without duplicating probe logic.
@@ -27,8 +27,9 @@ The library normalizes the three-tier health endpoint convention (`/health/ready
| **OtOpcUa** | yes | yes | yes |
| **MxAccessGateway** | yes | — | — |
| **ScadaBridge** | yes | yes | yes |
| **HistorianGateway** | yes | — | — |
MxAccessGateway consumes the core package only — it has no Akka cluster and no EF DbContext. OtOpcUa and ScadaBridge consume all three packages.
All four pin `0.3.0` (verified 2026-11-09). MxAccessGateway and HistorianGateway consume the core package only — no Akka cluster, no EF DbContext. `GrpcDependencyHealthCheck` currently has **no** consumer. OtOpcUa and ScadaBridge consume all three packages.
---
@@ -101,6 +102,6 @@ orchestrator wiring, and ScadaBridge's distributed health-monitoring pipeline.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/)
(.NET 10; 3 packages — `ZB.MOM.WW.Health`, `ZB.MOM.WW.Health.Akka`, `ZB.MOM.WW.Health.EntityFrameworkCore`;
64 tests (verified 2026-07-18); `dotnet pack` → 3 nupkgs @ 0.1.0).
**Adopted by all four apps** — verified 2026-07-18 (each references the package and wires `MapZbHealth`); the previous "not yet adopted" claim was stale. Per-app probe coverage vs the spec has NOT been re-verified (→ #11) — see [`components/health/GAPS.md`](components/health/GAPS.md).
**Adopted by all four apps** — verified 2026-07-18 (each references the package and wires `MapZbHealth`); the previous "not yet adopted" claim was stale. Per-app probe coverage vs the spec was **re-verified 2026-11-09** (scadaproj#11) against each app's origin default branch — the per-endpoint coverage matrix, SPEC deviations and remaining gaps are in [`components/health/GAPS.md`](components/health/GAPS.md), per-app detail in `components/health/current-state/`.
Build/test from `ZB.MOM.WW.Health/`: `dotnet test`. Consumer matrix: MxAccessGateway → core only;
OtOpcUa & ScadaBridge → all three packages.
+104 -190
View File
@@ -1,211 +1,125 @@
# Health — gaps & adoption backlog
# Health — gaps vs spec
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
> **Status lives in Gitea.** Open work is tracked as issues on the owning repo's tracker
> (`gitea.dohertylan.com/dohertj2/<repo>`); this file is a design record of where each app stands against
> [`spec/SPEC.md`](spec/SPEC.md). Do not add status rows here — file an issue and reference it.
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach the shared `ZB.MOM.WW.Health` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
**Re-verified 2026-11-09 (scadaproj#11)** against each repo's origin default branch, by reading the
registration and mapping code (not just the `PackageReference`):
> **✅ ADOPTED — verified 2026-07-18.** This file previously carried no adoption banner and the
> root docs claimed the library was "**not yet adopted**". That was stale. Verified directly:
> `ZB.MOM.WW.Health` `0.1.0` is on the Gitea feed, and **all four apps reference it and wire
> `MapZbHealth`** — OtOpcUa (+ `.Akka` + `.EntityFrameworkCore`, uses `AkkaClusterHealthCheck`),
> MxAccessGateway (core only), ScadaBridge (all three packages; uses `AkkaClusterHealthCheck` +
> `IActiveNodeGate`), HistorianGateway (core; uses `GrpcDependencyHealthCheck`).
>
> **Caveat on the evidence:** this was verified by `PackageReference` + registration-call grep,
> which proves the library is wired in — *not* that every per-app probe below is registered or
> that the endpoint tiers match the spec. The divergence tables further down were written
> pre-adoption and have **not** been re-verified against current code (→ #11); treat them as historical
> until someone re-walks them. They also predate HistorianGateway, which is absent from every
> table below.
> **⚠️ Gaps L1/L2 were closed the WRONG WAY, then reopened and closed properly at `0.3.0`
> (2026-07-24).** Everything below about the active-node check describes the leader-based design and
> is **superseded** — read [`spec/SPEC.md`](spec/SPEC.md) §2.3 for the current rule.
>
> L1 shipped `ActiveNodeHealthCheck` selecting by `ClusterState.Leader` / `RoleLeader`, and L2 backed
> `AkkaActiveNodeGate` the same way. Leadership is **address**-ordered (host, then port) with no
> relationship to time, while cluster-singleton placement is **age**-ordered. The two agree on a
> freshly-formed cluster — so every unit test and happy-path rig passed — and diverge permanently
> after any restart: the restarted node rejoins youngest but keeps its address, so if it holds the
> lower address it becomes leader while the singletons stay on the other node. During a partition
> both sides also compute themselves leader.
>
> Both apps adopted L1/L2 as written, both hit the defect on live rigs, and both **reverted to
> private copies** (ScadaBridge `OldestNodeActiveHealthCheck`, OtOpcUa `ClusterPrimaryHealthCheck`).
> The result was that the package's whole active-node surface had **zero consumers** — avoided, not
> unused. `0.3.0` promoted those two copies into one `ClusterActiveNode` primitive, rebuilt the check
> and the gate on it, and both apps migrated back and deleted their copies.
>
> **The lesson worth keeping:** a shared component can be adopted, green, and still wrong, when the
> only fixture that distinguishes right from wrong is a cluster whose oldest member is not its
> lowest-addressed one. The library now pins exactly that fixture.
## Divergence vs spec
### §1 Endpoint tiers
| Spec tier | OtOpcUa | MxAccessGateway | ScadaBridge |
| App | Audited at | Health pin | Detail |
|---|---|---|---|
| `/health/ready` (tag `ready`) | ✅ present | ⛔ absent | ✅ present (name-predicate) |
| `/health/active` (tag `active`) | ✅ present | ⛔ absent | ✅ present (name-predicate) |
| `/healthz` (bare process liveness) | ✅ present | ⛔ absent | ⛔ absent |
| `/health/live` (non-standard) | — | ⛔ present (hardcoded `"Healthy"`, bypasses health-check pipeline) | — |
| OtOpcUa | `origin/master` `c904472a` | 0.3.0 (core + `.Akka` + `.EntityFrameworkCore`) | [current-state/otopcua](current-state/otopcua/CURRENT-STATE.md) |
| MxAccessGateway | `origin/main` `de86f8b8` | 0.3.0 (core) | [current-state/mxaccessgw](current-state/mxaccessgw/CURRENT-STATE.md) |
| ScadaBridge | `origin/main` `c7d868c5` | 0.3.0 (core + `.Akka` + `.EntityFrameworkCore`) | [current-state/scadabridge](current-state/scadabridge/CURRENT-STATE.md) |
| HistorianGateway | `origin/main` `4d6a9ad` | 0.3.0 (core) | [current-state/historiangateway](current-state/historiangateway/CURRENT-STATE.md) |
→ **Gap T1 (P1):** MxAccessGateway has no standard health tiers. The existing `/health/live`
`MapGet` lambda must be replaced by `app.MapZbHealth()` + real probes.
→ **Gap T2:** ScadaBridge lacks `/healthz`. `MapZbHealth()` adds it automatically.
→ **Gap T3:** MxAccessGateway's `/health/live` uses a raw `MapGet` that bypasses the ASP.NET Core
health-check middleware — it does not participate in `IHealthCheckPublisher`, `HealthReport`, or
UI integration. Must be removed.
All four call `MapZbHealth()` with default paths and the canonical writer, so every app serves
`/healthz` (no checks), `/health/ready` (tag `ready`) and `/health/active` (tag `active`), anonymously.
No app tags anything `live`.
### §2 Probe coverage
## Coverage matrix (what each endpoint runs)
| Probe | OtOpcUa | MxAccessGateway | ScadaBridge |
| App / node | `/health/ready` | `/health/active` | Active-node rule |
|---|---|---|---|
| Database connectivity | ✅ `DatabaseHealthCheck` (query probe) | ⛔ none | ✅ `DatabaseHealthCheck` (`CanConnectAsync`) |
| Akka cluster membership | ✅ `AkkaClusterHealthCheck` (2-way) | n/a (no Akka) | ✅ `AkkaClusterHealthCheck` (3-way) |
| Active / leader node | ✅ `AdminRoleLeaderHealthCheck` (role-filtered) | n/a | ✅ `ActiveNodeHealthCheck` (role-less) |
| Downstream gRPC dependency | ⛔ none | ⛔ none | ⛔ none |
| OtOpcUa admin-only | `configdb`, `akka` | `configdb`, `akka`, `cluster-primary`, `localdb-replication` | shared `ActiveNodeHealthCheck`, roles `[admin, driver]` |
| OtOpcUa fused admin+driver | `opcua-endpoint`, `configdb`, `akka` | `configdb`, `akka`, `cluster-primary`, `localdb-replication` | same |
| OtOpcUa driver-only | `opcua-endpoint`, `akka` | `opcua-endpoint`, `akka`, `cluster-primary`, `localdb-replication` | same |
| ScadaBridge central | `database`, `akka-cluster`, `required-singletons` | `database`, `active-node` | shared `ActiveNodeHealthCheck`, unscoped |
| ScadaBridge site | `akka-cluster`, `localdb` | `active-node` | `SitePairActiveNodeHealthCheck` → shared `ClusterActiveNode` (site role) |
| MxAccessGateway | `auth-store`, `worker-executable`, `galaxy-sql` | `mxaccess-sessions` | none (singleton, no cluster) |
| HistorianGateway | `historian-connection`, `galaxy-sql`, `store-forward-drain`*, `historian-session-pool`*, `historian-event-session-pool`* | *(empty — always 200)* | none (singleton, no cluster) |
→ **Gap P1 (P1):** MxAccessGateway has zero probes — `AddHealthChecks()` at
`GatewayApplication.cs:61` is dead code. Minimum viable: a `GrpcDependencyHealthCheck`
targeting the x86 worker IPC channel.
→ **Gap P2:** No project probes its downstream gRPC dependency. OtOpcUa should probe the
MxAccessGateway channel; MxAccessGateway should probe the worker IPC.
→ **Gap P3:** Dead `AddHealthChecks()` in MxAccessGateway (`GatewayApplication.cs:61`) should be
removed or replaced — it currently implies health checks are configured when they are not.
\* registered only when the feature is enabled; both session pools default to enabled.
### §3 Akka status-policy divergence
Checks that can only return **Degraded** (so never cause a 503): OtOpcUa `akka` (`OtOpcUaCompat`) and
`localdb-replication`; HistorianGateway `store-forward-drain`; the shared Akka checks during startup.
| Aspect | OtOpcUa | ScadaBridge |
|---|---|---|
| Probe implementation | Scans `State.Members` for self by address | Reads `SelfMember.Status` directly |
| Joining status | Degraded (not in Members as Up) | Healthy |
| Leaving/Exiting status | Degraded | Degraded |
| Other (Removed, Down…) | Degraded | Unhealthy |
| ActorSystem null guard | — (none; `ActorSystem` injected directly) | ✅ Degraded if null |
## Spec requirements vs apps
The two implementations diverge in how they classify `Joining` (ScadaBridge calls it Healthy;
OtOpcUa would see it as Degraded because `SelfMember` with status `Joining` would not appear as
`Up` in the member scan). They also diverge in the Removed/Down classification (ScadaBridge
Unhealthy, OtOpcUa Degraded).
✅ covered · 🟡 partial / deliberate deviation · ⛔ missing · n/a not applicable
The shared `ZB.MOM.WW.Health.Akka.AkkaClusterHealthCheck` ships two presets to preserve both
behaviors rather than forcing one onto the other:
- **Default** — ScadaBridge's three-way policy (`Up`/`Joining`=Healthy, `Leaving`/`Exiting`=Degraded,
else Unhealthy)
- **OtOpcUaCompat** — OtOpcUa's self-Up-among-members scan (found Up=Healthy, not found=Degraded)
| SPEC requirement | OtOpcUa | MxAccessGateway | ScadaBridge | HistorianGateway |
|---|---|---|---|---|
| §1/§5 three tiers via `MapZbHealth`, canonical tags | ✅ | ✅ | ✅ (central + site) | ✅ |
| §1 nothing tagged `live` | ✅ | ✅ (test-asserted) | ✅ | ✅ |
| §2.1 DB probe (`DatabaseHealthCheck<T>` where EF exists) | ✅ `ProbeQuery` | n/a (custom SQLite + Galaxy SQL) | ✅ central; site custom LocalDb | n/a (custom `SqlConnectionHealthCheck`) |
| §2.2 / §7(b) Akka probe, **Default** policy | 🟡 `OtOpcUaCompat` | n/a | ✅ | n/a |
| §2.3 active tier = oldest `Up` member | ✅ | 🟡 tier used for session health | ✅ central; 🟡 site adapter | 🟡 tier empty |
| §2.3 startup-safe Degraded + `activeNode` data | ✅ | n/a | ✅ central; ⛔ site | n/a |
| §2.4 downstream dependency probe | ⛔ (see P2) | 🟡 presence + Galaxy SQL, not worker IPC | n/a | ✅ custom (Historian via pool) |
| §3 canonical JSON writer | ✅ | ✅ | ✅ | ✅ |
| §4 / §7(e) `IActiveNodeGate` | n/a (Traefik routes by probe) | n/a | 🟡 local interface, shared rule | n/a |
→ **Gap A1:** OtOpcUa adopts the `OtOpcUaCompat` preset; ScadaBridge adopts the `Default` preset.
Both preserve existing behavior without forcing convergence on a single policy.
→ **Gap A2:** OtOpcUa's `AkkaClusterHealthCheck` injects `ActorSystem` directly (no null guard).
The shared implementation injects via `AkkaHostedService` for startup safety.
## Closed
### §4 Database probe technique
- **T1/T2/T3, P1, P3, W1** (pre-adoption: mxgw had no tiers or probes and a bespoke `/health/live`;
ScadaBridge lacked `/healthz`; per-app writers) — closed by the 2026-06-01 adoption; confirmed on all
four tips above. No bespoke health endpoint remains anywhere.
- **L1/L2 active-node** — closed properly at Health `0.3.0`. They first shipped leader-based (address-
ordered) selection, which diverges from age-ordered singleton placement after any restart; both Akka
apps hit it live and reverted to private copies until `0.3.0` rebuilt `ActiveNodeHealthCheck` and
`AkkaActiveNodeGate` on `ClusterActiveNode` (oldest `Up` member). Both now use the shared rule; their
private copies are deleted. The library pins the only fixture that tells the two orderings apart (a
two-node cluster whose oldest member is not the lowest-addressed one).
- **D1/D2 DB probe technique** — shared `DatabaseHealthCheck<T>` covers both (OtOpcUa `ProbeQuery`,
ScadaBridge `CanConnectAsync` via self-scoping; the `IDbContextFactory` switch was dropped as
unnecessary).
- **mxaccessgw#206** (closed 2026-11-09, `8c21678c`) — `worker-executable` and bounded `galaxy-sql`
readiness checks, both `ready` only; sessions stay on `active`. No LDAP check (out of scope by decision).
| Aspect | OtOpcUa | ScadaBridge |
|---|---|---|
| Probe method | `db.Deployments.AsNoTracking().Take(1).ToListAsync()` (query) | `_dbContext.Database.CanConnectAsync()` (connection only) |
| Injection style | `IDbContextFactory<T>` (pooled, safe for concurrent probes) | `DbContext` directly (scoped, requires care in background use) |
| Schema verification | ✅ implies schema is applied | ⛔ connection only |
## Remaining gaps
→ **Gap D1:** `ZB.MOM.WW.Health.EntityFrameworkCore.DatabaseHealthCheck<TContext>` uses
`CanConnectAsync` as the default (ScadaBridge behavior). An optional `ProbeQuery` delegate covers
OtOpcUa's stricter approach. Both apps retain their existing probe semantics; neither is forced
to change unless desired.
→ **Gap D2:** ScadaBridge injects `DbContext` directly; the shared probe should use
`IDbContextFactory<TContext>` for safe reuse from a background-service health-check context.
ScadaBridge's DI registration will need updating on adoption.
- **A1 — OtOpcUa still on `AkkaClusterStatusPolicy.OtOpcUaCompat`.** Its `akka` check can only report
Degraded, so a node that has left or been downed never fails readiness on cluster grounds. SPEC §2.2 /
§7(b) name `Default` as the convergence target. Needs a lmxopcua issue if convergence is still wanted.
- **ScadaBridge site `active-node` adapter** (`SitePairActiveNodeHealthCheck`) returns Unhealthy while the
ActorSystem is still null (SPEC §2.3 says Degraded during startup) and emits no
`selfAddress`/`activeRole`/`activeNode` `data`, so the Overview dashboard cannot tell from a site standby
which node is active. Same rule as the shared check; only the contract around it differs. Needs a
ScadaBridge issue if the site tier should match central's (registering the shared
`ActiveNodeHealthCheck` with the site role would close both).
- **HistorianGateway session-pool readiness latch — historiangw#87 (open, High/P1).** The
`historian-session-pool` / `historian-event-session-pool` checks go Unhealthy after 3 consecutive factory
failures and reset only on a factory success, which needs an RPC, which a 503-ed pod never receives;
caller cancellation also counts. Unfixed on `4d6a9ad` and on by default. Detail:
[current-state/historiangateway](current-state/historiangateway/CURRENT-STATE.md).
- **SPEC text is stale on §2.4.** It names OtOpcUa and MxGateway as `GrpcDependencyHealthCheck` users;
**no app uses it** (HistorianGateway, once cited as a user, now probes the Historian through its
connection pool). The check stays in the library but has zero consumers; the SPEC/README wording should
be corrected when the spec is next revised.
### §5 Active-node / leader check
## Deferred items — status from code (2026-11-09)
| Aspect | OtOpcUa | ScadaBridge |
|---|---|---|
| Probe type | `AdminRoleLeaderHealthCheck` (role-filtered: `"admin"`) | `ActiveNodeHealthCheck` (role-less; Up + leader) |
| Non-role-bearing node | Healthy immediately | n/a (all central nodes have no role filter) |
| Leader status | Healthy | Healthy |
| Non-leader (standby) | Degraded | Unhealthy |
| `IActiveNodeGate` backing | Not present | `ActiveNodeGate` (separate type, duplicated logic) |
- **Downstream dependency probes (P2).** Re-scoped, not adopted as `GrpcDependencyHealthCheck` anywhere:
- *MxAccessGateway:* worker IPC is named pipes and workers exist only per session, so there is no
channel to probe at idle. mxaccessgw#206 closed the practical part (worker binary present, Galaxy SQL
reachable). A live-worker probe is not planned.
- *HistorianGateway:* covered by its own `historian-connection` and `galaxy-sql` checks.
- *OtOpcUa:* still **no** probe of mxaccessgw or any driver dependency — gateway channels are created
per driver from DB config, and OtOpcUa's availability guarantee (serve last-known-good when a source
is down) argues against failing readiness on a driver dependency. Treat as **by design**; if a
gateway-down signal is wanted, it belongs on a Degraded-only check or the driver-status surface, and
needs a lmxopcua issue.
- **ScadaBridge `IActiveNodeGate` unification (L2 follow-on).** Still deferred, low value: ScadaBridge
registers `ActiveNodeGate` as its own `ZB.MOM.WW.ScadaBridge.InboundAPI.IActiveNodeGate`
(`Program.cs:614`), consumed by `InboundApiEndpointFilter`, `InboundMethodWarmupService`,
`AuditWriteMiddleware` and `FlowDebugRunner`. The behaviour already converged: the gate calls
`ClusterActivityEvaluator.SelfIsOldest` → `ActiveNodeEvaluator.SelfIsOldestUp` → shared
`ClusterActiveNode.SelfIsActive`, the same rule as `AkkaActiveNodeGate` and the `active-node` probe, so
the gate and the probe cannot disagree. Only the interface type is local. Swapping to the shared
interface would touch the InboundAPI project (which would then reference `ZB.MOM.WW.Health`) for no
behaviour change.
→ **Gap L1:** `ZB.MOM.WW.Health.Akka.ActiveNodeHealthCheck` with an optional `RoleFilter`
parameter unifies both behaviors. OtOpcUa passes `RoleFilter = "admin"` (role-filtered);
ScadaBridge uses no role filter.
→ **Gap L2:** ScadaBridge's `ActiveNodeGate` duplicates `ActiveNodeHealthCheck` logic. The shared
`IActiveNodeGate` seam + a backing singleton eliminates the duplication.
### §6 Response writer
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Writer | Default (plain-text/JSON) | Bespoke `GatewayHealthReply` JSON | `UIResponseWriter.WriteHealthCheckUIResponse` |
→ **Gap W1:** the shared `ZB.MOM.WW.Health` package ships a canonical JSON response writer
(lifting `HealthChecks.UI.Client` style to the default). All three projects adopt it on
`MapZbHealth()` call — no per-project writer wiring needed.
### §7 Endpoint authentication
Both OtOpcUa and ScadaBridge expose health endpoints without authentication (`AllowAnonymous` or
open by default). MxAccessGateway's `/health/live` has no authentication requirement. The spec
canonizes this: health tiers are `AllowAnonymous`; `MapZbHealth()` applies `AllowAnonymous` by
default.
No gap — consistent across all three. `MapZbHealth()` should document and enforce this default.
## Adoption backlog (ordered)
| # | Item | Projects | Priority | Effort | Risk | Notes |
|---|---|---|---|---|---|---|
| 1 | MxAccessGateway: remove dead `/health/live` + `AddHealthChecks()`, add `GrpcDependencyHealthCheck` (worker IPC) + `MapZbHealth()` | MxGateway | P1 | S | Low | Gap T1, T3, P1, P3 — no probes/tiers today; highest delta |
| 2 | OtOpcUa: replace 3 bespoke checks with shared probes (`AkkaClusterHealthCheck` OtOpcUaCompat + `ActiveNodeHealthCheck` role-filtered + `DatabaseHealthCheck<T>` ProbeQuery) | OtOpcUa | P2 | S | Low | Gap A1, D1, L1 |
| 3 | ScadaBridge: replace 3 bespoke checks with shared probes (Default policy + role-less Active + `CanConnectAsync`) + add `/healthz` + unify `ActiveNodeGate` with `IActiveNodeGate` seam | ScadaBridge | P2 | S | Low | Gap T2, A1, D2, L1, L2 |
| 4 | OtOpcUa + MxAccessGateway: add `GrpcDependencyHealthCheck` for downstream gRPC channel | OtOpcUa, MxGateway | P2 | S | Low | Gap P2 — closes the silent-gateway-down scenario |
| 5 | All: adopt canonical response writer (switch from per-project writers to `MapZbHealth` default) | all 3 | P3 | XS | Low | Gap W1 — mechanical; bundled with #1–3 |
| 6 | DB injection style: switch ScadaBridge from injected `DbContext` to `IDbContextFactory<T>` | ScadaBridge | P3 | XS | Low | Gap D2 — background-service safety |
**Note: adoption items #1–6 are all follow-on tasks.** They are tracked here as the backlog for
after `ZB.MOM.WW.Health` @ 0.1.0 is published. The library build itself (nupkgs, tests) is a
separate task. This is consistent with how `ZB.MOM.WW.Auth` and `ZB.MOM.WW.Theme` are structured:
the library is built first; adoption by the three apps is the next step.
## Adoption status — 2026-06-01 (DONE)
`ZB.MOM.WW.Health` 0.1.0 was published to the `dohertj2-gitea` NuGet feed and adopted across all
three apps on branch `feat/adopt-zb-health` in each repo (one branch per repo; commits below).
Plan + design: [`../../docs/plans/2026-06-01-health-library-adoption.md`](../../docs/plans/2026-06-01-health-library-adoption.md).
| Repo | What shipped | Build / tests |
|---|---|---|
| **MxAccessGateway** | Removed the pipeline-bypassing `/health/live` lambda + dead `AddHealthChecks()`; added a custom `AuthStoreHealthCheck` (readiness probe over the SQLite auth store) tagged `Ready`; `MapZbHealth()` → ready/active/healthz + canonical writer. | Server builds; 568 pass / 3 **pre-existing** macOS failures unrelated to health (OrphanWorkerTerminator ×2, fake-worker timeout ×1). |
| **OtOpcUa** | Swapped all 3 bespoke checks → shared probes (`DatabaseHealthCheck<OtOpcUaConfigDbContext>` + `ProbeQuery`, `AkkaClusterHealthCheck` **OtOpcUaCompat**, `ActiveNodeHealthCheck(role:"admin")`); `MapZbHealth()`. | Host builds clean; health tests pass **including a real two-node-cluster integration test**. Independently code-reviewed: APPROVED, behaviour-preserving. |
| **ScadaBridge** | Swapped 3 bespoke checks → shared probes (`DatabaseHealthCheck<ScadaBridgeDbContext>` scoped fallback, `AkkaClusterHealthCheck` **Default**, role-less `ActiveNodeHealthCheck`); added transient `ActorSystem` DI bridge (Central + Site roots); added `/healthz`; canonical writer. Kept its own `ActiveNodeGate`. | Builds clean (TreatWarningsAsErrors); 212 Host tests pass. |
### Deferred (verified ill-fitting on adoption — re-scoped from the original backlog)
- **#4 downstream gRPC dependency probes — DROPPED for now.** (→ #11) Neither repo holds a host-level
`GrpcChannel` to probe: the MxGateway↔worker IPC is **named pipes** (not gRPC), and OtOpcUa's
gateway channel is created **per-driver** from DB config (no DI-level channel). MxGateway readiness
instead probes the SQLite auth store (`AuthStoreHealthCheck`). A real worker/downstream probe needs
a custom non-gRPC check — future work.
- **#3 `IActiveNodeGate` seam unification (ScadaBridge) — DEFERRED.** (→ #11) ScadaBridge's `IActiveNodeGate`
is `…ScadaBridge.InboundAPI.IActiveNodeGate`, wired into inbound-API endpoint gating — a different
interface from the shared `ZB.MOM.WW.Health.IActiveNodeGate`. Unification touches the InboundAPI
path; its existing `ActiveNodeGate` (logic identical to the shared `AkkaActiveNodeGate`) was kept.
- **#6 `IDbContextFactory<T>` switch (ScadaBridge) — DROPPED as unnecessary.** The shared
`DatabaseHealthCheck<T>` self-scopes (creates its own DI scope per probe) when no factory is
registered — that *is* the background-safety fix — and ScadaBridge's context is built with an
injected `IDataProtectionProvider`, which `AddDbContextFactory` does not accommodate cleanly.
### Accepted behaviour change (one) — flag for ops
ScadaBridge `/health/active` during the **startup window** (ActorSystem/cluster not yet ready) now
returns **Degraded → HTTP 200** instead of the prior **Unhealthy → HTTP 503**. This is the shared
`ActiveNodeHealthCheck`'s documented startup-safe behaviour and the normalized convergence target.
The steady-state standby case (node Up but not leader) is **unchanged** (Unhealthy → 503). If a
load-balancer (Traefik) keys strictly on a 503 from `/health/active` to fence standby nodes during
startup, that fail-safe is briefly relaxed until the cluster forms.
## Behaviour notes (kept for ops)
- **Extra probes on the active tier.** ScadaBridge central (`database`) and OtOpcUa admin nodes
(`configdb`) put their DB check on `/health/active` deliberately: a DB-dead active node leaves Traefik
rotation. Side effect: during a config-DB outage **both** nodes answer 503 on `/health/active`, so
Traefik has no backend for the routed UI until the DB returns.
- **Startup window.** The shared `ActiveNodeHealthCheck` returns Degraded (200) while the ActorSystem or
cluster is not yet available (accepted 2026-06-01 for ScadaBridge central). The ScadaBridge site
adapter returns 503 in the same window (see Remaining gaps).
- **Singleton apps answer 200 on `/health/active` on every node.** MxAccessGateway (unless all sessions
are faulted) and HistorianGateway (always). The QA pair runs both on vq03 and vq04 as independent
singletons, so this is correct, but `/health/active` cannot be used to pick one of them.
+8 -13
View File
@@ -23,14 +23,12 @@ semantics.
## Status by project
| Project | Endpoints today | Probes today | Response writer | `/healthz` | `IActiveNodeGate` | Adoption status |
|---|---|---|---|---|---|---|
| **OtOpcUa** | `/health/ready`, `/health/active`, `/healthz` | Database (query), AkkaCluster (2-way), AdminRoleLeader (role-filtered) | Default (plain-text/JSON) | ✅ present | — | Not started |
| **MxAccessGateway** | `/health/live` only (raw `MapGet`; hardcoded `"Healthy"`) | **None** (`AddHealthChecks()` called but unused) | Bespoke `GatewayHealthReply` JSON | ⛔ absent | — | Not started |
| **ScadaBridge** | `/health/ready`, `/health/active` | Database (`CanConnectAsync`), AkkaCluster (3-way), ActiveNode (role-less) | `HealthChecks.UI.Client` JSON | ⛔ absent | `ActiveNodeGate` (backs Inbound API 503 gate) | Not started |
See each project's [`current-state/<project>/CURRENT-STATE.md`](current-state/) for the
code-verified detail and its adoption plan.
**Adopted by all four apps at `0.3.0`; probe coverage re-verified 2026-11-09 (scadaproj#11).** Every app
calls `MapZbHealth()` (`/healthz`, `/health/ready`, `/health/active`, canonical JSON). The per-endpoint
coverage matrix, SPEC deviations and remaining gaps are in [`GAPS.md`](GAPS.md); code-verified detail per
app (OtOpcUa, MxAccessGateway, ScadaBridge, HistorianGateway) is in
[`current-state/<project>/CURRENT-STATE.md`](current-state/). The "Why" section above and the bullets
below describe the original 2026-06 design rationale.
## Normalized vs. left per-project
@@ -81,8 +79,5 @@ all three.
## Component status
**Status: Draft — library built at 0.1.0.** Spec and shared-contract written; current-state docs
verified; GAPS backlog populated. Library implemented and packed at
[`../../ZB.MOM.WW.Health/`](../../ZB.MOM.WW.Health/) (3 packages, 58 tests;
`ZB.MOM.WW.Health`, `ZB.MOM.WW.Health.Akka`, `ZB.MOM.WW.Health.EntityFrameworkCore`).
Adoption by the three apps is the next follow-on tracked in [`GAPS.md`](GAPS.md).
**Status: Adopted — library at 0.3.0** ([`../../ZB.MOM.WW.Health/`](../../ZB.MOM.WW.Health/), 3 packages),
pinned by all four apps; coverage re-verified 2026-11-09. Remaining gaps: [`GAPS.md`](GAPS.md).
@@ -0,0 +1,72 @@
# Health — current state: HistorianGateway
**Verified 2026-11-09 at `historiangw@4d6a9ad` (`origin/main`)** for scadaproj#11. Read-only `git grep` /
`git show` against the origin default branch; all paths relative to the repo root
(`~/Desktop/HistorianGateway`). First current-state doc for this app (it postdates the original
three-project survey).
- **Package:** `ZB.MOM.WW.Health` **0.3.0**, core only
(`src/ZB.MOM.WW.HistorianGateway.Server/ZB.MOM.WW.HistorianGateway.Server.csproj:23`).
- **Registration:** inline block in `src/ZB.MOM.WW.HistorianGateway.Server/Program.cs:256-329`.
- **Mapping:** `app.MapZbHealth()` with default paths/writer (`Program.cs:417`).
- **Not** `GrpcDependencyHealthCheck`: earlier scadaproj docs said HistorianGateway used it. It does not
on `main` — the gRPC-only Historian client owns its own channel, so the Historian probe goes through the
connection pool instead (comment at `Program.cs:265-268`).
## Per-endpoint contents
| Endpoint | Checks run | Can return 503 when |
|---|---|---|
| `/healthz` (live) | none | never |
| `/health/ready` | `historian-connection`, `galaxy-sql`, + `store-forward-drain` (if `StoreForward:Enabled`), + `historian-session-pool` / `historian-event-session-pool` (if each pool is enabled — **both default `true`**) | Historian probe fails/> 3 s; Galaxy SQL fails/> `CommandTimeoutSeconds`; a session-pool factory has ≥ 3 consecutive failures |
| `/health/active` | **none** | never — an empty report is Healthy, so every node answers 200 |
## Checks
Files under `src/ZB.MOM.WW.HistorianGateway.Server/Health/`. All tagged `ready`, `failureStatus: null`.
| Name | Class | Probes | Failure |
|---|---|---|---|
| `historian-connection` | `HistorianConnectionHealthCheck` | `IHistorianConnectionPool.AcquireAsync` + `client.ProbeAsync`, 3 s cap; reports a faulted client back to the pool | Unhealthy |
| `galaxy-sql` | `SqlConnectionHealthCheck` (raw `SqlConnection` + `SELECT 1`; no EF) | Galaxy Repository DB from the top-level `Galaxy` section, bounded by its command timeout | Unhealthy |
| `store-forward-drain` | `StoreForwardDrainHealthCheck` | `IHistorianStoreForward.GetStatusAsync`; backing up = `Storing \|\| ErrorOccurred` | **Degraded** only (never 503) |
| `historian-session-pool` | `SessionPoolHealthCheck("read", …)` | `HistorianSessionPool.Snapshot.ConsecutiveFactoryFailures` | 0 → Healthy; 1–2 → Degraded; ≥ 3 → **Unhealthy** |
| `historian-event-session-pool` | `SessionPoolHealthCheck("event", …)` | same over `HistorianEventSessionPool` | same |
### Open finding: the session-pool check latches Unhealthy — historiangw#87 (open)
historiangw#87 (Server-007, High/P1): `_consecutiveFactoryFailures` is reset only by a **successful
factory call**, and the factory only runs when a real RPC needs a new lease (the keepalive sweep never
opens sessions). After a Historian outage of ≥ 3 failed leases, `/health/ready` goes 503, a k8s probe
removes the pod, no RPC arrives, and the check **never recovers** until restart; three clients whose
short deadlines cancel a cold handshake also trip it (the `catch` counts caller cancellation). On the NSSM
wonder boxes it shows as a false Unhealthy on Overview.
**Still present on `origin/main` `4d6a9ad`:** `HistorianSessionPool.cs:264-273` and
`HistorianEventSessionPool.cs:254-262` increment on any throw and reset only on success; nothing probes
or decays the streak; both pools default to `Enabled = true`
(`Configuration/HistorianSessionPoolOptions.cs:24`, `EventSessionPoolOptions.cs:24`), so the latching
check is on the ready tier in the default configuration.
## Active-node gating
None, by design: a single-process sidecar per host with no cluster. No `IActiveNodeGate`, no
`RequireActiveNode()`. `Redundancy` options concern the Historian servers it talks to, not node
election.
## Other endpoints
`MapZbMetrics()`, `MapZbGalaxyRepository()`, the dashboard (its `HealthPage` renders the same
ready-tier results), and the four Historian gRPC services.
## vs SPEC
| SPEC | Status | Note |
|---|---|---|
| §1 three tiers via `MapZbHealth`, canonical tags | ✅ | `/health/active` is empty (always 200) |
| §1 nothing on `live` | ✅ | |
| §2.1 DB probe | 🟡 custom | `SqlConnectionHealthCheck` (no EF context to wrap) |
| §2.4 downstream dependency probe | ✅ (custom) | Historian via the pool, not `GrpcDependencyHealthCheck` |
| Ready = "can this node serve its dependencies?" | 🟡 | correct while dependencies fail; **does not recover** after a session-pool outage (#87) |
| §3 canonical JSON writer | ✅ | |
| §4 `IActiveNodeGate` | n/a | |
@@ -1,133 +1,68 @@
# Health — current state: MxAccessGateway
Repo: `~/Desktop/MxAccessGateway`. Stack: .NET 10 gateway (x64) + .NET 4.8 worker (x86), gRPC;
solution `src/MxGateway.sln`.
Health code lives in `src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs`. All paths relative
to repo root.
Verified 2026-06-01.
**Verified 2026-11-09 at `mxaccessgw@de86f8b8` (`origin/main`)** for scadaproj#11. Read-only `git grep` /
`git show` against the origin default branch; all paths relative to the repo root
(`~/Desktop/MxAccessGateway`). The pre-adoption state (verified 2026-06-01: a hardcoded `/health/live`
`MapGet`, a dead `AddHealthChecks()`, no probes) is gone; see git history of this file.
**Summary: bare liveness only.** MxAccessGateway has a single `/health/live` endpoint that returns
a hardcoded `GatewayHealthReply` JSON object. `AddHealthChecks()` is called at startup but is
entirely unused — no `IHealthCheck` implementations are registered, `MapHealthChecks` is never
called, and there is no readiness or active-node tier. The net48 x86 worker process has no HTTP
server and therefore no health endpoint of any kind.
- **Package:** `ZB.MOM.WW.Health` **0.3.0**, core only (`src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj:20`).
No `.Akka`, no `.EntityFrameworkCore` — the gateway has no Akka cluster and no EF context.
- **Registration:** `builder.Services.AddGatewayHealthChecks()` (`GatewayApplication.cs:118`) →
`src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayHealthCheckExtensions.cs:19-41`.
- **Mapping:** `endpoints.MapZbHealth()` with default paths/writer (`GatewayApplication.cs:346`),
anonymous unconditionally (the Overview dashboard polls `/health/active` without credentials).
## 1. Endpoint wiring
## Per-endpoint contents
`src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs`:
- `:61` — `builder.Services.AddHealthChecks()` is called in the DI registration block. **This call
is dead**: no `.AddCheck<T>()` call follows it, no `MapHealthChecks` is ever called. The
framework registers the health-check infrastructure but nothing is wired through it.
- `:139–145` — `MapGatewayEndpoints` maps a raw `endpoints.MapGet("/health/live", ...)` (not
`MapHealthChecks`). The handler is an inline lambda that returns `Results.Ok(new GatewayHealthReply(...))`
with a hardcoded `Status: "Healthy"`:
```csharp
endpoints.MapGet(
"/health/live",
() => Results.Ok(new GatewayHealthReply(
Status: "Healthy",
DefaultBackend: GatewayContractInfo.DefaultBackendName,
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
.WithName("LiveHealth");
```
This endpoint always returns HTTP 200 `{"Status":"Healthy",...}` as long as the process is alive.
It carries no authentication requirement (no `[Authorize]` or `.RequireAuthorization()`).
## 2. Response shape
`GatewayHealthReply` is a record with three fields:
- `Status` — always `"Healthy"` (hardcoded string, not the ASP.NET Core `HealthStatus` enum)
- `DefaultBackend` — value of `GatewayContractInfo.DefaultBackendName` (the configured backend
name, useful for confirming which gateway instance a probe hit)
- `WorkerProtocolVersion` — value of `GatewayContractInfo.WorkerProtocolVersion` (the gRPC
protocol version the gateway expects from the worker, useful for version-skew detection)
The response is not `HealthChecks.UI.Client` JSON and is not the standard ASP.NET Core health
response shape. It is a bespoke JSON record.
## 3. Probes
None. There is no `IHealthCheck` registered. The `/health/live` response does not reflect:
- Whether the SQLite auth-store is reachable
- Whether any active MXAccess session is functional
- Whether the x86 worker named-pipe IPC is connected or the worker process is alive
- Whether the gRPC service is actually accepting calls
The endpoint is purely a process liveness indicator.
## 4. Tier coverage
| Tier | Endpoint | Status |
| Endpoint | Checks run | Can return 503 when |
|---|---|---|
| Process liveness | `/health/live` (raw `MapGet`) | ✅ present (but non-standard) |
| Readiness | `/health/ready` | ⛔ absent |
| Active node | `/health/active` | ⛔ absent (not Akka-based; not applicable as-is) |
| `healthz` convention | `/healthz` | ⛔ absent |
| `/healthz` (live) | none (`_ => false`) | never (process up) |
| `/health/ready` | `auth-store`, `worker-executable`, `galaxy-sql` | auth SQLite unreachable; worker exe missing; Galaxy SQL unreachable or > 5 s |
| `/health/active` | `mxaccess-sessions` | every open session is `Faulted` (zero sessions = Healthy) |
MxAccessGateway is not an Akka.NET application — it has no cluster, no leader election, and no
active-node concept. The "active" tier in the shared spec translates here to "is the worker process
connected and the gRPC service ready to accept calls?" rather than cluster leadership.
No check is tagged `live` (asserted by `GatewayHealthCheckRegistrationTests`).
## 5. x86 worker
## Checks
`ZB.MOM.WW.MxGateway.Worker` is a .NET 4.8 console application communicating with the gateway
over Windows named-pipe IPC. It has no HTTP server, no health endpoint, and no exposure to any
probe mechanism. Its liveness must be inferred indirectly — either via the gateway process
monitoring it (not currently implemented) or via the `GrpcDependencyHealthCheck` the gateway
could use to probe the IPC channel.
All registered with `AddTypeActivatedCheck`, `failureStatus: null` (the check's own status is used).
Files under `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/`.
## 6. Notable gaps
| Name | Class | Tag | Probes | Failure |
|---|---|---|---|---|
| `auth-store` | `AuthStoreHealthCheck` | ready | Opens the API-key SQLite store via `AuthSqliteConnectionFactory`, runs `SELECT 1` | Unhealthy |
| `worker-executable` | `WorkerExecutableHealthCheck` | ready | `File.Exists(Path.GetFullPath(MxGateway:Worker:ExecutablePath))` — same resolution as `WorkerProcessLauncher.ResolveExecutablePath` | Unhealthy (missing or invalid path) |
| `galaxy-sql` | `GalaxySqlHealthCheck` | ready | `IGalaxyRepository.TestConnectionAsync` bounded by a 5 s `TimeProvider` timeout | Unhealthy (false, throw, or timeout) |
| `mxaccess-sessions` | `SessionHealthCheck` | active | Buckets `ISessionRegistry.Snapshot()` by state; `data` = total/ready/faulted/starting/closing | Healthy if 0 faulted (incl. 0 sessions); Degraded if some faulted; Unhealthy if all usable sessions faulted |
- `AddHealthChecks()` at `:61` is dead code. No `IHealthCheck` is ever registered via this call.
- `/health/live` uses `MapGet` (a raw minimal-API handler) rather than `MapHealthChecks`. It
bypasses the ASP.NET Core health-check middleware entirely, which means it does not participate
in the standard health-check pipeline (no `IHealthCheckPublisher`, no `HealthReport`, no UI
integration).
- The hardcoded `"Healthy"` status means the endpoint cannot reflect real probe results even if
probes were added later — the handler must be replaced, not just supplemented.
- No readiness gating: orchestrators (Kubernetes, Traefik) that rely on `/health/ready` returning
503 until the process is actually ready will receive 200 (or 404) from MxAccessGateway today.
`worker-executable` and `galaxy-sql` are new on `main` `8c21678c` (mxaccessgw#206, closed 2026-11-09).
They check that the worker **binary exists** and that **Galaxy SQL answers**; neither checks a running
worker or the named-pipe IPC (workers are per-session and only exist while a client holds a session).
No LDAP check was added (#206 comment: deliberately out of scope).
---
`galaxy-sql` is unconditional: `AddZbGalaxyRepository` is always registered (`GatewayApplication.cs:167`),
so a gateway whose Galaxy SQL is down drops out of readiness even though its MXAccess data path does not
use SQL. That is the #206 design ("must take the gateway out of rotation"), not an accident.
## Adoption plan → `ZB.MOM.WW.Health`
## Active-node gating
**Replace `/health/live` + wire the shared tiers:**
None. The gateway is a singleton per host (no cluster), registers no `IActiveNodeGate`, and uses no
`RequireActiveNode()`. The `active` tier is used for a **session-health** signal instead of an
active/standby signal, so every gateway answers 200 on `/health/active` unless all its sessions are
faulted.
The `AddHealthChecks()` call at `GatewayApplication.cs:61` is already present — it just needs
probes registered against it. The raw `MapGet("/health/live", ...)` handler at `:139–145` must be
removed and replaced with `app.MapZbHealth()` from `ZB.MOM.WW.Health`.
## Other endpoints
Steps:
`MapZbMetrics()` (`:347`, anonymous by default, see `MetricsOptions`), `MapZbGalaxyRepository()` (`:359`),
dashboard routes. No bespoke health endpoint remains.
1. **Remove** the inline `MapGet("/health/live", ...)` lambda (`:139–145`). The `GatewayHealthReply`
record and `DefaultBackend`/`WorkerProtocolVersion` metadata can be surfaced differently (e.g., a
`/info` endpoint or as custom data on the health response).
2. **Register a `GrpcDependencyHealthCheck`** (from `ZB.MOM.WW.Health`) that probes the
named-pipe IPC channel to the x86 worker. Tag `["ready"]`. This replaces the hardcoded
liveness-only response with a real probe that reflects whether the worker is reachable.
3. **Optionally add a `GrpcDependencyHealthCheck`** for any downstream gRPC dependency (e.g., the
Galaxy Repository connection) if the gateway is expected to be healthy only when its upstreams are
reachable. Tag `["ready"]`.
4. **Call `app.MapZbHealth()`** — this maps `/health/ready` (tag `ready`), `/health/active` (tag
`active`; initially empty — no active-node concept in MxGateway), and `/healthz` (bare liveness).
The `/healthz` endpoint replaces the semantic role that `/health/live` served today.
5. **Do not add `ZB.MOM.WW.Health.Akka`** — MxAccessGateway has no Akka dependency. The consumer
matrix in the design specifies MxGateway uses the core package only.
## vs SPEC
**Keep bespoke:**
- The `WorkerProtocolVersion` / `DefaultBackend` metadata from `GatewayHealthReply` is
MxAccessGateway-specific; keep it as a separate `/info` endpoint or embed it as `Data` on a
custom probe rather than normalizing it into the shared contract.
- The x86 worker itself (net48 console, named-pipe IPC, no HTTP) remains outside the shared health
scheme. The `GrpcDependencyHealthCheck` observes the worker indirectly from the gateway side.
- Per-gateway auth and TLS concerns on who may call health endpoints remain per-project.
**Adoption is a follow-on task** (tracked in `GAPS.md`), not part of the `ZB.MOM.WW.Health`
library build. MxGateway is the **highest-priority adopter** (P1 gap — no probes/tiers today)
and should be the first app wired up once the nupkg is available.
| SPEC | Status | Note |
|---|---|---|
| §1 three tiers via `MapZbHealth`, canonical tags | ✅ | default paths |
| §1 nothing on `live` | ✅ | test-asserted |
| §2.1 `DatabaseHealthCheck<T>` | n/a | no EF; custom SQLite + Galaxy SQL checks |
| §2.3 active tier = oldest `Up` member | 🟡 deviation | no cluster; active tier carries `mxaccess-sessions` (deliberate, documented in `SessionHealthCheck`) |
| §2.4 `GrpcDependencyHealthCheck` for the worker | ⛔ not used | worker IPC is named pipes, not gRPC; `worker-executable` is a presence proxy (see GAPS P2) |
| §3 canonical JSON writer | ✅ | `MapZbHealth` default |
| §4 `IActiveNodeGate` | n/a | no active/standby |
@@ -1,154 +1,68 @@
# Health — current state: OtOpcUa
Repo: `~/Desktop/OtOpcUa`. Stack: .NET 10, Akka.NET, OPC UA; solution `ZB.MOM.WW.OtOpcUa.slnx`.
Health code lives in `src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/`. All paths relative to repo root.
Verified 2026-06-01.
**Verified 2026-11-09 at `lmxopcua@c904472a` (`origin/master`)** for scadaproj#11. Read-only `git grep` /
`git show` against the origin default branch; all paths relative to the repo root (`~/Desktop/OtOpcUa`).
The pre-adoption state (verified 2026-06-01: three bespoke checks incl. `AdminRoleLeaderHealthCheck`,
default writer) is gone; see git history of this file.
Full three-tier pattern: `/health/ready`, `/health/active`, and `/healthz`. Three probes covering
the database, the Akka cluster, and the admin-role leader. All endpoints are `AllowAnonymous` to
permit Traefik and load-balancer probing without credentials.
- **Packages:** `ZB.MOM.WW.Health`, `.Akka`, `.EntityFrameworkCore` all **0.3.0**
(`Directory.Packages.props:211,219,220`; referenced by `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj:37-39`;
`.Akka` also by `ControlPlane` and `Runtime`, which use `ClusterActiveNode` for the redundancy election).
- **Registration:** `builder.Services.AddOtOpcUaHealth(hasAdmin, hasDriver)` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:581`)
→ `src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs:40-130`. The check set depends on the node's roles.
- **Mapping:** `app.MapOtOpcUaHealth()` (`Program.cs:767`) → `app.MapZbHealth()` with defaults (`HealthEndpoints.cs:135`).
## 1. Endpoint wiring
## Per-endpoint contents (by node role)
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs`:
- `:13` — XML comment explicitly names this as "ScadaLink's three-tier pattern: `ready` = boot ok;
`active` = fully serving traffic; `healthz` = bare process liveness."
- `:17` — `AddOtOpcUaHealth(IServiceCollection)` calls `services.AddHealthChecks()` and registers
all three probes (lines 20–22):
- `DatabaseHealthCheck` name `"configdb"`, tags `["ready","active"]`
- `AkkaClusterHealthCheck` name `"akka"`, tags `["ready","active"]`
- `AdminRoleLeaderHealthCheck` name `"admin-leader"`, tags `["active"]` only
- `:28` — `MapOtOpcUaHealth(IEndpointRouteBuilder)` maps three endpoints (lines 33–44):
- `/health/ready` — predicate `c => c.Tags.Contains("ready")`, `.AllowAnonymous()` (lines 33–36)
- `/health/active` — predicate `c => c.Tags.Contains("active")`, `.AllowAnonymous()` (lines 37–40)
- `/healthz` — predicate `_ => false` (no probes run; bare process liveness only), `.AllowAnonymous()` (lines 41–44)
`Program.cs`:
- `:137` — `builder.Services.AddOtOpcUaHealth()`
- `:159` — `app.MapOtOpcUaHealth()`
Response writer: default ASP.NET Core plain-text/JSON (no `HealthChecks.UI.Client`).
## 2. Probes
### DatabaseHealthCheck
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/DatabaseHealthCheck.cs`:
- `:9` — injects `IDbContextFactory<OtOpcUaConfigDbContext>`
- `:25–37` — opens a pooled context via `CreateDbContextAsync`, runs
`db.Deployments.AsNoTracking().Take(1).ToListAsync()`. If the query succeeds →
`HealthCheckResult.Healthy("ConfigDb reachable")` (`:31`). If it throws →
`HealthCheckResult.Unhealthy("ConfigDb unreachable", ex)` (`:35`). No `Degraded` path.
The probe exercises a real query (not just `CanConnectAsync`) — it confirms the `Deployments` table
is readable, which implies the schema migration has run. This is **stricter** than ScadaBridge's
`CanConnectAsync` but more opaque about the failure reason.
Tags on registration: `["ready","active"]` — the database must be reachable for both readiness and
active-node determination.
### AkkaClusterHealthCheck
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AkkaClusterHealthCheck.cs`:
- `:9` — injects `ActorSystem` directly
- `:27–33` — calls `Cluster.Get(_system)`, scans `cluster.State.Members` for the member whose
`Address == cluster.SelfAddress` and `Status == MemberStatus.Up`:
- Found Up → `HealthCheckResult.Healthy($"Self Up; {cluster.State.Members.Count} member(s)")` (`:32`)
- Not found → `HealthCheckResult.Degraded("Self not yet Up in cluster")` (`:33`)
No `Unhealthy` path — joining/leaving/removed nodes are all reported as `Degraded`. This differs from
ScadaBridge's more granular three-way policy (see GAPS).
Tags on registration: `["ready","active"]`.
### AdminRoleLeaderHealthCheck
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/AdminRoleLeaderHealthCheck.cs`:
- `:14` — injects `IClusterRoleInfo`
- `:27–38` — three-path logic:
- Node does not carry the `"admin"` role → `Healthy("Node does not carry admin role")` (`:30`) —
non-admin nodes are immediately healthy, so this probe never gates a non-admin node.
- Admin role + node is the role leader → `Healthy($"Admin leader ({...})")` (`:36`)
- Admin role + not the leader → `Degraded($"Admin member but not leader (leader=...)")` (`:37`)
Tags on registration: `["active"]` only — does not participate in `/health/ready`. The intent is
Traefik routing: the active node (admin-role leader) gets sticky admin-UI traffic; standby nodes
are reachable for data-plane OPC UA but report `Degraded` on `/health/active` so the load balancer
does not route control-plane traffic to them.
Note: no `Unhealthy` path for the role-filter case. If the ActorSystem is not running, `IClusterRoleInfo`
presumably returns safe defaults (no role); this is not separately health-checked.
## 3. Tag / tier summary
| Probe | `/health/ready` | `/health/active` | `/healthz` |
| Endpoint | admin-only node | fused admin+driver | driver-only node |
|---|---|---|---|
| `DatabaseHealthCheck` | ✅ | ✅ | — |
| `AkkaClusterHealthCheck` | ✅ | ✅ | — |
| `AdminRoleLeaderHealthCheck` | — | ✅ | — |
| (no probes) | — | — | ✅ (bare liveness) |
| `/healthz` | none | none | none |
| `/health/ready` | `configdb`, `akka` | `opcua-endpoint`, `configdb`, `akka` | `opcua-endpoint`, `akka` |
| `/health/active` | `configdb`, `akka`, `cluster-primary`, `localdb-replication` | `configdb`, `akka`, `cluster-primary`, `localdb-replication` | `opcua-endpoint`, `akka`, `cluster-primary`, `localdb-replication` |
`/healthz` runs zero probes — it is a pure process liveness sentinel (process reachable = healthy;
a crashed process = no response). Kubernetes liveness probes, Traefik TCP checks, and uptime
monitors use this tier.
`opcua-endpoint` is deliberately **not** on the fused node's active tier: Traefik routes the AdminUI by
`/health/active`, and a failed SDK start must not take the operator console out of rotation; OPC UA
failover is handled by the Runtime (ServiceLevel 0 + `OpcUaEndpointStepDownActor`, lmxopcua#762)
(`HealthEndpoints.cs:44-65`).
## 4. Downstream dependency coverage
## Checks
No probe for the upstream MxAccessGateway gRPC channel. If the gateway is unreachable, OtOpcUa
reports healthy here (the GalaxyDriver will surface errors in OPC UA diagnostics, but `/health/ready`
and `/health/active` will not reflect it). This is a gap that the shared `GrpcDependencyHealthCheck`
probe in `ZB.MOM.WW.Health` would close.
| Name | Class | Tags | Probes | Failure |
|---|---|---|---|---|
| `opcua-endpoint` (driver nodes) | `OpcUaEndpointHealthCheck` (host, `Health/OpcUaEndpointHealthCheck.cs`) | ready (+ active on driver-only) | `IOpcUaEndpointState.Status`: `Started` healthy | Unhealthy on `NotStarted` / `Stopped` / failed |
| `configdb` (admin nodes) | shared `DatabaseHealthCheck<OtOpcUaConfigDbContext>` | ready, active | `ProbeQuery` = `Deployments.AsNoTracking().Take(1)` (implies schema applied) | Unhealthy |
| `akka` | shared `AkkaClusterHealthCheck`, policy **`OtOpcUaCompat`** | ready, active | self `Up` among reachable members | **Degraded only** (never 503) |
| `cluster-primary` | shared `ActiveNodeHealthCheck`, `RolePreference = [admin, driver]` | active | oldest `Up` member of the first preferred role held | Unhealthy on standby and on a node holding neither role; Degraded while the ActorSystem/cluster is not yet available |
| `localdb-replication` | `LocalDbReplicationHealthCheck` (host) | active | `ISyncStatus` connected + oplog backlog < 100 000 | **Degraded only**; Healthy when LocalDb or replication is absent/off |
## 5. Notable design choices
Driver-only nodes register no `configdb` check: they hold no ConfigDb (FetchAndCache from central), and the
type-activated check would throw resolving the factory.
- **AllowAnonymous on all tiers** — see `HealthEndpoints.cs:30–32` comment: "Without it the
`AddOtOpcUaAuth` fallback policy 401s every probe and Traefik marks every backend unhealthy."
- **Query probe, not `CanConnectAsync`** — the `Deployments` query validates that the schema has
been applied. ScadaBridge uses `CanConnectAsync`; neither is wrong but they diverge.
- **`Degraded` semantics** — the Akka check uses `Degraded` (not `Unhealthy`) for a joining/pre-Up
node. ASP.NET Core maps `Degraded` to HTTP 200 by default; Traefik sees 200 and considers the
node ready. If `Unhealthy` (HTTP 503) is required to gate traffic, the `Degraded` path is
insufficient.
- **`IClusterRoleInfo` abstraction** — the admin-leader check depends on `IClusterRoleInfo`, an OtOpcUa
interface, not the raw `Akka.Cluster.Cluster` API. This is a testability-friendly layer absent in
ScadaBridge's direct Akka usage.
## Active-node gating
---
- **Probe:** `cluster-primary` above — the shared `ClusterActiveNode` rule. Per-cluster meshes (Phase 6)
make "oldest `Up` member" already per-`Cluster`, so exactly one node per `Cluster` answers 200.
`RedundancyStateActor`'s Primary election routes through the same primitive, so the tier and the
OPC UA ServiceLevel 250/240 split cannot disagree.
- **Request gate:** none. OtOpcUa registers no `IActiveNodeGate` and uses no `RequireActiveNode()`; the
AdminUI is pinned to the active node by Traefik routing on `/health/active`.
- **Extra probes on the active tier:** `configdb` (Unhealthy) — a ConfigDb outage makes the admin pair
answer 503 on both nodes, so Traefik has no AdminUI backend until it recovers. `akka` and
`localdb-replication` can only Degrade, so they never change routing.
## Adoption plan → `ZB.MOM.WW.Health`
## Other endpoints
**Replace with shared probes:**
`MapOtOpcUaMetrics()`; mesh gRPC services (`MapOtOpcUaMeshGrpcServices`). No bespoke health endpoint.
- `AkkaClusterHealthCheck` → `ZB.MOM.WW.Health.Akka.AkkaClusterHealthCheck` using the
**`OtOpcUaCompat` preset** (self-Up-among-members scan → Healthy/Degraded). The preset keeps
OtOpcUa's existing two-way policy without forcing ScadaBridge's three-way policy onto it.
- `AdminRoleLeaderHealthCheck` → `ZB.MOM.WW.Health.Akka.ActiveNodeHealthCheck` with
`RoleFilter = "admin"`. The role-filter parameter produces identical behavior: non-admin nodes
immediately healthy, admin leader healthy, admin non-leader degraded.
- `DatabaseHealthCheck` → `ZB.MOM.WW.Health.EntityFrameworkCore.DatabaseHealthCheck<OtOpcUaConfigDbContext>`
with a `ProbeQuery` delegate of `db => db.Deployments.AsNoTracking().Take(1).ToListAsync()`.
The delegate preserves the stricter query probe rather than falling back to `CanConnectAsync`.
- Add `GrpcDependencyHealthCheck` targeting the MxAccessGateway channel (closes the downstream
dependency gap noted in §4). Tag `["ready","active"]`.
- Replace `AddOtOpcUaHealth` / `MapOtOpcUaHealth` with
`services.AddHealthChecks().AddCheck<...>()` (one call per probe, per spec §5) +
`app.MapZbHealth()`. The `/healthz` bare-liveness tier is part of `MapZbHealth` by default —
no separate wiring needed.
## vs SPEC
**Keep bespoke:**
- `IClusterRoleInfo` and its Akka implementation — on adoption this testability seam is given up
for the health-check path. The shared `ActiveNodeHealthCheck` reads cluster role state from the
ActorSystem directly (resolving it lazily via `IServiceProvider`); it does not accept
`IClusterRoleInfo` as an injection point. This is an accepted trade-off: the shared implementation
is simpler and consistent across projects, while `IClusterRoleInfo` remains available elsewhere
in the OtOpcUa codebase where it is used outside health checks.
- The `AllowAnonymous` policy — this is an OtOpcUa auth concern; `MapZbHealth` must document that
callers are responsible for applying `AllowAnonymous` (or the shared helper applies it by default).
- Which probes are registered and their tag assignments — the shared library supplies the check
implementations; the wiring (which names, which tags, which options) remains per-project.
**Adoption is a follow-on task** (tracked in `GAPS.md`), not part of the `ZB.MOM.WW.Health`
library build. The library build delivers the shared implementations; adoption lands in the
OtOpcUa repo as a separate commit once the nupkg is available.
| SPEC | Status | Note |
|---|---|---|
| §1 three tiers via `MapZbHealth`, canonical tags | ✅ | |
| §1 nothing on `live` | ✅ | |
| §2.1 `DatabaseHealthCheck<T>` | ✅ | `ProbeQuery` variant |
| §2.2 / §7(b) Akka probe with **Default** policy | 🟡 | still `OtOpcUaCompat` (Degraded-only); see GAPS A1 |
| §2.3 / §7(b) `ActiveNodeHealthCheck` | ✅ | role-preference mode |
| §2.4 downstream (mxaccessgw) gRPC probe | ⛔ not used | gateway channels are per-driver, created from DB config; see GAPS P2 |
| §3 canonical JSON writer | ✅ | |
| §4 / §7(e) `IActiveNodeGate` | n/a | no gated endpoints; Traefik routes by the probe |
@@ -1,186 +1,71 @@
# Health — current state: ScadaBridge
Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET; solution `ZB.MOM.WW.ScadaBridge.slnx`.
Health code centers on `src/ZB.MOM.WW.ScadaBridge.Host/Health/` (ASP.NET probes) and the
separate `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/` project (domain aggregation pipeline).
All paths relative to repo root.
Verified 2026-06-01.
**Verified 2026-11-09 at `ScadaBridge@c7d868c5` (`origin/main`)** for scadaproj#11. Read-only `git grep` /
`git show` against the origin default branch; all paths relative to the repo root
(`~/Desktop/ScadaBridge`). The pre-adoption state (verified 2026-06-01: name-predicate endpoints, no
`/healthz`, `HealthChecks.UI.Client` writer) is gone; see git history of this file.
Two-tier pattern: `/health/ready` and `/health/active` — no `/healthz`. Three probes (database,
Akka cluster, active-node). ScadaBridge also has a bespoke distributed `HealthMonitoring/`
pipeline that is entirely separate from the ASP.NET health checks and is out of scope for the
shared library.
Out of scope, as before: the `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/` domain-aggregation pipeline
(site → central health reports over Akka) is not an ASP.NET health probe.
## 1. Endpoint wiring
- **Packages:** `ZB.MOM.WW.Health`, `.Akka`, `.EntityFrameworkCore` all **0.3.0** (`Directory.Packages.props:97-99`;
referenced by `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj:32-34`; `.Akka` also by `Communication`).
- **Two composition roots**, both calling `app.MapZbHealth()` with defaults:
central (`src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:544-574` register, `:741` map) and
site (`src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs:267-282` register, `Program.cs:861` map, on the
HTTP/1.1 metrics listener, default `:8084`).
- `CentralListenerIsolation.cs:14-15` admits `/healthz`, `/health/ready`, `/health/active` and `/metrics`
on a dedicated metrics port and 404s everything else there.
`src/ZB.MOM.WW.ScadaBridge.Host/Program.cs`:
## Per-endpoint contents
- `:114–117` — `builder.Services.AddHealthChecks()` followed by three `.AddCheck<T>()` calls
(no tags, checked by name at the endpoint level):
- `.AddCheck<DatabaseHealthCheck>("database")`
- `.AddCheck<AkkaClusterHealthCheck>("akka-cluster")`
- `.AddCheck<ActiveNodeHealthCheck>("active-node")`
- `:131` — `builder.Services.AddSingleton<IActiveNodeGate, ActiveNodeGate>()` registers the
production `IActiveNodeGate` implementation (Inbound API gating, not a health-check probe).
- `:222–226` — `/health/ready` mapped with `Predicate = check => check.Name != "active-node"` and
`ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse` (from `HealthChecks.UI.Client`).
Excludes the active-node check so a healthy standby node reports ready.
- `:229–233` — `/health/active` mapped with `Predicate = check => check.Name == "active-node"` and
`ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse`. Active-node check only.
| Endpoint | Central node | Site node |
|---|---|---|
| `/healthz` | none | none |
| `/health/ready` | `database`, `akka-cluster`, `required-singletons` | `akka-cluster`, `localdb` |
| `/health/active` | `database`, `active-node` | `active-node` |
No `/healthz` endpoint. Both mapped endpoints use `HealthChecks.UI.Client` JSON (not the default
plain-text writer), which is a divergence from OtOpcUa.
## Checks
## 2. Probes
| Root | Name | Class | Tags | Probes | Failure |
|---|---|---|---|---|---|
| central | `database` | shared `DatabaseHealthCheck<ScadaBridgeDbContext>` (scoped context, no factory) | ready, active | `CanConnectAsync` | Unhealthy |
| central | `akka-cluster` | shared `AkkaClusterHealthCheck`, policy **`Default`** | ready | `SelfMember.Status` | Up/Joining Healthy; Leaving/Exiting Degraded; else Unhealthy; Degraded during startup |
| central | `required-singletons` | `RequiredSingletonsHealthCheck` (host) | ready | `Identify` (2 s, concurrent) on 5 singleton proxies (`notification-outbox`, `audit-log-ingest`, `site-call-audit`, `audit-log-purge`, `site-audit-reconciliation`) | Unhealthy if any unreachable or no ActorSystem |
| central | `active-node` | shared `ActiveNodeHealthCheck`, unscoped | active | oldest `Up` member of the central cluster | Unhealthy on standby; Degraded while cluster not yet available |
| site | `akka-cluster` | shared `AkkaClusterHealthCheck`, `Default` | ready | as above | as above |
| site | `localdb` | `SiteLocalDbHealthCheck` (host) | ready | `SELECT 1` on the site LocalDb; replication state in `data` only | Unhealthy if unreachable |
| site | `active-node` | `SitePairActiveNodeHealthCheck` (host) | active | `IClusterNodeProvider.SelfIsPrimary` → `ClusterActiveNode.SelfIsActive(cluster, siteRole)` | **Unhealthy** on standby **and while the ActorSystem is null** |
### DatabaseHealthCheck
`src/ZB.MOM.WW.ScadaBridge.Host/Health/DatabaseHealthCheck.cs`:
The `database` check on central's active tier is deliberate (review 01 [High]): a DB-dead active node drops
out of Traefik rotation (`Program.cs:534-537`).
- `:11` — injects `ScadaBridgeDbContext` directly (not a factory)
- `:33–43` — calls `_dbContext.Database.CanConnectAsync(cancellationToken)`:
- Returns `true` → `HealthCheckResult.Healthy("Database connection is available.")` (`:34–35`)
- Returns `false` → `HealthCheckResult.Unhealthy("Database connection failed.")` (`:36`)
- Throws → `HealthCheckResult.Unhealthy("Database connection failed.", ex)` (`:40`)
## Active-node gating
`CanConnectAsync` tests the connection layer only — it does not run any query or verify schema
state. This is less strict than OtOpcUa's `Deployments` query but more transparent about failure
cause (connection vs. schema). No `Degraded` path.
- **Probes:** central `active-node` (shared check) and site `active-node` (thin adapter over the site's
role-scoped `IClusterNodeProvider`). Both route through the shared `ClusterActiveNode` via
`Communication.ClusterState.ActiveNodeEvaluator.SelfIsOldestUp` — the same rule as the S&F delivery
gate and the heartbeat `IsActive` stamp.
- **Request gate:** `ActiveNodeGate` (`Host/Health/ActiveNodeGate.cs`) registered as
**`ZB.MOM.WW.ScadaBridge.InboundAPI.IActiveNodeGate`** (`Program.cs:614`), *not* the shared
`ZB.MOM.WW.Health.IActiveNodeGate`. Consumed by `InboundApiEndpointFilter` (503 on standby),
`InboundMethodWarmupService`, `AuditWriteMiddleware` and `FlowDebugRunner`. Its logic is
`ClusterActivityEvaluator.SelfIsOldest(cluster)` → shared `ClusterActiveNode` — identical in behaviour to
`AkkaActiveNodeGate`; only the interface type is local. Central only; sites register no gate.
### AkkaClusterHealthCheck
`src/ZB.MOM.WW.ScadaBridge.Host/Health/AkkaClusterHealthCheck.cs`:
## Other endpoints
- `:13` — injects `AkkaHostedService` (not `ActorSystem` directly)
- `:33–50` — gets `_akkaService.ActorSystem`, guards on null → `Degraded("ActorSystem not yet
available.")`, then reads `cluster.SelfMember.Status`:
- `Up` or `Joining` → `Healthy($"Akka cluster member status: {status}")` (`:43`)
- `Leaving` or `Exiting` → `Degraded($"Akka cluster member status: {status}")` (`:45`)
- anything else (Removed, Down, WeaklyUp…) → `Unhealthy($"Akka cluster member status: {status}")` (`:47`)
`MapZbMetrics()` on both roots; central gRPC control plane on its own h2c port.
Three-way status policy: Healthy / Degraded / Unhealthy. This is more granular than OtOpcUa's
two-way policy (self-Up-or-not → Healthy/Degraded with no Unhealthy path).
## vs SPEC
### ActiveNodeHealthCheck
`src/ZB.MOM.WW.ScadaBridge.Host/Health/ActiveNodeHealthCheck.cs`:
- `:13` — injects `AkkaHostedService`
- `:29–44` — three-path logic:
- `ActorSystem == null` → `Unhealthy("ActorSystem not yet available.")` (`:31`)
- `SelfMember.Status != Up` → `Unhealthy($"Node not Up (status: ...)")` (`:37`)
- `Up` AND `cluster.State.Leader == self.Address` → `Healthy("Active node (cluster leader).")` (`:41`)
- `Up` but not leader → `Unhealthy("Standby node (not cluster leader).")` (`:43`)
No `Degraded` path — `ActiveNodeHealthCheck` uses `Unhealthy` for standby and non-Up states,
which causes `/health/active` to return HTTP 503 on a standby. This is the intended behavior for
Traefik active-node routing.
## 3. Tag / tier summary
ScadaBridge uses **name-based predicates** at the endpoint level rather than tags on the check
registration. Tags are absent from all three `.AddCheck<T>()` calls.
| Probe | `/health/ready` | `/health/active` | `/healthz` |
|---|---|---|---|
| `DatabaseHealthCheck` | ✅ | — (excluded by name) | ⛔ absent |
| `AkkaClusterHealthCheck` | ✅ | — (excluded by name) | ⛔ absent |
| `ActiveNodeHealthCheck` | — (excluded by name) | ✅ | ⛔ absent |
`/healthz` is absent — there is no bare process liveness endpoint. Kubernetes or Traefik liveness
probes must either use `/health/ready` or tolerate its 503-until-ready behavior.
## 4. IActiveNodeGate and Inbound API gating
`src/ZB.MOM.WW.ScadaBridge.Host/Health/ActiveNodeGate.cs`:
- `:24` — `ActiveNodeGate` implements `IActiveNodeGate` (from the `InboundAPI` project)
- `:40` — `IsActiveNode` property: returns `true` only when `_akkaService.ActorSystem != null`
AND `cluster.SelfMember.Status == MemberStatus.Up` AND `cluster.State.Leader == self.Address`.
Defaults to `false` safely during startup (`:45–46`).
- `:131` in `Program.cs` — registered as a singleton. The `InboundApiEndpointFilter` consults this
gate on every `/api/*` request and returns HTTP 503 on a standby node.
`ActiveNodeGate` mirrors the exact same logic as `ActiveNodeHealthCheck` — both check Up + leader.
They are separate types serving two different concerns (the health endpoint and the API gate) but
are not abstracted into a shared service; each reads cluster state independently.
`IActiveNodeGate` is the generalized seam the `ZB.MOM.WW.Health` core package lifts to the shared
library.
## 5. HealthMonitoring domain pipeline (out of scope for shared library)
`src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/` is a separate project implementing a distributed
health aggregation pipeline. It is **not ASP.NET Core health checks** and is **not in scope** for
`ZB.MOM.WW.Health`.
Key types:
- `SiteHealthCollector` — thread-safe singleton accumulating per-site error counters, connection
metrics, and tag-read metrics. Populated by actors in the DCL layer.
- `HealthReportSender` — a background service on site clusters that serializes `SiteHealthState`
and ships it to the central cluster via Akka remoting at a configurable interval.
- `CentralHealthReportLoop` — central-only background service that generates a synthetic
`SiteHealthReport` for the central cluster itself (siteId `"$central"`) and feeds it into the
central aggregator.
- `CentralHealthAggregator` — a `BackgroundService` on the central cluster tracking the latest
health report per site and detecting offline sites via heartbeat timeout. Exposes
`GetAggregatedHealth()` to the Central UI's `/monitoring/health` endpoint.
This pipeline is domain-specific (multi-site ScadaBridge topology) and will remain per-project
regardless of shared-library adoption.
## 6. Notable design choices
- **Name-based predicates vs. tags** — ScadaBridge uses `check.Name == "active-node"` predicate
logic at the endpoint level. OtOpcUa uses tag membership (`c.Tags.Contains("ready")`). The tag
approach is more composable (a probe can participate in multiple tiers), the name approach is
more explicit. The shared `MapZbHealth` should use tags by default.
- **`HealthChecks.UI.Client` response writer** — ScadaBridge uses the richer JSON response writer
from the `AspNetCore.HealthChecks.UI.Client` package. OtOpcUa uses the default plain-text writer.
The shared library's canonical response writer standardizes this.
- **`ActiveNodeHealthCheck` returns `Unhealthy` for standby** — a standby is not *unhealthy* in the
system sense; it is a deliberate routing discriminator. Using `Unhealthy` here ensures `/health/active`
returns HTTP 503 (Traefik sees the node as down for active traffic). The naming is semantically
imprecise but operationally correct.
- **`IActiveNodeGate` + `ActiveNodeGate` duplication** — the gate and the health check implement the
same logic independently. The shared library's `IActiveNodeGate` seam + `ActiveNodeHealthCheck`
unify them: one backing service, two consumers.
---
## Adoption plan → `ZB.MOM.WW.Health`
**Replace with shared probes:**
- `AkkaClusterHealthCheck` → `ZB.MOM.WW.Health.Akka.AkkaClusterHealthCheck` using the **Default
policy** (`Up`/`Joining`=Healthy, `Leaving`/`Exiting`=Degraded, else Unhealthy). ScadaBridge's
existing three-way policy is the Default — no preset selection needed.
- `ActiveNodeHealthCheck` → `ZB.MOM.WW.Health.Akka.ActiveNodeHealthCheck` with no role filter
(role-less default: Up && leader = Healthy, else Unhealthy). The shared implementation also backs
`IActiveNodeGate`, eliminating the duplicated leader-check logic between `ActiveNodeHealthCheck`
and `ActiveNodeGate`.
- `DatabaseHealthCheck` → `ZB.MOM.WW.Health.EntityFrameworkCore.DatabaseHealthCheck<ScadaBridgeDbContext>`
using the default `CanConnectAsync` probe (ScadaBridge's existing behavior). No `ProbeQuery`
delegate needed.
- Replace the name-based predicates with tag-based predicates by adding tags at registration time:
`"database"` and `"akka-cluster"` → `["ready"]`; `"active-node"` → `["active"]`. Then call
`app.MapZbHealth()` instead of the two manual `MapHealthChecks` calls.
- **Add `/healthz`** — `MapZbHealth()` maps the bare liveness tier automatically. ScadaBridge
currently lacks this endpoint.
- Switch `ResponseWriter` from `UIResponseWriter.WriteHealthCheckUIResponse` to the shared
canonical writer (a convergence item — `HealthChecks.UI.Client` style lifted to the default in
`ZB.MOM.WW.Health`).
**Keep bespoke:**
- `HealthMonitoring/` domain pipeline (`SiteHealthCollector`, `CentralHealthAggregator`, etc.) —
entirely per-project, no shared-library equivalent.
- `IActiveNodeGate` moves from the `InboundAPI` project to `ZB.MOM.WW.Health` (core package) on
adoption. `InboundApiEndpointFilter` references the shared interface; `AkkaActiveNodeGate`
(from `ZB.MOM.WW.Health.Akka`) becomes the singleton implementation registered in DI. The
interface definition is no longer owned by the `InboundAPI` project.
- The Central UI's `/monitoring/health` endpoint — powered by `CentralHealthAggregator`, not by
ASP.NET health checks.
- The comment at `Program.cs:217–221` explains the readiness design decision (standby nodes are
ready; leadership is a separate concern). This intent is preserved by the tag-based approach.
**Adoption is a follow-on task** (tracked in `GAPS.md`), not part of the `ZB.MOM.WW.Health`
library build. The library build delivers the shared implementations; adoption lands in the
ScadaBridge repo as a separate commit once the nupkg is available.
| SPEC | Status | Note |
|---|---|---|
| §1 three tiers via `MapZbHealth`, canonical tags | ✅ | both roots |
| §1 nothing on `live` | ✅ | |
| §2.1 `DatabaseHealthCheck<T>` | ✅ central | site uses a custom LocalDb check (no EF on sites) |
| §2.2 / §7(b) Akka probe, Default policy | ✅ | |
| §2.3 `ActiveNodeHealthCheck` | ✅ central; 🟡 site | site adapter is Unhealthy (not Degraded) during startup and carries no `selfAddress`/`activeNode` `data` |
| §2.4 downstream probe | n/a | DCL connection health is reported through HealthMonitoring, not readiness |
| §3 canonical JSON writer | ✅ | |
| §4 / §7(e) `IActiveNodeGate` | 🟡 | same rule, local interface type (GAPS L2 deferral) |