Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ad967083 | |||
| 654df8abc2 | |||
| c8e2f4da02 | |||
| d66e0d585f | |||
| caf14a3e03 | |||
| f679d5c749 | |||
| eca69505bc | |||
| 4a6341d871 | |||
| 69b3ccfc37 | |||
| cf3bd52f93 | |||
| dced0d2794 | |||
| 34227991ea | |||
| 9d925c3347 | |||
| 8c6fc2f886 |
@@ -134,8 +134,13 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
- **Operational constraints (read before upgrading a site pair):** stop and start both nodes TOGETHER — rolling one at a time is no longer supported, since the legacy `SfBufferSnapshot` compatibility handler went with the replicator. And a node offline longer than `LocalDb:Replication:TombstoneRetention` (default 7 days) can resurrect deleted rows on rejoin. See `docs/deployment/topology-guide.md`.
|
- **Operational constraints (read before upgrading a site pair):** stop and start both nodes TOGETHER — rolling one at a time is no longer supported, since the legacy `SfBufferSnapshot` compatibility handler went with the replicator. And a node offline longer than `LocalDb:Replication:TombstoneRetention` (default 7 days) can resurrect deleted rows on rejoin. See `docs/deployment/topology-guide.md`.
|
||||||
- `LocalDb:Replication:MaxBatchSize` batches by ROW COUNT, not bytes, against a 4 MB gRPC cap — the rig pins it to **16** (~70 KB worst-case `config_json` x 16 ~= 1.1 MB). The 500 default would allow ~35 MB.
|
- `LocalDb:Replication:MaxBatchSize` batches by ROW COUNT, not bytes, against a 4 MB gRPC cap — the rig pins it to **16** (~70 KB worst-case `config_json` x 16 ~= 1.1 MB). The 500 default would allow ~35 MB.
|
||||||
- All timestamps are UTC throughout the system.
|
- All timestamps are UTC throughout the system.
|
||||||
- Inter-cluster communication uses two transports: ClusterClient for command/control (deployments, lifecycle, subscribe/unsubscribe handshake, snapshots) and gRPC server-streaming for real-time data (attribute values, alarm states). Both CentralCommunicationActor and SiteCommunicationActor registered with receptionist. Central creates one ClusterClient per site using NodeA/NodeB as contact points. Sites configure multiple central contact points for failover. Addresses cached in CentralCommunicationActor, refreshed periodically (60s) and on admin changes. Heartbeats serve health monitoring only.
|
- Inter-cluster communication uses **three** transports, not two: **ClusterClient** for command/control (deployments, lifecycle, subscribe/unsubscribe handshake, snapshots); **gRPC** server-streaming for real-time data (attribute values, alarm states); and **plain token-gated HTTP** for the deployment config itself — notify-and-fetch, the site pulls the config from `DeploymentConfigEndpoints` (`ManagementService/DeploymentConfigEndpoints.cs`) with an `X-Deployment-Token` header, `AllowAnonymous` with the per-deployment token as the entire security boundary. Both CentralCommunicationActor and SiteCommunicationActor registered with receptionist (**per node, not as a singleton** — contact rotation reaches whichever node answers). Central creates one ClusterClient per site using NodeA/NodeB as contact points. Sites configure multiple central contact points for failover. Addresses cached in CentralCommunicationActor, refreshed periodically (60s) and on admin changes. Heartbeats serve health monitoring only. **Discovery is asymmetric by design:** central discovers sites from the *database* (`Site.NodeAAddress`/`NodeBAddress`, refreshable at runtime), sites discover central from *appsettings* (`ScadaBridge:Communication:CentralContactPoints`, static — restart required). **Central never buffers for an unreachable site** — the send is dropped with a warning and the caller's Ask times out; a `ConnectionStateChanged` mechanism built for this was deleted as dead code.
|
||||||
- gRPC streaming channel: SiteStreamGrpcServer on each site node (Kestrel HTTP/2, port 8083); central creates per-site SiteStreamGrpcClient via SiteStreamGrpcClientFactory. Site entity has GrpcNodeAAddress/GrpcNodeBAddress fields. Proto: sitestream.proto with SiteStreamService, SiteStreamEvent (oneof: AttributeValueUpdate, AlarmStateUpdate). DebugStreamEvent message removed (no longer flows through ClusterClient).
|
- **All clusters share ONE ActorSystem name**, `"scadabridge"` — hardcoded at `AkkaHostedService.cs:191`. Central and each site are separate clusters *only* by seed-node partitioning. This is required, not incidental: Akka.Remote address matching means a ClusterClient could not reach a differently-named system.
|
||||||
|
- **`ActiveNodeEvaluator.SelfIsOldestUp` is THE single definition of "active node"** (`Communication/ClusterState/ActiveNodeEvaluator.cs`) — the **oldest Up member** in a role scope, and explicitly **never `cluster.State.Leader`**: leadership (lowest address) is an Akka-internal concept that diverges from singleton placement permanently once the original first node restarts and rejoins, and both sides claim it during a partition. The equivalence *oldest-Up == where `ClusterSingletonManager` places singletons* **is** the design. `ClusterActivityEvaluator.SelfIsOldest`, the S&F delivery gate, `/health/active` and the heartbeat `IsActive` stamp all delegate here.
|
||||||
|
- Site nodes carry **two Akka roles**: the base `Site` plus a site-specific `site-{SiteId}` (`BuildRoles`, `AkkaHostedService.cs:386`). Singletons scope to the **site-specific** role.
|
||||||
|
- **Neither inter-cluster transport is authenticated or encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths`; the gRPC listener is **h2c** and `LocalDbSyncAuthInterceptor` gates *only* `/localdb_sync.v1.LocalDbSync/`, so the whole `SiteStreamService` surface — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — is callable by anyone who can reach :8083. The boundary assumes a trusted network. (The HTTP fetch token and the LocalDb sync interceptor are the only authenticated pieces.)
|
||||||
|
- **Akka frame size is the default 128 KB with `log-frame-size-exceeding` off**, and no custom serializer is configured — so payload carried over ClusterClient is JSON-escaped a second time by the default Newtonsoft serializer, roughly doubling it. Over the limit the transport drops **that one message** without tearing down the association (heartbeats keep flowing, the site still reports healthy) and the central Ask simply times out. See `docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`; `DeployArtifactsCommand` still carries payload and remains exposed.
|
||||||
|
- gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. There is **no gRPC server on central at all**, which is why the two `Ingest*` unary RPCs — documented as a "central-side ingest surface" — are dead in practice (acknowledged in `AkkaHostedService.cs:510-519`); sites reach central over ClusterClient instead. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Site entity has GrpcNodeAAddress/GrpcNodeBAddress fields. Proto: `sitestream.proto`, **6 RPCs** (2 server-streaming: `SubscribeInstance`, `SubscribeSite` (site-wide, alarm-only); 4 unary: `IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents`, `PullSiteCalls`), `SiteStreamEvent` (oneof: AttributeValueUpdate, AlarmStateUpdate). Field numbers are never reused; evolution is additive only (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle. DebugStreamEvent message removed (no longer flows through ClusterClient).
|
||||||
- Native alarms: a read-only mirror of native alarms from OPC UA Alarms & Conditions servers and the MxAccess Gateway, unified onto an A&C-style condition model (`AlarmConditionState`: orthogonal Active/Acked/Confirmed/Shelved/Suppressed + 0–1000 severity) plus an `AlarmKind` discriminator (Computed/NativeOpcUa/NativeMxAccess). New DCL capability seam `IAlarmSubscribableConnection` (implemented by the OPC UA and MxGateway adapters); the `DataConnectionActor` opens ONE alarm feed per connection and routes transitions to instances by source-object reference. A `NativeAlarmActor` (peer to the computed `AlarmActor` under `InstanceActor`) mirrors one source binding: snapshot atomic-swap on (re)subscribe, retention (drops once inactive+acked), per-source cap, and site SQLite persistence (`native_alarm_state`, survives failover, cleared on redeploy/undeploy — mirrors static overrides). State streams to central over the additively-enriched gRPC `AlarmStateUpdate` (the existing computed `AlarmStateChanged` was enriched additively) and seeds via the DebugView snapshot. Authoring: `TemplateNativeAlarmSource` / `InstanceNativeAlarmSourceOverride` entities flatten to `ResolvedNativeAlarmSource` (inherit/compose/override); management commands + ManagementActor handlers + CLI (`template/instance native-alarm-source`) + Central UI (template editor tab + instance override panel) + enriched DebugView alarm table. Read-only — no ack-back; no central tables.
|
- Native alarms: a read-only mirror of native alarms from OPC UA Alarms & Conditions servers and the MxAccess Gateway, unified onto an A&C-style condition model (`AlarmConditionState`: orthogonal Active/Acked/Confirmed/Shelved/Suppressed + 0–1000 severity) plus an `AlarmKind` discriminator (Computed/NativeOpcUa/NativeMxAccess). New DCL capability seam `IAlarmSubscribableConnection` (implemented by the OPC UA and MxGateway adapters); the `DataConnectionActor` opens ONE alarm feed per connection and routes transitions to instances by source-object reference. A `NativeAlarmActor` (peer to the computed `AlarmActor` under `InstanceActor`) mirrors one source binding: snapshot atomic-swap on (re)subscribe, retention (drops once inactive+acked), per-source cap, and site SQLite persistence (`native_alarm_state`, survives failover, cleared on redeploy/undeploy — mirrors static overrides). State streams to central over the additively-enriched gRPC `AlarmStateUpdate` (the existing computed `AlarmStateChanged` was enriched additively) and seeds via the DebugView snapshot. Authoring: `TemplateNativeAlarmSource` / `InstanceNativeAlarmSourceOverride` entities flatten to `ResolvedNativeAlarmSource` (inherit/compose/override); management commands + ManagementActor handlers + CLI (`template/instance native-alarm-source`) + Central UI (template editor tab + instance override panel) + enriched DebugView alarm table. Read-only — no ack-back; no central tables.
|
||||||
- OPC UA / MxGateway UX (M7): operator **Alarm Summary** page (`/monitoring/alarms`, RequireDeployment, read-only) fans out the existing per-instance `DebugViewSnapshot` Ask (SemaphoreSlim-capped, partial-results tolerant) and aggregates client-side — no central alarm store; shared `AlarmStateBadges` component. **Aggregated live stream shipped 2026-07-10** (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`): a transient in-memory per-site central live cache (`ISiteAlarmLiveCache`) fed by a site-wide, alarm-only `SubscribeSite` gRPC stream (seed-then-stream), pushing near-real-time deltas to the page over the Blazor circuit with the 15s poll kept as fallback + NotReporting authority — still no persisted central alarm store. OPC UA node browser gains `BrowseNext` continuation paging ("Load more"), a bounded recursive address-space **search** (`IAddressSpaceSearchable` seam; depth + result caps; substring on DisplayName/path), and **type-info** (DataType/ValueRank/Writable on `BrowseNode` for Variables). Attribute-override **CSV bulk import** (`OverrideCsvParser`, all-or-nothing) via InstanceConfigure `InputFile` + CLI `instance import-overrides --file` (native-alarm-source-override CSV deferred). **Verify-endpoint** probe (temporary `RealOpcUaClient`, short timeout, captures an untrusted server cert but NEVER trusts it) + **site-local cert trust**: per-node `CertStoreActor` (runs on every site node, not a singleton) writing the `.der` into the node's OPC UA trusted-peer PKI store; DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to BOTH site nodes so PKI stores stay consistent across failover; Admin-gated cert-management UI (`/design/connections/{id}/certificates`). No central persistence of cert trust (follow-up).
|
- OPC UA / MxGateway UX (M7): operator **Alarm Summary** page (`/monitoring/alarms`, RequireDeployment, read-only) fans out the existing per-instance `DebugViewSnapshot` Ask (SemaphoreSlim-capped, partial-results tolerant) and aggregates client-side — no central alarm store; shared `AlarmStateBadges` component. **Aggregated live stream shipped 2026-07-10** (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`): a transient in-memory per-site central live cache (`ISiteAlarmLiveCache`) fed by a site-wide, alarm-only `SubscribeSite` gRPC stream (seed-then-stream), pushing near-real-time deltas to the page over the Blazor circuit with the 15s poll kept as fallback + NotReporting authority — still no persisted central alarm store. OPC UA node browser gains `BrowseNext` continuation paging ("Load more"), a bounded recursive address-space **search** (`IAddressSpaceSearchable` seam; depth + result caps; substring on DisplayName/path), and **type-info** (DataType/ValueRank/Writable on `BrowseNode` for Variables). Attribute-override **CSV bulk import** (`OverrideCsvParser`, all-or-nothing) via InstanceConfigure `InputFile` + CLI `instance import-overrides --file` (native-alarm-source-override CSV deferred). **Verify-endpoint** probe (temporary `RealOpcUaClient`, short timeout, captures an untrusted server cert but NEVER trusts it) + **site-local cert trust**: per-node `CertStoreActor` (runs on every site node, not a singleton) writing the `.der` into the node's OPC UA trusted-peer PKI store; DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to BOTH site nodes so PKI stores stay consistent across failover; Admin-gated cert-management UI (`/design/connections/{id}/certificates`). No central persistence of cert trust (follow-up).
|
||||||
|
|
||||||
@@ -208,11 +213,13 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.) Pending secured writes expire server-side after a configurable TTL (`ManagementServiceOptions.SecuredWritePendingTtl`, default 24 h): an overdue `Pending` row is CAS'd to `Expired` (never relayed) — enforced at approve/reject and swept opportunistically on list (arch-review S2, `AuditKind.SecuredWriteExpire`).
|
- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.) Pending secured writes expire server-side after a configurable TTL (`ManagementServiceOptions.SecuredWritePendingTtl`, default 24 h): an overdue `Pending` row is CAS'd to `Expired` (never relayed) — enforced at approve/reject and swept opportunistically on list (arch-review S2, `AuditKind.SecuredWriteExpire`).
|
||||||
|
|
||||||
### Cluster & Failover
|
### Cluster & Failover
|
||||||
- Keep-oldest split-brain resolver with `down-if-alone = on`, 15s stable-after.
|
- **`auto-down` downing strategy (decision 2026-07-21 — availability over partition-safety).** Akka's `AutoDowning` provider, `auto-down-unreachable-after` = 15s: the leader among the REACHABLE members downs the unreachable peer, so a hard crash of EITHER node (active/oldest included) fails over to the survivor in ~25s. Accepted trade: a real partition → dual-active until an operator restarts one side. `keep-oldest` remains a supported `SplitBrainResolverStrategy` value (partition-safe, but an oldest-crash is a total outage — Akka's `down-if-alone` only rescues a side with ≥2 members, proven live + in 1.5.62 source). Decision record: `docs/plans/2026-07-21-auto-down-availability-decision.md`.
|
||||||
- Both nodes are seed nodes. `min-nr-of-members = 1`.
|
- Both nodes are seed nodes. `min-nr-of-members = 1`.
|
||||||
- Failure detection: 2s heartbeat, 10s threshold. Total failover ~25s.
|
- Failure detection: 2s heartbeat, 10s threshold. Total failover ~25s (drill-measured 2026-07-21 under auto-down: active-crash TAKEOVER in 28s, standby-crash removal in 27s with 0 routing blips — `docker/failover-drill.sh`).
|
||||||
- CoordinatedShutdown for graceful singleton handover.
|
- CoordinatedShutdown for graceful singleton handover.
|
||||||
- Automatic dual-node recovery from persistent storage.
|
- Automatic dual-node recovery from persistent storage.
|
||||||
|
- **Active/standby is decided by `ActiveNodeEvaluator.SelfIsOldestUp`, never by cluster leadership** — see the Architecture note above. `/health/active` is **central-only** (site nodes map no `/health/*` at all) and backs both Traefik's active-node routing and `IActiveNodeGate`, so the proxy and the Inbound API always agree on which node is active. Central never needs to know which *site* node is active: ClusterClient contact rotation reaches either receptionist and the site-internal `ClusterSingletonProxy` lands the work on the active node for free. The **exception is gRPC**, which picks `GrpcNodeAAddress`/`GrpcNodeBAddress` explicitly and flips on error.
|
||||||
|
- **Seed-node ordering: every node lists ITSELF first (decision 2026-07-22) — the boot-alone gap is CLOSED.** Only `seed-nodes[0]` may self-join to form a new cluster (Akka runs `FirstSeedNodeProcess` for it, `JoinSeedNodeProcess` — which can never form one — for everyone else). All 14 shipped node appsettings now lead with the node's own address, so any node can cold-start alone and become operational unattended (~5s, `seed-node-timeout`); `StartupValidator` fails the boot if the ordering is broken (compares host AND port; Akka does no DNS canonicalisation). Two nodes cold-starting together while mutually reachable converge on ONE cluster via the `InitJoin` handshake — they split only under a genuine boot-time partition, the same class auto-down accepts. **An external self-form timer (`Cluster.Join(SelfAddress)` after a window) was implemented and REJECTED:** it sits outside the join handshake, so on a routine standby restart — where the peer is alive but the join is stalled behind removal of the node's own stale incarnation — it fires mid-join and permanently splits the pair (measured: still split after 90s). Regression tests: `SelfFirstSeedBootstrapTests`. The keep-oldest active-crash total outage was separately closed by the auto-down decision. See `docs/requirements/Component-ClusterInfrastructure.md` → Seed Node Ordering.
|
||||||
|
|
||||||
### UI & Monitoring
|
### UI & Monitoring
|
||||||
- Central UI: Blazor Server (ASP.NET Core + SignalR) with Bootstrap CSS. No third-party component frameworks (no Blazorise, MudBlazor, Radzen, etc.). Build custom Blazor components for tables, grids, forms, etc.
|
- Central UI: Blazor Server (ASP.NET Core + SignalR) with Bootstrap CSS. No third-party component frameworks (no Blazorise, MudBlazor, Radzen, etc.). Build custom Blazor components for tables, grids, forms, etc.
|
||||||
|
|||||||
@@ -108,9 +108,9 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.2.3" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.2.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.3" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.SqlServer" Version="0.2.3" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.SqlServer" Version="0.2.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Contracts" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb.Contracts" Version="0.1.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -145,6 +145,27 @@
|
|||||||
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Four NU1903 high-severity advisories (GHSA-23rf-6693-g89p, GHSA-8q5v-6pqq-x66h,
|
||||||
|
GHSA-cvvh-rhrc-wg4q, GHSA-g8r8-53c2-pm3f) landed in the NuGet audit data against
|
||||||
|
System.Security.Cryptography.Xml 10.0.7, pulled in TRANSITIVELY by
|
||||||
|
Microsoft.AspNetCore.DataProtection 10.0.7 (ConfigurationDatabase's DataProtection
|
||||||
|
key storage). With TreatWarningsAsErrors any FRESH restore — notably the docker
|
||||||
|
image build — went red (surfaced 2026-07-21; local builds had cached audit data).
|
||||||
|
|
||||||
|
Same pattern as SQLitePCLRaw above: pin the vulnerable transitive package to its
|
||||||
|
patched version (10.0.10) with an explicit <PackageReference> in the one project
|
||||||
|
where the chain enters (ConfigurationDatabase; every other resolver — AuditLog,
|
||||||
|
SiteCallAudit, Transport, PerformanceTests, tests — reaches it through that
|
||||||
|
ProjectReference). Bumping the DataProtection parent instead was tried and
|
||||||
|
rejected: 10.0.10 floors Microsoft.Extensions.* and (via the EFCore adapter)
|
||||||
|
Microsoft.EntityFrameworkCore at 10.0.10, forcing a family-wide servicing bump
|
||||||
|
(NU1605 downgrade errors) that belongs in its own reviewed commit.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
GHSA-pgww-w46g-26qg (NU1902, moderate) on AngleSharp, reached only transitively via bunit
|
GHSA-pgww-w46g-26qg (NU1902, moderate) on AngleSharp, reached only transitively via bunit
|
||||||
in ZB.MOM.WW.ScadaBridge.CentralUI.Tests. With TreatWarningsAsErrors it made the WHOLE
|
in ZB.MOM.WW.ScadaBridge.CentralUI.Tests. With TreatWarningsAsErrors it made the WHOLE
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
|
|||||||
| `EventId` | `uniqueidentifier` PK | Generated where the event originates (site or central). Idempotency key. |
|
| `EventId` | `uniqueidentifier` PK | Generated where the event originates (site or central). Idempotency key. |
|
||||||
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
|
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
|
||||||
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
||||||
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
|
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound` \| `SecuredWrite` \| `Cluster`. The last two are not script trust-boundary crossings: `SecuredWrite` records the two-person write lifecycle, and `Cluster` records operator-initiated topology actions (admin-triggered manual failover, decision 2026-07-22). |
|
||||||
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
||||||
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
||||||
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events (inbound API, central notification dispatch). |
|
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events (inbound API, central notification dispatch). |
|
||||||
@@ -135,7 +135,7 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
|
|||||||
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X."
|
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X."
|
||||||
- Partitioning by month on `OccurredAtUtc` from day one (purge becomes a partition switch instead of a delete storm).
|
- Partitioning by month on `OccurredAtUtc` from day one (purge becomes a partition switch instead of a delete storm).
|
||||||
|
|
||||||
**`Kind` values (flat — 10 discriminators across all channels):**
|
**`Kind` values (flat — 17 discriminators across all channels; pinned by `AuditEnumTests`):**
|
||||||
|
|
||||||
| Kind | Fires when |
|
| Kind | Fires when |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -149,6 +149,13 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
|
|||||||
| `InboundAuthFailure` | An inbound API request was rejected at the auth boundary (bad/missing key). One row, `Status=Failed`, `HttpStatus=401`. |
|
| `InboundAuthFailure` | An inbound API request was rejected at the auth boundary (bad/missing key). One row, `Status=Failed`, `HttpStatus=401`. |
|
||||||
| `CachedSubmit` | Script-side enqueue of a cached call (`ExternalSystem.CachedCall` / `Database.CachedWrite`); first row in the cached-call lifecycle, written to site SQLite before any forward attempt. |
|
| `CachedSubmit` | Script-side enqueue of a cached call (`ExternalSystem.CachedCall` / `Database.CachedWrite`); first row in the cached-call lifecycle, written to site SQLite before any forward attempt. |
|
||||||
| `CachedResolve` | Terminal row for a cached operation — `Status` = `Delivered` / `Failed` / `Parked` / `Discarded`. |
|
| `CachedResolve` | Terminal row for a cached operation — `Status` = `Delivered` / `Failed` / `Parked` / `Discarded`. |
|
||||||
|
| `SecuredWriteSubmit` | An Operator submitted a two-person secured write; row written after the `PendingSecuredWrite` is persisted so it carries the store-assigned id as `CorrelationId`. |
|
||||||
|
| `SecuredWriteApprove` | A distinct Verifier approved a pending secured write (no self-approval; enforced server-side). |
|
||||||
|
| `SecuredWriteReject` | A Verifier rejected a pending secured write. |
|
||||||
|
| `SecuredWriteExecute` | An approved secured write was relayed to the site MxGateway connection. |
|
||||||
|
| `SecuredWriteExpire` | A `Pending` secured write aged past its server-side TTL and was transitioned to `Expired` without executing — emitted by the system (no verifier). |
|
||||||
|
| `ReconciliationAbandoned` | A reconciliation pull row failed to insert up to the permanent-abandon threshold and central advanced its cursor past it; one synthetic row so the loss is queryable in the Audit Log itself. |
|
||||||
|
| `ManualFailover` | An administrator triggered a manual failover of the central pair from the Health page; one row per invocation, written BEFORE the graceful `Cluster.Leave` is issued. `Target` = the leaving node's address. |
|
||||||
|
|
||||||
### Site: `AuditLog` (SQLite)
|
### Site: `AuditLog` (SQLite)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081",
|
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081",
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081"
|
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
},
|
},
|
||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081",
|
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081",
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081"
|
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"akka.tcp://scadabridge@scadabridge-env2-site-x-a:8082",
|
"akka.tcp://scadabridge@scadabridge-env2-site-x-a:8082",
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-site-x-b:8082"
|
"akka.tcp://scadabridge@scadabridge-env2-site-x-b:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
},
|
},
|
||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-site-x-a:8082",
|
"akka.tcp://scadabridge@scadabridge-env2-site-x-b:8082",
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-site-x-b:8082"
|
"akka.tcp://scadabridge@scadabridge-env2-site-x-a:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
+41
-12
@@ -273,29 +273,43 @@ All test passwords are `password`. See `infra/glauth/config.toml` for the full l
|
|||||||
### Automated failover drill (`failover-drill.sh`)
|
### Automated failover drill (`failover-drill.sh`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DRILL_MODE=standby bash docker/failover-drill.sh # default — survivable younger-node crash
|
DRILL_MODE=standby bash docker/failover-drill.sh # default — younger-node crash, active untouched
|
||||||
DRILL_MODE=active bash docker/failover-drill.sh # oldest-node crash — measures the registered outage gap
|
DRILL_MODE=active bash docker/failover-drill.sh # oldest-node crash — survivor must TAKE OVER
|
||||||
```
|
```
|
||||||
|
|
||||||
The scripted drill (`docker kill` = SIGKILL, the hard-crash path — a `docker stop` would take the graceful `CoordinatedShutdown` path and would not prove crash recovery) has **two modes**, because under the unified oldest-member semantics the *active* node IS the oldest, i.e. the one crash two-node keep-oldest cannot survive:
|
The scripted drill (`docker kill` = SIGKILL, the hard-crash path — a `docker stop` would take the graceful `CoordinatedShutdown` path and would not prove crash recovery) has **two modes**, and since the **auto-down decision (2026-07-21)** both expect recovery — the cluster runs Akka's `AutoDowning` provider (`auto-down-unreachable-after` = 15s), under which the leader among the *reachable* members downs the unreachable peer, so a crash of either node fails over:
|
||||||
|
|
||||||
- **`DRILL_MODE=standby` (default) — kills the STANDBY (younger) central node.** The survivable direction: SBR downs the crashed member and the active node keeps its singletons. Expected result: **no routing outage at all** (the active node is never touched, so `/health/active` blips = 0) and member removal on the survivor within **~25s** (10s failure-detection threshold + 15s stable-after; the 2s heartbeat interval is not additive). PASS = the survivor logs the member removal within `TIMEOUT_S` (default 90s) while routing stays up.
|
- **`DRILL_MODE=standby` (default) — kills the STANDBY (younger) central node.** The active node is untouched: expected result is **no routing outage at all** (`/health/active` blips = 0) and member removal on the survivor within **~25s** (10s failure-detection threshold + 15s auto-down window; the 2s heartbeat interval is not additive). PASS = the survivor logs the downing/removal within `TIMEOUT_S` (default 90s) while routing stays up.
|
||||||
- **`DRILL_MODE=active` — kills the ACTIVE (oldest) central node.** Expected result: a **total central outage** until the victim container is restarted — this is the registered deferred keep-oldest decision (master tracker 2026-07-08): keep-oldest downs the partition *without* the oldest, so the younger survivor downs itself, and it cannot re-form a cluster alone (see the seed-node constraint below). The drill confirms the dark window, then recovery within ~2 min of restarting the victim. The mode exists to make the registered gap *observable*, not to pretend it is covered.
|
- **`DRILL_MODE=active` — kills the ACTIVE (oldest) central node.** The survivor must **take over while the victim is still down**: it auto-downs the dead oldest, becomes the oldest member itself, re-hosts all singletons, and its `/health/active` goes 200. PASS = survivor active within `TIMEOUT_S`, then Traefik routing to it. (Under the pre-2026-07-21 `keep-oldest` strategy this direction was a proven total outage — the younger survivor took `DownReachable` and downed itself, because Akka's `down-if-alone` only rescues a side with ≥ 2 members.)
|
||||||
|
|
||||||
The drill exercises S1 (SBR downing on hard crash), S3 (single active node routed through Traefik), and the Task 20 restart/rejoin contract. Requires a running cluster (`bash docker/deploy.sh`) and `curl` + `docker` on the host.
|
Both modes finish by restarting the victim and confirming it rejoins as a ready standby. The drill exercises downing-on-hard-crash, S3 (single active node routed through Traefik), and the Task 20 restart/rejoin contract. Requires a running cluster (`bash docker/deploy.sh`) and `curl` + `docker` on the host.
|
||||||
|
|
||||||
**Seed-node bootstrap constraint.** Only the FIRST seed in `Cluster:SeedNodes` may self-join to form a *new* cluster. Both central nodes list `scadabridge-central-a` first (`docker/central-node-a/appsettings.Central.json`, `docker/central-node-b/appsettings.Central.json`), so a lone restarted `central-b` (with `central-a` still down) loops on `InitJoin` forever — it never reaches `Up`, and `/health/active` never returns 200. Operator recovery actions: **(1)** restart the dead first-seed node (`central-a`) — preferred; or **(2)** restart the survivor with a self-first seed override (env `ScadaBridge__Cluster__SeedNodes__0=akka.tcp://scadabridge@<self-host>:8081`, `ScadaBridge__Cluster__SeedNodes__1=<peer>`). The repo deliberately does NOT ship self-first ordering per node: with *both* nodes self-first, a simultaneous cold start can let each self-join independently → two one-node clusters that never merge (the cold-start split-brain the identical-seed-order convention exists to prevent). The real remedy is the pending keep-oldest topology/strategy decision (deferred, owner: user).
|
**Partition trade (accepted).** Auto-down is availability-first: in a *real network partition* (both nodes alive, link cut) each side downs the other and both run active — dual-active until an operator restarts one side after the partition heals. This was an explicit owner decision (2026-07-21): site pairs have no shared lease infrastructure to arbitrate, and a stalled system is a bigger risk than a rare partition. See `docs/plans/2026-07-21-auto-down-availability-decision.md`.
|
||||||
|
|
||||||
> **Observed results** (plan R2-01 T3):
|
**Seed-node ordering — every node lists ITSELF first (decision 2026-07-22).** Akka runs `FirstSeedNodeProcess` — the only bootstrap path that can form a *new* cluster when no peer answers `InitJoin` — exclusively when `seed-nodes[0]` is the node's own address; every other node runs `JoinSeedNodeProcess`, which retries `InitJoin` forever and can never form a cluster. Each shipped node config therefore lists itself first and its partner second (`docker/central-node-b/appsettings.Central.json` leads with `scadabridge-central-b`), and `StartupValidator` fails the boot if that ordering is ever broken. This closes the former **registered outage gap**, where a lone cold-starting `central-b` (with `central-a` down) never came `Up` and recovery was operator-driven.
|
||||||
|
|
||||||
|
Self-first ordering is safe, and the three interesting cases are covered by `SelfFirstSeedBootstrapTests` (real in-process clusters at production failure-detection timings):
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Lone cold-start, peer dead | Forms alone in ~5s (`seed-node-timeout`) — operational, unattended |
|
||||||
|
| Restart into a **live** peer | `InitJoinAck` answers, node rejoins; never islands |
|
||||||
|
| Both cold-start simultaneously (mutually reachable) | The `InitJoin` handshake resolves it *before* either self-joins → **one** 2-member cluster |
|
||||||
|
|
||||||
|
> An earlier revision of this README claimed the repo deliberately avoided self-first ordering because simultaneous cold start would produce "two one-node clusters that never merge". That is **not** what happens while the nodes are mutually reachable — the handshake converges them (measured, row 3 above). Only a genuine boot-time *partition* splits them, which is the same class `auto-down` already accepts.
|
||||||
|
|
||||||
|
> **Rejected alternative — an external self-form timer.** A watchdog that waits N seconds for membership and then calls `Cluster.Join(SelfAddress)` was implemented and discarded: it cannot see Akka's join handshake, so it cannot distinguish "no seed answered" from "a seed answered and the join is in flight". On a routine standby restart the peer is alive but the join stalls behind removal of the restarting node's own stale incarnation; a `Join(self)` issued during `TryingToJoin` abandons the in-flight join and forms a second cluster at the same address — a **permanent** split (measured: still split after 90s). Akka's own first-seed process has no such race because it *is* part of the handshake.
|
||||||
|
|
||||||
|
> **Observed results** (auto-down decision verification):
|
||||||
>
|
>
|
||||||
> **Run 2026-07-13** against a freshly-deployed cluster on `main` @ `99544985` (round-2 merged image; `active=central-a`). Both directions behaved exactly as the design predicts.
|
> **Run 2026-07-21** against a freshly-deployed cluster with `SplitBrainResolverStrategy: auto-down` (first drill: `active=central-a`). Both directions recovered.
|
||||||
>
|
>
|
||||||
> | Direction (`DRILL_MODE`) | Outcome | Measured |
|
> | Direction (`DRILL_MODE`) | Outcome | Measured |
|
||||||
> |--------------------------|---------|----------|
|
> |--------------------------|---------|----------|
|
||||||
> | `standby` (younger-node crash) | **PASS** — SBR downed+removed the crashed `central-b`; active `central-a` kept all 7 singletons; recovered on restart. | Member removed in **27s** (budget ~25s: 10s detection + 15s stable-after); **0** `/health/active` routing blips (active node never touched); routable **0s** after victim restart. |
|
> | `active` (oldest-node crash) | **PASS — TAKEOVER** — `central-b` auto-downed the dead oldest, went `Younger -> Oldest` on all 7 singletons, and served `/health/active` **while the victim was still down**; restarted victim rejoined as standby. | Survivor active + Traefik routing in **28s** (budget ~25s: 10s detection + 15s auto-down + hand-over); victim ready **2s** after restart. |
|
||||||
> | `active` (oldest-node crash) | **Outage as designed** — killing the oldest/active `central-a` made the younger `central-b` self-down (total central outage — the registered keep-oldest gap); recovered after restarting the victim, `central-b` then assuming Oldest and re-hosting all singletons. | Outage confirmed at **9s**; central routable again **4s** after restarting `central-a`. |
|
> | `standby` (younger-node crash) | **PASS** — active node untouched; survivor downed+removed the crashed member; restarted victim rejoined as standby. | Member removed in **27s**; **0** `/health/active` routing blips; victim ready **2s** after restart. |
|
||||||
>
|
>
|
||||||
> Notes: the `standby` PASS shows the survivable direction is clean end-to-end (SBR `DownUnreachable` decision + per-singleton "Member removed" in the survivor log, zero routing interruption). The `active` result **empirically confirms the deferred keep-oldest topology gap** (master tracker 2026-07-08 / `docs/plans/2026-07-08-deferred-work-register.md`): a hard crash of the active/oldest central node is a total outage until that node (the first seed) is restarted — the remedy remains the pending topology/strategy decision. In-process envelope (`FailoverTimingTests`, plan R2-01 T4) independently measured full failover at **33.7s**.
|
> Historical baseline (keep-oldest, run 2026-07-13 on `99544985`): `standby` PASS with member removal in 27s / 0 routing blips; `active` was a **total outage** — `central-b` self-downed ~20s after the kill (live SBR log 2026-07-21: `SBR took decision Akka.Cluster.SBR.DownReachable … including myself`) and could not re-bootstrap until `central-a` returned. That result is what motivated the auto-down decision. In-process envelope (`FailoverTimingTests`) measured full failover at **33.7s**.
|
||||||
|
|
||||||
### Central Failover
|
### Central Failover
|
||||||
|
|
||||||
@@ -313,6 +327,14 @@ open http://localhost:9002
|
|||||||
docker start scadabridge-central-a
|
docker start scadabridge-central-a
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Manual failover from the UI (admin-only).** Instead of stopping a container, an Administrator can trigger a planned role swap from the **Trigger failover** button on the central-cluster card at `/monitoring/health` (via Traefik, `http://localhost:9000`). The active (oldest Up) node leaves the cluster **gracefully**, so singletons hand over rather than being killed; the node then restarts under `restart: unless-stopped` and rejoins as the standby.
|
||||||
|
|
||||||
|
- The button is disabled when the pair has no online standby — the same guard is re-enforced server-side, since failing over a lone node is an outage, not a failover.
|
||||||
|
- Triggering it **disconnects the page you clicked it on**: Traefik routes the UI to the active node, which is the node being restarted. The page reconnects against the new active node.
|
||||||
|
- Each invocation writes one `Cluster` / `ManualFailover` row to `dbo.AuditLog` naming the admin and the target address, written before the Leave is issued.
|
||||||
|
|
||||||
|
To verify on the rig: press the button, watch `central-a` restart and `central-b`'s badge flip to Primary, then confirm the audit row landed.
|
||||||
|
|
||||||
### Site Failover
|
### Site Failover
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -329,3 +351,10 @@ docker start scadabridge-site-a-a
|
|||||||
Same pattern applies for site-b (`scadabridge-site-b-a`/`scadabridge-site-b-b`) and site-c (`scadabridge-site-c-a`/`scadabridge-site-c-b`).
|
Same pattern applies for site-b (`scadabridge-site-b-a`/`scadabridge-site-b-b`) and site-c (`scadabridge-site-c-a`/`scadabridge-site-c-b`).
|
||||||
|
|
||||||
Failover takes approximately 25 seconds (2s heartbeat + 10s detection threshold + 15s stable-after for split-brain resolver).
|
Failover takes approximately 25 seconds (2s heartbeat + 10s detection threshold + 15s stable-after for split-brain resolver).
|
||||||
|
|
||||||
|
**Manual site failover from the UI (admin-only).** Each site card on `/monitoring/health` carries the same **Trigger failover** button as the central card. Central and each site are separate Akka clusters, so this is a *request* relayed over the ClusterClient command/control channel — the site's own communication actor performs the graceful `Leave` against its `site-{SiteId}` role and acks the result.
|
||||||
|
|
||||||
|
- Unlike central failover, this does **not** disconnect your page — a site is a different cluster.
|
||||||
|
- A refusal from the site (no standby, or a command addressed to a different site) reads differently from an unreachable site (Ask timeout); the UI shows the site's own reason. Only the timeout leaves any doubt about whether the failover took effect.
|
||||||
|
- A site running an older binary has no handler for the command, so it dead-letters and you see "site did not respond".
|
||||||
|
- Each invocation writes a `Cluster` / `ManualFailover` audit row stamped with the site id.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
},
|
},
|
||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"akka.tcp://scadabridge@scadabridge-central-b:8081",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"akka.tcp://scadabridge@scadabridge-central-a:8081"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
+52
-39
@@ -1,33 +1,33 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Failover drill against the running docker cluster (bash docker/deploy.sh first).
|
# Failover drill against the running docker cluster (bash docker/deploy.sh first).
|
||||||
#
|
#
|
||||||
# ROUND-2 REWRITE (arch-review 01 round 2, N1). The original drill killed the
|
# AUTO-DOWN REWRITE (decision 2026-07-21). The cluster now runs the 'auto-down'
|
||||||
# ACTIVE central node — but under the unified oldest-member semantics the
|
# downing strategy (availability-first): the leader among the REACHABLE members
|
||||||
# active node IS the oldest, i.e. the one crash two-node keep-oldest CANNOT
|
# downs the unreachable peer after StableAfter, so a hard crash of EITHER
|
||||||
# survive (registered deferred user decision, master tracker 2026-07-08;
|
# central node — the active/oldest included — fails over to the survivor. The
|
||||||
# SbrFailoverTests.cs XML doc). Two modes:
|
# accepted trade (made explicitly by the owner) is dual-active during a real
|
||||||
|
# network partition. Both drill directions therefore expect RECOVERY:
|
||||||
#
|
#
|
||||||
# DRILL_MODE=standby (default) — kills the STANDBY (younger) central node.
|
# DRILL_MODE=standby (default) — kills the STANDBY (younger) central node.
|
||||||
# The survivable direction: SBR downs the crashed member, the active node
|
# The active node is untouched: expect zero /health/active routing blips
|
||||||
# keeps its singletons, and Traefik routing never goes dark. PASS = the
|
# and member removal on the survivor within ~25s (10s failure detection +
|
||||||
# survivor logs the member removal within TIMEOUT_S (budget ~25s+: 10s
|
# 15s auto-down-unreachable-after).
|
||||||
# failure detection + 15s stable-after) while /health/active stays up.
|
|
||||||
#
|
#
|
||||||
# DRILL_MODE=active — kills the ACTIVE (oldest) central node. THE EXPECTED
|
# DRILL_MODE=active — kills the ACTIVE (oldest) central node. THE SURVIVOR
|
||||||
# OUTCOME IS A TOTAL CENTRAL OUTAGE: keep-oldest downs the partition
|
# MUST TAKE OVER: it downs the dead oldest, becomes oldest itself, hosts
|
||||||
# without the oldest, so the younger survivor downs ITSELF (down-if-alone
|
# the singletons, and /health/active goes 200 on the survivor WHILE THE
|
||||||
# cannot help — the alone-oldest is dead and cannot down itself), and the
|
# VICTIM IS STILL DOWN. Budget ~25s + singleton hand-over + health-probe
|
||||||
# self-downed survivor cannot re-form a cluster alone unless it is the
|
# margin. (Under the pre-2026-07-21 keep-oldest strategy this direction
|
||||||
# FIRST seed (both nodes list central-a first; only the first seed may
|
# was a total outage — the younger survivor downed ITSELF, verified live;
|
||||||
# self-join). This mode measures the dark window and PASSes only when
|
# Akka's down-if-alone only rescues a side with >= 2 members.)
|
||||||
# central recovers AFTER the victim container is restarted. It exists to
|
#
|
||||||
# make the registered gap observable — not to pretend it is covered.
|
# Both modes finish by restarting the victim and confirming it rejoins as a
|
||||||
|
# fresh incarnation (standby).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TRAEFIK_URL="${TRAEFIK_URL:-http://localhost:9000}"
|
TRAEFIK_URL="${TRAEFIK_URL:-http://localhost:9000}"
|
||||||
TIMEOUT_S="${TIMEOUT_S:-90}"
|
TIMEOUT_S="${TIMEOUT_S:-90}"
|
||||||
DRILL_MODE="${DRILL_MODE:-standby}"
|
DRILL_MODE="${DRILL_MODE:-standby}"
|
||||||
OUTAGE_CONFIRM_S="${OUTAGE_CONFIRM_S:-60}"
|
|
||||||
|
|
||||||
active_container() {
|
active_container() {
|
||||||
if curl -sf -o /dev/null "http://localhost:9001/health/active"; then echo scadabridge-central-a
|
if curl -sf -o /dev/null "http://localhost:9001/health/active"; then echo scadabridge-central-a
|
||||||
@@ -35,6 +35,7 @@ active_container() {
|
|||||||
else echo "ERROR: no active central node found" >&2; exit 1; fi
|
else echo "ERROR: no active central node found" >&2; exit 1; fi
|
||||||
}
|
}
|
||||||
peer_of() { [ "$1" = scadabridge-central-a ] && echo scadabridge-central-b || echo scadabridge-central-a; }
|
peer_of() { [ "$1" = scadabridge-central-a ] && echo scadabridge-central-b || echo scadabridge-central-a; }
|
||||||
|
port_of() { [ "$1" = scadabridge-central-a ] && echo 9001 || echo 9002; }
|
||||||
|
|
||||||
case "$DRILL_MODE" in
|
case "$DRILL_MODE" in
|
||||||
standby|active) ;;
|
standby|active) ;;
|
||||||
@@ -47,6 +48,7 @@ if [ "$DRILL_MODE" = standby ]; then
|
|||||||
else
|
else
|
||||||
VICTIM="$ACTIVE"; SURVIVOR=$(peer_of "$ACTIVE")
|
VICTIM="$ACTIVE"; SURVIVOR=$(peer_of "$ACTIVE")
|
||||||
fi
|
fi
|
||||||
|
SURVIVOR_PORT=$(port_of "$SURVIVOR")
|
||||||
|
|
||||||
echo "mode=${DRILL_MODE} active=${ACTIVE} victim=${VICTIM} survivor=${SURVIVOR}"
|
echo "mode=${DRILL_MODE} active=${ACTIVE} victim=${VICTIM} survivor=${SURVIVOR}"
|
||||||
KILL_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
KILL_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
@@ -54,60 +56,71 @@ docker kill "${VICTIM}" > /dev/null
|
|||||||
START=$(date +%s)
|
START=$(date +%s)
|
||||||
|
|
||||||
if [ "$DRILL_MODE" = standby ]; then
|
if [ "$DRILL_MODE" = standby ]; then
|
||||||
echo "Standby crash: waiting for ${SURVIVOR} to DOWN+REMOVE the dead member (SBR budget ~25s)..."
|
echo "Standby crash: waiting for ${SURVIVOR} to DOWN+REMOVE the dead member (budget ~25s)..."
|
||||||
BLIPS=0
|
BLIPS=0
|
||||||
while true; do
|
while true; do
|
||||||
ELAPSED=$(( $(date +%s) - START ))
|
ELAPSED=$(( $(date +%s) - START ))
|
||||||
curl -sf -o /dev/null "${TRAEFIK_URL}/health/active" || BLIPS=$((BLIPS + 1))
|
curl -sf -o /dev/null "${TRAEFIK_URL}/health/active" || BLIPS=$((BLIPS + 1))
|
||||||
if docker logs --since "${KILL_AT}" "${SURVIVOR}" 2>&1 | grep -Eiq "marking.*node.*down|member removed|is removed"; then
|
if docker logs --since "${KILL_AT}" "${SURVIVOR}" 2>&1 | grep -Eiq "auto-downing|marking.*node.*down|member removed|is removed"; then
|
||||||
echo "PASS: survivor removed the crashed member in ${ELAPSED}s (budget ~25s: 10s detection + 15s stable-after)."
|
echo "PASS: survivor downed/removed the crashed member in ${ELAPSED}s (budget ~25s: 10s detection + 15s auto-down)."
|
||||||
echo "Active-node routing blips during the drill: ${BLIPS} (expected 0 — the active node was never touched)."
|
echo "Active-node routing blips during the drill: ${BLIPS} (expected 0 — the active node was never touched)."
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if (( ELAPSED > TIMEOUT_S )); then
|
if (( ELAPSED > TIMEOUT_S )); then
|
||||||
echo "FAIL: no downing/removal evidence on ${SURVIVOR} after ${ELAPSED}s — SBR did not act" >&2
|
echo "FAIL: no downing/removal evidence on ${SURVIVOR} after ${ELAPSED}s — auto-down did not act" >&2
|
||||||
docker start "${VICTIM}" > /dev/null
|
docker start "${VICTIM}" > /dev/null
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
echo "Active crash: EXPECTING a central outage (registered keep-oldest gap). Watching /health/active..."
|
echo "Active crash: waiting for ${SURVIVOR} to take over as the active node (victim stays DOWN; budget ~25s + hand-over)..."
|
||||||
DARK_STREAK=0
|
|
||||||
while true; do
|
while true; do
|
||||||
ELAPSED=$(( $(date +%s) - START ))
|
ELAPSED=$(( $(date +%s) - START ))
|
||||||
if curl -sf -o /dev/null "${TRAEFIK_URL}/health/active"; then DARK_STREAK=0; else DARK_STREAK=$((DARK_STREAK + 1)); fi
|
if curl -sf -o /dev/null "http://localhost:${SURVIVOR_PORT}/health/active"; then
|
||||||
if (( DARK_STREAK >= 10 )); then
|
echo "PASS: ${SURVIVOR} took over as active in ${ELAPSED}s with the victim still down"
|
||||||
echo "Outage confirmed at ${ELAPSED}s: no active central node — the younger survivor self-downed"
|
echo "(downed the dead oldest via auto-down, assumed Oldest, re-hosted the singletons)."
|
||||||
echo "(keep-oldest downs the partition WITHOUT the oldest; this is the registered deferred gap)."
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if (( ELAPSED > OUTAGE_CONFIRM_S )); then
|
if (( ELAPSED > TIMEOUT_S )); then
|
||||||
echo "NOTE: /health/active stayed reachable ${ELAPSED}s after killing the oldest — better than the"
|
echo "FAIL: ${SURVIVOR} never became active within ${ELAPSED}s of killing the oldest — takeover did not happen." >&2
|
||||||
echo "registered gap predicts. Do NOT celebrate: capture both nodes' logs and investigate before trusting it."
|
docker logs --since "${KILL_AT}" "${SURVIVOR}" 2>&1 | grep -Ei "sbr|downing|oldest|shutting down|terminated" | tail -20 >&2 || true
|
||||||
break
|
docker start "${VICTIM}" > /dev/null
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
echo "Confirming Traefik routes to the new active node..."
|
||||||
|
TR_START=$(date +%s)
|
||||||
|
while ! curl -sf -o /dev/null "${TRAEFIK_URL}/health/active"; do
|
||||||
|
if (( $(date +%s) - TR_START > 60 )); then
|
||||||
|
echo "FAIL: survivor is active but not routable through Traefik after 60s" >&2
|
||||||
|
docker start "${VICTIM}" > /dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Traefik routing recovered $(( $(date +%s) - START ))s after the kill."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Restarting ${VICTIM}..."
|
echo "Restarting ${VICTIM}..."
|
||||||
docker start "${VICTIM}" > /dev/null
|
docker start "${VICTIM}" > /dev/null
|
||||||
RESTART_AT=$(date +%s)
|
RESTART_AT=$(date +%s)
|
||||||
echo "Waiting for central to be routable again through Traefik (${TRAEFIK_URL}/health/active)..."
|
echo "Waiting for the restarted victim to rejoin as a ready standby (${VICTIM} /health/ready)..."
|
||||||
|
VICTIM_PORT=$(port_of "$VICTIM")
|
||||||
while true; do
|
while true; do
|
||||||
ELAPSED=$(( $(date +%s) - RESTART_AT ))
|
ELAPSED=$(( $(date +%s) - RESTART_AT ))
|
||||||
if curl -sf -o /dev/null "${TRAEFIK_URL}/health/active"; then
|
if curl -sf -o /dev/null "http://localhost:${VICTIM_PORT}/health/ready"; then
|
||||||
echo "Recovered: an active central node is routable ${ELAPSED}s after the victim restart."
|
echo "Recovered: ${VICTIM} is ready (rejoined as a fresh incarnation) ${ELAPSED}s after restart."
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if (( ELAPSED > 120 )); then
|
if (( ELAPSED > 120 )); then
|
||||||
echo "FAIL: central not routable 120s after restarting ${VICTIM}" >&2
|
echo "FAIL: ${VICTIM} not ready 120s after restart" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Survivor singleton/downing evidence (last 20 matching log lines from ${SURVIVOR}):"
|
echo "Survivor downing/singleton evidence (last 20 matching log lines from ${SURVIVOR}):"
|
||||||
docker logs "${SURVIVOR}" 2>&1 | grep -Ei "singleton|oldest|downing|removed" | tail -20 || true
|
docker logs "${SURVIVOR}" 2>&1 | grep -Ei "auto-downing|singleton|oldest|downing|removed" | tail -20 || true
|
||||||
echo "Drill complete (${DRILL_MODE}). Verify on the Health dashboard that both nodes show Up and exactly one is Primary."
|
echo "Drill complete (${DRILL_MODE}). Verify on the Health dashboard that both nodes show Up and exactly one is Primary."
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"akka.tcp://scadabridge@scadabridge-site-a-a:8082",
|
"akka.tcp://scadabridge@scadabridge-site-a-a:8082",
|
||||||
"akka.tcp://scadabridge@scadabridge-site-a-b:8082"
|
"akka.tcp://scadabridge@scadabridge-site-a-b:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
},
|
},
|
||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadabridge@scadabridge-site-a-a:8082",
|
"akka.tcp://scadabridge@scadabridge-site-a-b:8082",
|
||||||
"akka.tcp://scadabridge@scadabridge-site-a-b:8082"
|
"akka.tcp://scadabridge@scadabridge-site-a-a:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"akka.tcp://scadabridge@scadabridge-site-b-a:8082",
|
"akka.tcp://scadabridge@scadabridge-site-b-a:8082",
|
||||||
"akka.tcp://scadabridge@scadabridge-site-b-b:8082"
|
"akka.tcp://scadabridge@scadabridge-site-b-b:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
},
|
},
|
||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadabridge@scadabridge-site-b-a:8082",
|
"akka.tcp://scadabridge@scadabridge-site-b-b:8082",
|
||||||
"akka.tcp://scadabridge@scadabridge-site-b-b:8082"
|
"akka.tcp://scadabridge@scadabridge-site-b-a:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"akka.tcp://scadabridge@scadabridge-site-c-a:8082",
|
"akka.tcp://scadabridge@scadabridge-site-c-a:8082",
|
||||||
"akka.tcp://scadabridge@scadabridge-site-c-b:8082"
|
"akka.tcp://scadabridge@scadabridge-site-c-b:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
},
|
},
|
||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadabridge@scadabridge-site-c-a:8082",
|
"akka.tcp://scadabridge@scadabridge-site-c-b:8082",
|
||||||
"akka.tcp://scadabridge@scadabridge-site-c-b:8082"
|
"akka.tcp://scadabridge@scadabridge-site-c-a:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
# Cluster Infrastructure
|
# Cluster Infrastructure
|
||||||
|
|
||||||
The Cluster Infrastructure component manages Akka.NET cluster formation, active/standby failover, split-brain resolution, and the singleton hosting that all other ScadaBridge components depend on. Every site and central cluster is a two-node active/standby pair governed by the same configuration contract and bootstrap logic.
|
The Cluster Infrastructure component manages Akka.NET cluster formation, active/standby failover, the downing strategy for unreachable members, and the singleton hosting that all other ScadaBridge components depend on. Every site and central cluster is a two-node active/standby pair governed by the same configuration contract and bootstrap logic.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Cluster Infrastructure (#13) is a **design responsibility** spanning two projects rather than a single buildable project:
|
Cluster Infrastructure (#13) is a **design responsibility** spanning two projects rather than a single buildable project:
|
||||||
|
|
||||||
- **`src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/`** owns the cluster configuration contract: `ClusterOptions` (seed nodes, failure-detection timings, split-brain settings), `ClusterOptionsValidator`, and the `AddClusterInfrastructure` DI extension that registers the validator. It does not start an actor system.
|
- **`src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/`** owns the cluster configuration contract: `ClusterOptions` (seed nodes, failure-detection timings, downing strategy), `ClusterOptionsValidator`, and the `AddClusterInfrastructure` DI extension that registers the validator. It does not start an actor system.
|
||||||
- **`src/ZB.MOM.WW.ScadaBridge.Host/`** owns the cluster bootstrap and runtime wiring: `AkkaHostedService` builds the Akka HOCON from `ClusterOptions` and `NodeOptions`, starts the `ActorSystem`, wires `CoordinatedShutdown`, and creates all role-specific actors including the cluster singletons.
|
- **`src/ZB.MOM.WW.ScadaBridge.Host/`** owns the cluster bootstrap and runtime wiring: `AkkaHostedService` builds the Akka HOCON from `ClusterOptions` and `NodeOptions`, starts the `ActorSystem`, wires `CoordinatedShutdown`, and creates all role-specific actors including the cluster singletons.
|
||||||
|
|
||||||
This split is deliberate. The Host is the single deployable binary and the only project that performs Akka.NET bootstrap, so all cluster bring-up lives there. `ClusterInfrastructure` is the portable configuration contract that the Host consumes — it can be referenced by tests and other components without pulling in the Host.
|
This split is deliberate. The Host is the single deployable binary and the only project that performs Akka.NET bootstrap, so all cluster bring-up lives there. `ClusterInfrastructure` is the portable configuration contract that the Host consumes — it can be referenced by tests and other components without pulling in the Host.
|
||||||
|
|
||||||
Both central and site clusters run this same topology: two nodes, one active (cluster leader), one standby, with automatic failover and no manual intervention required for dual-node recovery.
|
Both central and site clusters run this same topology: two nodes, one active (the oldest `Up` member), one standby, with automatic failover and no manual intervention required for dual-node recovery.
|
||||||
|
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
### Active/standby via cluster leadership
|
### One `ActorSystem` name for every cluster
|
||||||
|
|
||||||
Akka.NET cluster leadership determines which node is "active". The cluster leader is the oldest node in the cluster, as tracked by the keep-oldest split-brain resolver. `ActiveNodeGate` (in the Host) exposes `IsActiveNode` by checking whether `cluster.SelfMember.Status == MemberStatus.Up` and `cluster.State.Leader == cluster.SelfAddress`. Cluster singletons — which run on the oldest `Up` member — automatically migrate to the surviving node on failover.
|
Every node in every cluster — central and all sites — joins an `ActorSystem` named **`"scadabridge"`**, hardcoded at `AkkaHostedService.cs:191` (`ActorSystem.Create("scadabridge", config)`). Central and each site are separate clusters *only* by seed-node partitioning, not by system name. This is required rather than incidental: Akka.Remote matches addresses including the system name, so a `ClusterClient` could not reach a differently-named system.
|
||||||
|
|
||||||
|
### Active/standby is the oldest `Up` member — never the cluster leader
|
||||||
|
|
||||||
|
A node is "active" when it is the **oldest `Up` member** of its role scope — the member `ClusterSingletonManager` places singletons on. Akka's *cluster leader* (lowest address) is a different, Akka-internal concept: it diverges from singleton placement permanently once the original first node restarts and rejoins. Every product-level active/standby decision therefore goes through one evaluator and never reads `cluster.State.Leader`:
|
||||||
|
|
||||||
|
- `ActiveNodeEvaluator.SelfIsOldestUp(Cluster, string? role)` (`Communication/ClusterState/ActiveNodeEvaluator.cs:35`) is the single implementation — self is `Up`, carries the role when one is given, and no other `Up` member in that scope is older (`self.IsOlderThan(m)`).
|
||||||
|
- `ClusterActivityEvaluator.SelfIsOldest` (`Host/Health/ClusterActivityEvaluator.cs:23`) delegates to it, and is what `ActiveNodeGate.IsActiveNode` (`Host/Health/ActiveNodeGate.cs:48`), `OldestNodeActiveHealthCheck`, and `AkkaClusterNodeProvider.SelfIsPrimary` all call.
|
||||||
|
- `SiteCommunicationActor` stamps its heartbeat's `IsActive` from the same evaluator (`Communication/Actors/SiteCommunicationActor.cs:517-518`).
|
||||||
|
|
||||||
|
Cluster singletons automatically migrate to the surviving node on failover, and because "active" is defined as the singleton-placement member, the health/routing view and the singleton view can never disagree.
|
||||||
|
|
||||||
### Configuration contract vs. bootstrap split
|
### Configuration contract vs. bootstrap split
|
||||||
|
|
||||||
@@ -33,20 +43,24 @@ Cluster Infrastructure provides the hosting platform; each singleton is owned an
|
|||||||
|
|
||||||
`AkkaHostedService.BuildHocon` constructs the Akka HOCON document from the bound options at startup. All interpolated values pass through `QuoteHocon` (string escaping) and `DurationHocon` (millisecond rendering) so the document is never corrupted by hostnames or timing values containing special characters or sub-second precision.
|
`AkkaHostedService.BuildHocon` constructs the Akka HOCON document from the bound options at startup. All interpolated values pass through `QuoteHocon` (string escaping) and `DurationHocon` (millisecond rendering) so the document is never corrupted by hostnames or timing values containing special characters or sub-second precision.
|
||||||
|
|
||||||
The snippet below is abbreviated to highlight the cluster stanzas. The full method also emits three additional stanzas: `akka.extensions` (registers `DistributedPubSubExtensionProvider`), `akka.remote.dot-netty.tcp` (binds `NodeOptions.NodeHostname` and `NodeOptions.RemotingPort`), and `akka.remote.transport-failure-detector` (heartbeat interval and acceptable-heartbeat-pause from `CommunicationOptions.TransportHeartbeatInterval` / `TransportFailureThreshold`).
|
The snippet below is abbreviated to highlight the cluster stanzas. The full method also emits `akka.extensions` (registers `DistributedPubSubExtensionProvider`), `akka.remote.dot-netty.tcp` (binds `NodeOptions.NodeHostname` and `NodeOptions.RemotingPort`), and `akka.remote.transport-failure-detector` (heartbeat interval and acceptable-heartbeat-pause from `CommunicationOptions.TransportHeartbeatInterval` / `TransportFailureThreshold`).
|
||||||
|
|
||||||
|
The downing block is **not** a fixed stanza — `BuildHocon` branches on `ClusterOptions.SplitBrainResolverStrategy` and emits one of two shapes (`AkkaHostedService.cs:275-286`):
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Abbreviated — see AkkaHostedService.BuildHocon for the full method.
|
// Abbreviated — see AkkaHostedService.BuildHocon for the full method.
|
||||||
public static string BuildHocon(
|
var downingBlock = string.Equals(
|
||||||
NodeOptions nodeOptions,
|
clusterOptions.SplitBrainResolverStrategy, "auto-down", StringComparison.OrdinalIgnoreCase)
|
||||||
ClusterOptions clusterOptions,
|
? $@"downing-provider-class = ""Akka.Cluster.AutoDowning, Akka.Cluster""
|
||||||
IEnumerable<string> roles,
|
auto-down-unreachable-after = {DurationHocon(clusterOptions.StableAfter)}"
|
||||||
TimeSpan transportHeartbeat,
|
: $@"downing-provider-class = ""Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster""
|
||||||
TimeSpan transportFailure)
|
split-brain-resolver {{
|
||||||
{
|
active-strategy = {QuoteHocon(clusterOptions.SplitBrainResolverStrategy)}
|
||||||
var seedNodesStr = string.Join(",",
|
stable-after = {DurationHocon(clusterOptions.StableAfter)}
|
||||||
clusterOptions.SeedNodes.Select(QuoteHocon));
|
keep-oldest {{
|
||||||
var rolesStr = string.Join(",", roles.Select(QuoteHocon));
|
down-if-alone = {(clusterOptions.DownIfAlone ? "on" : "off")}
|
||||||
|
}}
|
||||||
|
}}";
|
||||||
|
|
||||||
return $@"
|
return $@"
|
||||||
audit-telemetry-dispatcher {{
|
audit-telemetry-dispatcher {{
|
||||||
@@ -66,13 +80,7 @@ akka {{
|
|||||||
seed-nodes = [{seedNodesStr}]
|
seed-nodes = [{seedNodesStr}]
|
||||||
roles = [{rolesStr}]
|
roles = [{rolesStr}]
|
||||||
min-nr-of-members = {clusterOptions.MinNrOfMembers}
|
min-nr-of-members = {clusterOptions.MinNrOfMembers}
|
||||||
split-brain-resolver {{
|
{downingBlock}
|
||||||
active-strategy = {QuoteHocon(clusterOptions.SplitBrainResolverStrategy)}
|
|
||||||
stable-after = {DurationHocon(clusterOptions.StableAfter)}
|
|
||||||
keep-oldest {{
|
|
||||||
down-if-alone = {(clusterOptions.DownIfAlone ? "on" : "off")}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
failure-detector {{
|
failure-detector {{
|
||||||
heartbeat-interval = {DurationHocon(clusterOptions.HeartbeatInterval)}
|
heartbeat-interval = {DurationHocon(clusterOptions.HeartbeatInterval)}
|
||||||
acceptable-heartbeat-pause = {DurationHocon(clusterOptions.FailureDetectionThreshold)}
|
acceptable-heartbeat-pause = {DurationHocon(clusterOptions.FailureDetectionThreshold)}
|
||||||
@@ -83,23 +91,35 @@ akka {{
|
|||||||
run-by-clr-shutdown-hook = on
|
run-by-clr-shutdown-hook = on
|
||||||
}}
|
}}
|
||||||
}}";
|
}}";
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A `downing-provider-class` is always named explicitly. Akka defaults to `NoDowning`, under which the downing configuration is inert and singletons never migrate on a hard crash or partition; naming the provider is what activates automatic downing.
|
||||||
|
|
||||||
The HOCON also defines the `audit-telemetry-dispatcher` (a two-thread `ForkJoinDispatcher`) so `SiteAuditTelemetryActor`'s SQLite reads and gRPC pushes never contend with the default dispatcher used by hot-path actors.
|
The HOCON also defines the `audit-telemetry-dispatcher` (a two-thread `ForkJoinDispatcher`) so `SiteAuditTelemetryActor`'s SQLite reads and gRPC pushes never contend with the default dispatcher used by hot-path actors.
|
||||||
|
|
||||||
### Split-brain resolution
|
Nothing in the emitted document enables remoting TLS or an Akka secure cookie — there is no `enable-ssl`, no `require-cookie`, no `trusted-selection-paths`. Akka remoting between nodes and from a `ClusterClient` is plaintext and unauthenticated; the deployment is assumed to sit on a trusted network.
|
||||||
|
|
||||||
The keep-oldest strategy is the only strategy `ClusterOptionsValidator` permits for ScadaBridge's two-node clusters. Quorum strategies (`keep-majority`, `static-quorum`) cannot distinguish a crash from a partition with two nodes — both sides would be below quorum and both would shut down. Keep-oldest with `down-if-alone = on` ensures at most one node runs the cluster at any time:
|
### Downing strategy (auto-down — availability-first)
|
||||||
|
|
||||||
- On a network partition, the older node stays active; the younger node downs itself.
|
**Decision 2026-07-21** (`docs/plans/2026-07-21-auto-down-availability-decision.md`): the default strategy is **`auto-down`** — Akka's `AutoDowning` provider with `auto-down-unreachable-after` = `StableAfter` (15 s). The leader among the *reachable* members downs the unreachable peer once the stability window elapses.
|
||||||
- If the oldest node finds itself alone (no reachable members), it downs itself rather than running in isolation. Without `down-if-alone`, the oldest node could run as a single-node cluster while the younger node forms its own — producing two live clusters with divergent singleton state.
|
|
||||||
|
- **Either-node crash is survivable.** If the standby crashes, the active node downs it and continues. If the **active/oldest** node crashes, the younger survivor downs the dead oldest, becomes the oldest itself, re-hosts every cluster singleton, and `/health/active` flips to it — no operator action and no victim restart.
|
||||||
|
- **The accepted trade is dual-active during a real network partition.** With both nodes alive but the link cut, each side downs the other and continues as a one-node cluster; both claim active until an operator restarts one side after the partition heals. This was chosen deliberately — pairs run one node per VM with no shared lease store (no Kubernetes, no site-side SQL) to arbitrate, and a stalled system is a bigger operational risk than a rare LAN partition.
|
||||||
|
- **`StableAfter` is the debounce**, not a resolver phase: 15 s of sustained unreachability before downing, which absorbs startup, rolling restarts, and transient blips.
|
||||||
|
|
||||||
|
`keep-oldest` remains a supported value (`ClusterOptionsValidator` allows exactly `auto-down` and `keep-oldest`) for deployments that prefer partition-safety, but it **cannot survive a crash of the oldest node in a two-node cluster**: Akka's `down-if-alone` only rescues the survivor when its own side has ≥ 2 members, so a 1-vs-1 survivor takes `DownReachable` and downs *itself*. Quorum strategies are rejected outright — `static-quorum` with quorum 1 trips Akka's `IsTooManyMembers` guard and downs *all* members on any unreachability, and `keep-majority` merely moves the fatal crash from the oldest node to the lowest-address node.
|
||||||
|
|
||||||
|
### Downed-node recovery
|
||||||
|
|
||||||
|
`run-coordinated-shutdown-when-down = on` means a downed node runs `CoordinatedShutdown` and terminates its own `ActorSystem`. The Host watches `ActorSystem.WhenTerminated`; a termination that is not the host's own `StopAsync` calls `IHostApplicationLifetime.StopApplication()` so the process exits and the service supervisor (docker `restart: unless-stopped`, Windows service recovery) restarts it as a fresh incarnation (`AkkaHostedService.cs:203-218`).
|
||||||
|
|
||||||
|
**Seed-node ordering (decision 2026-07-22).** Only the *first* seed listed in `Cluster:SeedNodes` may self-join to form a new cluster — Akka runs `FirstSeedNodeProcess` for it and `JoinSeedNodeProcess` (which can never form one) for everyone else. Every node therefore lists **itself** first and its partner second, so any node can boot alone and become operational unattended; `StartupValidator` fails the boot if that ordering is broken. Until this change all nodes shared one first seed, and a node that had to boot alone looped on `InitJoin` until its peer returned — the registered outage gap. See `docs/requirements/Component-ClusterInfrastructure.md` → Seed Node Ordering for the scenario table and for why an external self-form timer was rejected.
|
||||||
|
|
||||||
### Failure detection and failover timeline
|
### Failure detection and failover timeline
|
||||||
|
|
||||||
Detection uses two independent Akka heartbeat channels:
|
Detection uses two independent Akka heartbeat channels:
|
||||||
|
|
||||||
- **Cluster failure detector** (`akka.cluster.failure-detector`): monitors membership, triggers `Unreachable` events that the split-brain resolver acts on.
|
- **Cluster failure detector** (`akka.cluster.failure-detector`): monitors membership, triggers the `Unreachable` events the downing provider acts on.
|
||||||
- **Transport failure detector** (`akka.remote.transport-failure-detector`): monitors the underlying TCP transport between nodes; configured separately from `CommunicationOptions.TransportHeartbeatInterval` / `TransportFailureThreshold`.
|
- **Transport failure detector** (`akka.remote.transport-failure-detector`): monitors the underlying TCP transport between nodes; configured separately from `CommunicationOptions.TransportHeartbeatInterval` / `TransportFailureThreshold`.
|
||||||
|
|
||||||
With the defaults in `ClusterOptions`, the total failover budget is approximately 25 seconds:
|
With the defaults in `ClusterOptions`, the total failover budget is approximately 25 seconds:
|
||||||
@@ -107,28 +127,33 @@ With the defaults in `ClusterOptions`, the total failover budget is approximatel
|
|||||||
| Phase | Duration | Source |
|
| Phase | Duration | Source |
|
||||||
|-------|----------|--------|
|
|-------|----------|--------|
|
||||||
| Failure detection (`acceptable-heartbeat-pause`) | 10 s | `ClusterOptions.FailureDetectionThreshold` |
|
| Failure detection (`acceptable-heartbeat-pause`) | 10 s | `ClusterOptions.FailureDetectionThreshold` |
|
||||||
| Split-brain stable-after | 15 s | `ClusterOptions.StableAfter` |
|
| Downing window (`auto-down-unreachable-after`) | 15 s | `ClusterOptions.StableAfter` |
|
||||||
| Singleton restart | < 1 s | Actor `PreStart` |
|
| Singleton restart | < 1 s | Actor `PreStart` |
|
||||||
|
|
||||||
|
The docker failover drill (`docker/failover-drill.sh`) measures both directions — `standby` mode kills the younger node, `active` mode kills the active/oldest node and asserts the survivor takes over while the victim is still down.
|
||||||
|
|
||||||
### Graceful shutdown and singleton handover
|
### Graceful shutdown and singleton handover
|
||||||
|
|
||||||
When a node is stopped cleanly, `CoordinatedShutdown` runs before the CLR exits (`run-by-clr-shutdown-hook = on`). The cluster-leave phase signals Akka to migrate singletons before the actor system terminates, so handover happens in seconds rather than waiting for the full failure-detection timeout. `SiteCallAuditActor` has an explicit graceful-stop task registered on `PhaseClusterLeave` with a 10-second timeout to drain any in-flight EF Core upsert before handover opens:
|
When a node is stopped cleanly, `CoordinatedShutdown` runs before the CLR exits (`run-by-clr-shutdown-hook = on`). The cluster-leave phase signals Akka to migrate singletons before the actor system terminates, so handover happens in seconds rather than waiting for the full failure-detection timeout.
|
||||||
|
|
||||||
|
Every singleton is created through the shared `SingletonRegistrar.Start` helper (`Host/Actors/SingletonRegistrar.cs`), so the drain is uniform rather than per-singleton boilerplate. The registrar applies the canonical `{name}-singleton` / `{name}-proxy` naming, a `PoisonPill` termination message, an optional `.WithRole(role)` on both the manager and proxy settings, and a `PhaseClusterLeave` task that `GracefulStop`s the manager (10-second default) so in-flight EF Core (central) or SQLite (site) work completes before handover opens:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
siteCallAuditShutdown.AddTask(
|
// SingletonRegistrar.Start — the drain task registered for every singleton
|
||||||
|
Akka.Actor.CoordinatedShutdown.Get(system).AddTask(
|
||||||
Akka.Actor.CoordinatedShutdown.PhaseClusterLeave,
|
Akka.Actor.CoordinatedShutdown.PhaseClusterLeave,
|
||||||
"drain-site-call-audit-singleton",
|
$"drain-{name}-singleton",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await siteCallAuditSingletonManager.GracefulStop(TimeSpan.FromSeconds(10));
|
await manager.GracefulStop(timeout);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex,
|
logger.LogWarning(ex,
|
||||||
"SiteCallAudit singleton did not drain within the graceful-stop "
|
"{Singleton} singleton did not drain within the graceful-stop timeout; "
|
||||||
+ "timeout; falling through to PoisonPill handover");
|
+ "falling through to PoisonPill handover", name);
|
||||||
}
|
}
|
||||||
return Akka.Done.Instance;
|
return Akka.Done.Instance;
|
||||||
});
|
});
|
||||||
@@ -136,25 +161,29 @@ siteCallAuditShutdown.AddTask(
|
|||||||
|
|
||||||
### Cluster roles and singleton scoping
|
### Cluster roles and singleton scoping
|
||||||
|
|
||||||
Each node carries one or more cluster roles set in the HOCON `roles` list. Site nodes carry both a base `"Site"` role and a site-specific role (`"site-{SiteId}"`, e.g. `"site-site-a"`). Singletons on site clusters are scoped to the site-specific role so each site's singleton runs on exactly one node of that site's cluster, not on any other site's nodes. Central singletons use no role scope — all central nodes share the `"Central"` role.
|
Each node carries one or more cluster roles set in the HOCON `roles` list, built by `AkkaHostedService.BuildRoles` (`AkkaHostedService.cs:406-417`). Site nodes carry **two** roles: the base `"Site"` role plus a site-specific `"site-{SiteId}"` (a node with `SiteId: "site-a"` gets `"site-site-a"`). Singletons on site clusters are scoped to the site-specific role so each site's singleton runs on exactly one node of that site's cluster. Central singletons pass no role to the registrar and so are unscoped — all central nodes share the `"Central"` role.
|
||||||
|
|
||||||
### Dual-node recovery
|
### Dual-node recovery
|
||||||
|
|
||||||
Because both nodes are configured as seed nodes, whichever node starts first after a simultaneous failure forms a new cluster; the second joins when it comes up. No startup ordering dependency exists, and no manual intervention is required. The keep-oldest resolver handles the "both starting fresh" case naturally — there is no pre-existing cluster to conflict with.
|
Because both nodes are configured as seed nodes **and each lists itself first**, whichever node starts first after a simultaneous failure forms a new cluster; the second joins when it comes up. There is no pre-existing cluster to conflict with, so the "both starting fresh" case needs no downing decision at all. Since 2026-07-22 there is no remaining ordering dependency: a node that must boot *alone* forms a cluster regardless of which node it is. Two nodes cold-starting at the same moment converge on one cluster via the `InitJoin` handshake — they split only under a genuine boot-time partition, the same class `auto-down` already accepts.
|
||||||
|
|
||||||
### Cluster singletons hosted
|
### Cluster singletons hosted
|
||||||
|
|
||||||
The Host wires the following singletons. Cluster Infrastructure provides the `ClusterSingletonManager` / `ClusterSingletonProxy` pattern; each singleton's behaviour is documented in the owning component.
|
The Host wires the following singletons through `SingletonRegistrar.Start`. Cluster Infrastructure provides the `ClusterSingletonManager` / `ClusterSingletonProxy` pattern and the drain hook; each singleton's behaviour is documented in the owning component.
|
||||||
|
|
||||||
**Central singletons (active central node, no role scope):**
|
**Central singletons (oldest `Up` central node, no role scope):**
|
||||||
|
|
||||||
| Singleton name | Actor class | Owner |
|
| Singleton name | Actor class | Owner |
|
||||||
|----------------|-------------|-------|
|
|----------------|-------------|-------|
|
||||||
| `notification-outbox` | `NotificationOutboxActor` | Notification Outbox (#21) |
|
| `notification-outbox` | `NotificationOutboxActor` | Notification Outbox (#21) |
|
||||||
| `audit-log-ingest` | `AuditLogIngestActor` | Audit Log (#23) |
|
| `audit-log-ingest` | `AuditLogIngestActor` | Audit Log (#23) |
|
||||||
| `site-call-audit` | `SiteCallAuditActor` | Site Call Audit (#22) |
|
| `site-call-audit` | `SiteCallAuditActor` | Site Call Audit (#22) |
|
||||||
|
| `audit-log-purge` | `AuditLogPurgeActor` | Audit Log (#23) |
|
||||||
|
| `site-audit-reconciliation` | `SiteAuditReconciliationActor` | Audit Log (#23) |
|
||||||
|
| `kpi-history-recorder` | `KpiHistoryRecorderActor` | KPI History |
|
||||||
|
| `pending-deployment-purge` | `PendingDeploymentPurgeActor` | Deployment Manager (#2) |
|
||||||
|
|
||||||
**Site singletons (active site node, scoped to `"site-{SiteId}"` role):**
|
**Site singletons (oldest `Up` node of that site, scoped to the `"site-{SiteId}"` role):**
|
||||||
|
|
||||||
| Singleton name | Actor class | Owner |
|
| Singleton name | Actor class | Owner |
|
||||||
|----------------|-------------|-------|
|
|----------------|-------------|-------|
|
||||||
@@ -173,29 +202,29 @@ Every host calls `AddClusterInfrastructure` to register `ClusterOptionsValidator
|
|||||||
services.AddClusterInfrastructure();
|
services.AddClusterInfrastructure();
|
||||||
```
|
```
|
||||||
|
|
||||||
This registers `ClusterOptionsValidator` as an `IValidateOptions<ClusterOptions>` singleton. Because the Host binds `ClusterOptions` with `ValidateOnStart`, a misconfigured `ScadaBridge:Cluster` section (wrong strategy, `MinNrOfMembers != 1`, `DownIfAlone = false`, fewer than two seed nodes) throws an `OptionsValidationException` at startup rather than booting into a broken cluster.
|
This registers `ClusterOptionsValidator` as an `IValidateOptions<ClusterOptions>` singleton. Because the Host binds `ClusterOptions` with `ValidateOnStart`, a misconfigured `ScadaBridge:Cluster` section throws an `OptionsValidationException` at startup rather than booting into a broken cluster. The validator rejects: a strategy other than `auto-down` or `keep-oldest`; `MinNrOfMembers != 1`; a non-positive `StableAfter`, `HeartbeatInterval` or `FailureDetectionThreshold`; a `HeartbeatInterval` not below `FailureDetectionThreshold`; fewer than two seed nodes unless `AllowSingleNodeCluster = true`; and `DownIfAlone = false` **only when the strategy is `keep-oldest`** (the flag is inert under `auto-down`, so any value passes there).
|
||||||
|
|
||||||
### Checking active-node status
|
### Checking active-node status
|
||||||
|
|
||||||
Components that must run only on the active node resolve `IActiveNodeGate` (registered by the Host's Central composition root):
|
Components that must run only on the active node resolve `IActiveNodeGate` (registered by the Host's Central composition root). The gate is a thin wrapper over the oldest-`Up` evaluator — it never inspects cluster leadership:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
// Host/Health/ActiveNodeGate.cs
|
||||||
public bool IsActiveNode
|
public bool IsActiveNode
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
var system = _akkaService.ActorSystem;
|
var system = _akkaService.ActorSystem;
|
||||||
if (system == null) return false;
|
if (system == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
var cluster = Cluster.Get(system);
|
var cluster = Cluster.Get(system);
|
||||||
var self = cluster.SelfMember;
|
return ClusterActivityEvaluator.SelfIsOldest(cluster);
|
||||||
if (self.Status != MemberStatus.Up) return false;
|
|
||||||
var leader = cluster.State.Leader;
|
|
||||||
return leader != null && leader == self.Address;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This returns `false` while the actor system is warming up — the safe-by-default answer matching the standby case. The Inbound API uses this gate to return HTTP 503 on standby nodes.
|
This returns `false` while the actor system is warming up, and `SelfIsOldest` returns `false` unless the node has reached `MemberStatus.Up` — the safe-by-default answer matching the standby case. The Inbound API uses this gate to return HTTP 503 on standby nodes, and `OldestNodeActiveHealthCheck` backs `/health/active` off the same evaluator, so the proxy's routing decision and the API's gating decision can never disagree.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -205,13 +234,14 @@ This returns `false` while the actor system is warming up — the safe-by-defaul
|
|||||||
|
|
||||||
| Key | Type | Default | Description |
|
| Key | Type | Default | Description |
|
||||||
|-----|------|---------|-------------|
|
|-----|------|---------|-------------|
|
||||||
| `SeedNodes` | `List<string>` | (required) | Akka seed-node URIs. Must contain at least 2 entries; both nodes list both themselves and their partner. |
|
| `SeedNodes` | `List<string>` | (required) | Akka seed-node URIs. Must contain at least 2 entries (1 with `AllowSingleNodeCluster`); both nodes list both themselves and their partner. Only the **first** entry may self-form a new cluster. |
|
||||||
| `SplitBrainResolverStrategy` | `string` | `"keep-oldest"` | Must be `"keep-oldest"`. Quorum strategies are rejected by `ClusterOptionsValidator`. |
|
| `SplitBrainResolverStrategy` | `string` | `"auto-down"` | `"auto-down"` or `"keep-oldest"`. Quorum strategies are rejected by `ClusterOptionsValidator`. See downing strategy above. |
|
||||||
| `StableAfter` | `TimeSpan` | `00:00:15` | Cluster must be stable for this duration before the resolver acts to down unreachable nodes. |
|
| `StableAfter` | `TimeSpan` | `00:00:15` | Sustained unreachability before downing. Emitted as `auto-down-unreachable-after` under `auto-down`, as the SBR `stable-after` under `keep-oldest`. |
|
||||||
| `HeartbeatInterval` | `TimeSpan` | `00:00:02` | Cluster failure-detector heartbeat frequency. Must be less than `FailureDetectionThreshold`. |
|
| `HeartbeatInterval` | `TimeSpan` | `00:00:02` | Cluster failure-detector heartbeat frequency. Must be less than `FailureDetectionThreshold`. |
|
||||||
| `FailureDetectionThreshold` | `TimeSpan` | `00:00:10` | `acceptable-heartbeat-pause` for the cluster failure detector. |
|
| `FailureDetectionThreshold` | `TimeSpan` | `00:00:10` | `acceptable-heartbeat-pause` for the cluster failure detector. |
|
||||||
| `MinNrOfMembers` | `int` | `1` | Must be `1`. A value of `2` blocks the cluster singleton after failover. |
|
| `MinNrOfMembers` | `int` | `1` | Must be `1`. A value of `2` blocks the cluster singleton after failover. |
|
||||||
| `DownIfAlone` | `bool` | `true` | Must be `true`. See split-brain resolution above. |
|
| `DownIfAlone` | `bool` | `true` | `keep-oldest` only — inert under `auto-down`. Validated as `true` only when the strategy is `keep-oldest`. |
|
||||||
|
| `AllowSingleNodeCluster` | `bool` | `false` | Acknowledges a deliberate single-node install: permits exactly one seed node instead of the usual two. |
|
||||||
|
|
||||||
### `ScadaBridge:Node`
|
### `ScadaBridge:Node`
|
||||||
|
|
||||||
@@ -241,7 +271,7 @@ This returns `false` while the actor system is warming up — the safe-by-defaul
|
|||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
@@ -251,7 +281,7 @@ This returns `false` while the actor system is warming up — the safe-by-defaul
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`DownIfAlone` is not present in the docker files because its default value of `true` is correct and `ClusterOptionsValidator` rejects `false`.
|
`DownIfAlone` is not present in the docker files because it is a `keep-oldest`-only knob and every shipped deployment runs `auto-down`, under which the flag is inert.
|
||||||
|
|
||||||
## Dependencies & Interactions
|
## Dependencies & Interactions
|
||||||
|
|
||||||
@@ -261,22 +291,26 @@ This returns `false` while the actor system is warming up — the safe-by-defaul
|
|||||||
- [Site Runtime (#3)](./SiteRuntime.md) — the Deployment Manager singleton is the most operationally critical singleton this infrastructure hosts. It re-creates the full Instance Actor hierarchy from local SQLite on failover. Staggered Instance Actor startup after failover is Site Runtime's responsibility; this component provides the singleton placement guarantee.
|
- [Site Runtime (#3)](./SiteRuntime.md) — the Deployment Manager singleton is the most operationally critical singleton this infrastructure hosts. It re-creates the full Instance Actor hierarchy from local SQLite on failover. Staggered Instance Actor startup after failover is Site Runtime's responsibility; this component provides the singleton placement guarantee.
|
||||||
- [Notification Outbox (#21)](./NotificationOutbox.md), [Site Call Audit (#22)](./SiteCallAudit.md), [Audit Log (#23)](./AuditLog.md) — each hosts one or more central singletons wired by `RegisterCentralActors`. Cluster Infrastructure provides the `ClusterSingletonManager`/`ClusterSingletonProxy` boilerplate and the graceful-shutdown hooks; the business logic lives in the owning component.
|
- [Notification Outbox (#21)](./NotificationOutbox.md), [Site Call Audit (#22)](./SiteCallAudit.md), [Audit Log (#23)](./AuditLog.md) — each hosts one or more central singletons wired by `RegisterCentralActors`. Cluster Infrastructure provides the `ClusterSingletonManager`/`ClusterSingletonProxy` boilerplate and the graceful-shutdown hooks; the business logic lives in the owning component.
|
||||||
- [Central–Site Communication (#5)](./Communication.md) — `CentralCommunicationActor` and `SiteCommunicationActor` are created and registered with `ClusterClientReceptionist` inside the same `AkkaHostedService` startup, making them addressable by remote `ClusterClient` instances. The transport-level heartbeat (`TransportHeartbeatInterval`, `TransportFailureThreshold`) is configured separately from the cluster failure-detector and comes from `CommunicationOptions`.
|
- [Central–Site Communication (#5)](./Communication.md) — `CentralCommunicationActor` and `SiteCommunicationActor` are created and registered with `ClusterClientReceptionist` inside the same `AkkaHostedService` startup, making them addressable by remote `ClusterClient` instances. The transport-level heartbeat (`TransportHeartbeatInterval`, `TransportFailureThreshold`) is configured separately from the cluster failure-detector and comes from `CommunicationOptions`.
|
||||||
- [Inbound API (#14)](./InboundAPI.md) — resolves `IActiveNodeGate` to return HTTP 503 on standby central nodes. Gate returns `false` until the actor system is `Up` and this node is the cluster leader.
|
- [Inbound API (#14)](./InboundAPI.md) — resolves `IActiveNodeGate` to return HTTP 503 on standby central nodes. Gate returns `false` until the actor system is `Up` and this node is the oldest `Up` member.
|
||||||
- Design spec: [Component-ClusterInfrastructure.md](../requirements/Component-ClusterInfrastructure.md).
|
- Design spec: [Component-ClusterInfrastructure.md](../requirements/Component-ClusterInfrastructure.md).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Node fails to join cluster on startup
|
### Node fails to join cluster on startup
|
||||||
|
|
||||||
`ClusterOptionsValidator` rejects fewer than two seed nodes, a non-`keep-oldest` strategy, `MinNrOfMembers != 1`, or `DownIfAlone = false` at startup with an `OptionsValidationException`. Check that both seed-node URIs reference the Akka remoting port, not the gRPC port (8083) or metrics port (8084) — on site nodes, `StartupValidator` explicitly rejects seed entries whose port matches `GrpcPort`.
|
`ClusterOptionsValidator` rejects fewer than two seed nodes (without `AllowSingleNodeCluster`), a strategy outside `auto-down` / `keep-oldest`, `MinNrOfMembers != 1`, or `DownIfAlone = false` under `keep-oldest`, at startup with an `OptionsValidationException`. Check that both seed-node URIs reference the Akka remoting port, not the gRPC port (8083) or metrics port (8084) — on site nodes, `StartupValidator` explicitly rejects seed entries whose port matches `GrpcPort`.
|
||||||
|
|
||||||
|
A node that boots, logs no validation error, but never reaches `Up` was — before 2026-07-22 — usually hitting the seed-node bootstrap constraint: it was not the first entry in `SeedNodes` and the first seed was down, so it looped on `InitJoin` waiting for a peer that could form the cluster. Self-first ordering plus the `StartupValidator` rule that enforces it should make this unreachable; if you still see it, check that `seed-nodes[0]` really resolves to this node's own `NodeHostname:RemotingPort` (the validator compares host *and* port, and Akka does no DNS canonicalisation — `node-a` and `node-a.example.com` are different seed identities).
|
||||||
|
|
||||||
### Singleton not starting after failover
|
### Singleton not starting after failover
|
||||||
|
|
||||||
If the surviving node is `Up` but singletons do not start, `MinNrOfMembers` is the first thing to check. A value of `2` keeps the surviving node waiting for a second member indefinitely. The validator enforces `1`, but a manually patched `appsettings.json` that bypasses the validator could produce this.
|
If the surviving node is `Up` but singletons do not start, `MinNrOfMembers` is the first thing to check. A value of `2` keeps the surviving node waiting for a second member indefinitely. The validator enforces `1`, but a manually patched `appsettings.json` that bypasses the validator could produce this.
|
||||||
|
|
||||||
### Two live clusters (split-brain)
|
### Two live clusters (dual-active)
|
||||||
|
|
||||||
If `DownIfAlone = false` were accepted (the validator rejects it), the oldest node could run alone while the younger forms its own cluster, producing two live clusters with divergent singleton state and dual MS SQL writers on central. `ClusterOptionsValidator` makes this configuration impossible to boot.
|
Under `auto-down` this is the **accepted trade, not a misconfiguration**: during a real network partition each side downs the other and continues as a one-node cluster, so both nodes are oldest-`Up`, both host a full set of singletons, and both answer `/health/active` with 200 — including dual MS SQL writers on central. Monitoring surfaces it directly (both nodes stamp `IsActive` on their heartbeats; the Health dashboard shows two Primaries). The two sides do **not** merge on their own — the mutual downing quarantines the association. Recovery is operator-driven: once the link is restored, restart **one** side; it rejoins its peer as a fresh incarnation and comes back as standby.
|
||||||
|
|
||||||
|
Deployments that would rather lose availability than run dual-active should set `SplitBrainResolverStrategy: "keep-oldest"` (with `DownIfAlone = true`), accepting that a crash of the oldest node is then a total outage.
|
||||||
|
|
||||||
### Graceful shutdown takes longer than expected
|
### Graceful shutdown takes longer than expected
|
||||||
|
|
||||||
@@ -285,6 +319,7 @@ If a clean node stop takes up to 25 seconds instead of seconds, `CoordinatedShut
|
|||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Cluster Infrastructure design specification](../requirements/Component-ClusterInfrastructure.md)
|
- [Cluster Infrastructure design specification](../requirements/Component-ClusterInfrastructure.md)
|
||||||
|
- [Auto-down downing strategy — availability over partition-safety (decision, 2026-07-21)](../plans/2026-07-21-auto-down-availability-decision.md)
|
||||||
- [Host](./Host.md)
|
- [Host](./Host.md)
|
||||||
- [Site Runtime](./SiteRuntime.md)
|
- [Site Runtime](./SiteRuntime.md)
|
||||||
- [Health Monitoring](./HealthMonitoring.md)
|
- [Health Monitoring](./HealthMonitoring.md)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Central–Site Communication
|
# Central–Site Communication
|
||||||
|
|
||||||
The Central–Site Communication component is the transport layer that connects the central cluster to every site cluster. It provides two independent transports — Akka.NET `ClusterClient` for command/control and gRPC server-streaming for real-time data — wired together through a pair of actors that each cluster registers with the `ClusterClientReceptionist`.
|
The Central–Site Communication component is the transport layer that connects the central cluster to every site cluster. It provides three independent transports — Akka.NET `ClusterClient` for command/control, gRPC server-streaming for real-time data, and plain token-gated HTTP for the deployment-config fetch — anchored by a pair of actors that each cluster registers with the `ClusterClientReceptionist`.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -18,15 +18,33 @@ DI registration is called from the Host composition root via `AddCommunication`.
|
|||||||
|
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
### Two transports, two concerns
|
### Three transports, three concerns
|
||||||
|
|
||||||
| Transport | Direction | Purpose |
|
| Transport | Who dials | Data direction | Purpose |
|
||||||
|-----------|-----------|---------|
|
|-----------|-----------|----------------|---------|
|
||||||
| Akka.NET `ClusterClient` | bidirectional (command/control) | Deployments, lifecycle, subscribe/unsubscribe handshake, snapshots, heartbeats, health reports, telemetry, notifications |
|
| Akka.NET `ClusterClient` | both (central → site per site; site → central) | bidirectional | Deploy notifies, lifecycle, subscribe/unsubscribe handshake, snapshots, heartbeats, health reports, telemetry, notifications |
|
||||||
| gRPC server-streaming (`SiteStreamService`) | site → central | Real-time attribute value and alarm state changes |
|
| gRPC (`SiteStreamService`) | **central dials the site** | mostly site → central | Real-time attribute value and alarm state changes (server-streaming), plus the audit ingest/pull unary RPCs |
|
||||||
|
| HTTP `GET` (`/api/internal/deployments/{id}/config`) | **site dials central** | central → site | The flattened deployment config itself (notify-and-fetch), gated by a per-deployment `X-Deployment-Token` |
|
||||||
|
|
||||||
The transports are independent. A gRPC stream interruption does not affect in-flight `ClusterClient` commands, and vice versa.
|
The transports are independent. A gRPC stream interruption does not affect in-flight `ClusterClient` commands, and vice versa.
|
||||||
|
|
||||||
|
**The gRPC dial direction is inverted from its data direction.** Values flow site → central, but each **site node hosts the gRPC server** and **central is the client**. `MapGrpcService<SiteStreamGrpcServer>()` appears exactly once in the tree, inside the Site branch of `Program.cs` (`Host/Program.cs:542`); there is **no gRPC server on a central node at all**. That is why the two `Ingest*` unary RPCs — nominally a central-side ingest surface — are dead in the shipped topology (acknowledged in `AkkaHostedService.cs:505-508`: "when the gRPC server is not registered (current central topology)"); sites push audit telemetry to central over `ClusterClient` instead, and central pulls with `PullAuditEvents` / `PullSiteCalls` by dialling the site.
|
||||||
|
|
||||||
|
**None of the three transports carries a mutual-auth or transport-encryption story.** `BuildHocon` emits no Akka `enable-ssl`, no secure cookie and no `trusted-selection-paths`, so remoting and `ClusterClient` are plaintext and unauthenticated. The site gRPC listener is **h2c** — `ListenAnyIP(grpcPort, o => o.Protocols = HttpProtocols.Http2)` with no `UseHttps` (`Host/Program.cs:501-506`) — and `SiteStreamGrpcClient` opens a bare `GrpcChannel.ForAddress(endpoint, …)` with no credentials. `LocalDbSyncAuthInterceptor` shares that listener but gates **only** methods under `/localdb_sync.v1.LocalDbSync/`, explicitly letting every `SiteStreamService` call through untouched, so the whole surface — including the `PullAuditEvents` / `PullSiteCalls` RPCs that return audit rows — is callable by anything that can reach the site's gRPC port. The one authenticated piece is the HTTP config fetch, and its per-deployment token is the *entire* security boundary (the endpoint is `AllowAnonymous`). The design assumes a trusted network between central and sites.
|
||||||
|
|
||||||
|
### Notify-and-fetch: the deployment-config HTTP path
|
||||||
|
|
||||||
|
An instance deployment does not carry its flattened configuration inside the Akka message. Central stages a `PendingDeployment` row (config JSON + a freshly generated `DeploymentFetchToken` + a TTL) and sends only a small `RefreshDeploymentCommand` over `ClusterClient`, carrying the deployment id, revision hash, `CentralFetchBaseUrl` and the fetch token. The site's Deployment Manager singleton then calls back to central over plain HTTP:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs
|
||||||
|
var url = $"{centralFetchBaseUrl.TrimEnd('/')}/api/internal/deployments/{Uri.EscapeDataString(deploymentId)}/config";
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
req.Headers.Add("X-Deployment-Token", token);
|
||||||
|
```
|
||||||
|
|
||||||
|
`DeploymentConfigEndpoints.Resolve` (`ManagementService/DeploymentConfigEndpoints.cs:101`) checks existence and TTL *before* the token, so unknown, superseded and expired deployments are all indistinguishable `404`s; a live row with a wrong or missing token is `401`. The token comparison is constant-time. This exists because a flattened config can exceed the default 128 KB Akka frame size, which drops the single oversized message without tearing down the association — heartbeats keep flowing, the site still reports healthy, and the deploy just hangs to its Ask timeout. See `docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`. `DeployArtifactsCommand` was **not** moved to this path and still carries its payload inline.
|
||||||
|
|
||||||
### Hub-and-spoke topology
|
### Hub-and-spoke topology
|
||||||
|
|
||||||
Sites do not communicate with each other. All inter-cluster traffic flows through central. Central maintains one `ClusterClient` per site; each site maintains a single `ClusterClient` pointed at both central nodes.
|
Sites do not communicate with each other. All inter-cluster traffic flows through central. Central maintains one `ClusterClient` per site; each site maintains a single `ClusterClient` pointed at both central nodes.
|
||||||
@@ -36,9 +54,9 @@ Sites do not communicate with each other. All inter-cluster traffic flows throug
|
|||||||
Central-side callers wrap outbound messages in a `SiteEnvelope(SiteId, Message)`. `CentralCommunicationActor` resolves the site's `ClusterClient` by `SiteId` and forwards the inner message to `/user/site-communication` on the site:
|
Central-side callers wrap outbound messages in a `SiteEnvelope(SiteId, Message)`. `CentralCommunicationActor` resolves the site's `ClusterClient` by `SiteId` and forwards the inner message to `/user/site-communication` on the site:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// CommunicationService.cs — deployment pattern
|
// CommunicationService.cs — deployment pattern (notify-and-fetch)
|
||||||
public async Task<DeploymentStatusResponse> DeployInstanceAsync(
|
public async Task<DeploymentStatusResponse> RefreshDeploymentAsync(
|
||||||
string siteId, DeployInstanceCommand command, CancellationToken cancellationToken = default)
|
string siteId, RefreshDeploymentCommand command, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var envelope = new SiteEnvelope(siteId, command);
|
var envelope = new SiteEnvelope(siteId, command);
|
||||||
return await GetActor().Ask<DeploymentStatusResponse>(
|
return await GetActor().Ask<DeploymentStatusResponse>(
|
||||||
@@ -86,7 +104,7 @@ If a site is unreachable when a command arrives, the caller's Ask times out. Cen
|
|||||||
`SiteCommunicationActor` is a `ReceiveActor` created at `/user/site-communication` and registered with `ClusterClientReceptionist`. It owns:
|
`SiteCommunicationActor` is a `ReceiveActor` created at `/user/site-communication` and registered with `ClusterClientReceptionist`. It owns:
|
||||||
|
|
||||||
- An `IActorRef? _centralClient` — the site's outbound `ClusterClient` to central. Injected post-construction via `RegisterCentralClient`.
|
- An `IActorRef? _centralClient` — the site's outbound `ClusterClient` to central. Injected post-construction via `RegisterCentralClient`.
|
||||||
- A `Timers`-based heartbeat (default 5-second interval, first tick after 1 second). Each tick sends a `HeartbeatMessage` with `IsActive` stamped from the Akka `Cluster` leader check — the node is active when its `MemberStatus` is `Up` and it holds cluster leadership.
|
- A `Timers`-based heartbeat on `CommunicationOptions.ApplicationHeartbeatInterval` (default 5 s; deliberately distinct from the Akka.Remote `TransportHeartbeatInterval`, so retuning the transport failure detector cannot silently retune the health heartbeat). Each tick sends a `HeartbeatMessage` whose `IsActive` is stamped from `ActiveNodeEvaluator.SelfIsOldestUp` — the node is active when it is the **oldest `Up` member**, *not* when it holds cluster leadership (`SiteCommunicationActor.cs:517-518`). A throwing active-check is caught and reported as `IsActive = false`.
|
||||||
- Dispatch to local handlers for every inbound command pattern. Handlers for event-log, parked-message, integration, and artifact patterns are registered post-construction via `RegisterLocalHandler`; unregistered patterns receive an inline error reply so the central Ask does not stall.
|
- Dispatch to local handlers for every inbound command pattern. Handlers for event-log, parked-message, integration, and artifact patterns are registered post-construction via `RegisterLocalHandler`; unregistered patterns receive an inline error reply so the central Ask does not stall.
|
||||||
|
|
||||||
Site-to-central messages (health reports, audit batches, notification submissions) are sent via:
|
Site-to-central messages (health reports, audit batches, notification submissions) are sent via:
|
||||||
@@ -108,9 +126,9 @@ A malformed address for one site does not abort the refresh loop — the actor c
|
|||||||
|
|
||||||
### gRPC real-time data transport
|
### gRPC real-time data transport
|
||||||
|
|
||||||
Real-time attribute value and alarm state changes are delivered over `SiteStreamService`, a gRPC server-streaming service defined in `sitestream.proto`.
|
Real-time attribute value and alarm state changes are delivered over `SiteStreamService`, defined in `sitestream.proto`. The **server runs on every site node and the client runs on central** — central dials in to receive the stream (see the transport table above).
|
||||||
|
|
||||||
**Site-side** — `SiteStreamGrpcServer` (Kestrel HTTP/2, port 8083):
|
**Site-side** — `SiteStreamGrpcServer` (Kestrel h2c, HTTP/2 only, port 8083):
|
||||||
|
|
||||||
- Implements `SiteStreamService.SiteStreamServiceBase`.
|
- Implements `SiteStreamService.SiteStreamServiceBase`.
|
||||||
- For each `SubscribeInstance` call, creates a `StreamRelayActor` (named `stream-relay-{correlationId}-{seq}`) and subscribes it to `ISiteStreamSubscriber` (implemented by `SiteStreamManager` in the Site Runtime project — `SiteStreamGrpcServer` holds only the interface so it does not reference `SiteRuntime` directly).
|
- For each `SubscribeInstance` call, creates a `StreamRelayActor` (named `stream-relay-{correlationId}-{seq}`) and subscribes it to `ISiteStreamSubscriber` (implemented by `SiteStreamManager` in the Site Runtime project — `SiteStreamGrpcServer` holds only the interface so it does not reference `SiteRuntime` directly).
|
||||||
@@ -144,7 +162,7 @@ private void HandleAttributeValueChanged(AttributeValueChanged msg)
|
|||||||
|
|
||||||
**Central-side** — `SiteStreamGrpcClient` / `SiteStreamGrpcClientFactory`:
|
**Central-side** — `SiteStreamGrpcClient` / `SiteStreamGrpcClientFactory`:
|
||||||
|
|
||||||
- `SiteStreamGrpcClientFactory` (singleton) caches one `SiteStreamGrpcClient` per site identifier. On `GetOrCreate`, it compares the cached client's `Endpoint` to the requested endpoint and atomically replaces a stale client (different endpoint — NodeA→NodeB failover flip, or an edited address) with a fresh one.
|
- `SiteStreamGrpcClientFactory` (singleton) caches one `SiteStreamGrpcClient` per **`(site, endpoint)` pair** — a `ConcurrentDictionary<(string Site, string Endpoint), SiteStreamGrpcClient>`. The key was widened from site-only to fix an arch-review High: with a site-only key, one debug session's NodeA→NodeB failover flip disposed a channel another session was still using. `GetOrCreate` therefore no longer disposes on endpoint mismatch; both of a site's node channels coexist, and site *removal* (`RemoveSiteAsync`) is the only shared-disposal path. The trade-off is that an edited gRPC address leaves the old endpoint's idle channel cached until site removal or process shutdown — bounded at a handful of entries per site.
|
||||||
- `SiteStreamGrpcClient` opens a `GrpcChannel` with HTTP/2 keepalive (`KeepAlivePingDelay` default 15 s, `KeepAlivePingTimeout` default 10 s, `KeepAlivePingPolicy.Always`). `SubscribeAsync` is a plain `async Task` that calls `SubscribeInstance` and reads the response stream with `await foreach`, invoking `onEvent` for each received event and `onError` on any non-cancellation exception. The caller (`DebugStreamBridgeActor.OpenGrpcStream`) launches it inside a `Task.Run` so the long-running stream loop runs off the actor thread.
|
- `SiteStreamGrpcClient` opens a `GrpcChannel` with HTTP/2 keepalive (`KeepAlivePingDelay` default 15 s, `KeepAlivePingTimeout` default 10 s, `KeepAlivePingPolicy.Always`). `SubscribeAsync` is a plain `async Task` that calls `SubscribeInstance` and reads the response stream with `await foreach`, invoking `onEvent` for each received event and `onError` on any non-cancellation exception. The caller (`DebugStreamBridgeActor.OpenGrpcStream`) launches it inside a `Task.Run` so the long-running stream loop runs off the actor thread.
|
||||||
|
|
||||||
### Debug stream session lifecycle
|
### Debug stream session lifecycle
|
||||||
@@ -162,18 +180,22 @@ private void HandleAttributeValueChanged(AttributeValueChanged msg)
|
|||||||
### Proto definition summary
|
### Proto definition summary
|
||||||
|
|
||||||
```proto
|
```proto
|
||||||
// Protos/sitestream.proto
|
// Protos/sitestream.proto — six RPCs, all served by the SITE
|
||||||
service SiteStreamService {
|
service SiteStreamService {
|
||||||
rpc SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent);
|
rpc SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent);
|
||||||
|
rpc SubscribeSite(SiteStreamRequest) returns (stream SiteStreamEvent);
|
||||||
rpc IngestAuditEvents(AuditEventBatch) returns (IngestAck);
|
rpc IngestAuditEvents(AuditEventBatch) returns (IngestAck);
|
||||||
rpc IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck);
|
rpc IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck);
|
||||||
rpc PullAuditEvents(PullAuditEventsRequest) returns (PullAuditEventsResponse);
|
rpc PullAuditEvents(PullAuditEventsRequest) returns (PullAuditEventsResponse);
|
||||||
|
rpc PullSiteCalls(PullSiteCallsRequest) returns (PullSiteCallsResponse);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`SubscribeInstance` carries the real-time data stream. The other three RPCs (`IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents`) serve the Audit Log component's gRPC telemetry push and reconciliation pull paths — `SiteStreamGrpcServer` hosts them on the same port because sites already listen there.
|
Two are server-streaming: `SubscribeInstance` carries the per-instance real-time stream; `SubscribeSite` is the **site-wide, alarm-only** stream (no instance filter, attribute updates never carried) that feeds the aggregated central live alarm cache. The four unary RPCs serve the Audit Log and Site Call Audit push/pull paths — `SiteStreamGrpcServer` hosts them on the same port because sites already listen there. As noted above, the two `Ingest*` RPCs are dead in the shipped topology (no central gRPC server exists for a site to dial); the two `Pull*` RPCs are live, with central as the caller.
|
||||||
|
|
||||||
`SiteStreamEvent` uses a `oneof event { AttributeValueUpdate, AlarmStateUpdate }` discriminator. `AlarmStateUpdate` carries the full native alarm condition (fields 8–21) alongside the base computed-alarm fields (1–7), added additively so old clients ignoring unknown fields continue to work.
|
`SiteStreamEvent` uses a `oneof event { AttributeValueUpdate, AlarmStateUpdate }` discriminator. `AlarmStateUpdate` carries the full native alarm condition (fields 8–23) alongside the base computed-alarm fields (1–7), added additively so old clients ignoring unknown fields continue to work. Field numbers are never reused and evolution is additive only.
|
||||||
|
|
||||||
|
The generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out, so editing `sitestream.proto` does not regenerate on build — regeneration is a manual toggle-build-copy-untoggle.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -181,7 +203,7 @@ Central callers interact through `CommunicationService`, which wraps each comman
|
|||||||
|
|
||||||
| Pattern | Method | Timeout |
|
| Pattern | Method | Timeout |
|
||||||
|---------|--------|---------|
|
|---------|--------|---------|
|
||||||
| Instance deployment | `DeployInstanceAsync` | 120 s |
|
| Instance deployment (notify-and-fetch) | `RefreshDeploymentAsync` | 120 s |
|
||||||
| Instance lifecycle | `DisableInstanceAsync`, `EnableInstanceAsync`, `DeleteInstanceAsync` | 30 s |
|
| Instance lifecycle | `DisableInstanceAsync`, `EnableInstanceAsync`, `DeleteInstanceAsync` | 30 s |
|
||||||
| Artifact deployment | `DeployArtifactsAsync` | 60 s |
|
| Artifact deployment | `DeployArtifactsAsync` | 60 s |
|
||||||
| Integration routing | `RouteIntegrationCallAsync` | 30 s |
|
| Integration routing | `RouteIntegrationCallAsync` | 30 s |
|
||||||
@@ -197,11 +219,11 @@ For real-time streaming, callers use `DebugStreamService.StartStreamAsync`, whic
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All options are bound from the `Communication` section via `CommunicationOptions`:
|
All options are bound from the `ScadaBridge:Communication` section via `CommunicationOptions`:
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|-----|---------|-------------|
|
|-----|---------|-------------|
|
||||||
| `DeploymentTimeout` | `00:02:00` | Ask timeout for instance deployment commands. |
|
| `DeploymentTimeout` | `00:02:00` | Ask timeout for the `RefreshDeploymentCommand` round-trip (covers the site's HTTP config fetch and apply). |
|
||||||
| `LifecycleTimeout` | `00:00:30` | Ask timeout for lifecycle commands (disable, enable, delete). |
|
| `LifecycleTimeout` | `00:00:30` | Ask timeout for lifecycle commands (disable, enable, delete). |
|
||||||
| `ArtifactDeploymentTimeout` | `00:01:00` | Ask timeout for system-wide artifact deployment. |
|
| `ArtifactDeploymentTimeout` | `00:01:00` | Ask timeout for system-wide artifact deployment. |
|
||||||
| `QueryTimeout` | `00:00:30` | Ask timeout for remote queries and management commands. |
|
| `QueryTimeout` | `00:00:30` | Ask timeout for remote queries and management commands. |
|
||||||
@@ -213,8 +235,12 @@ All options are bound from the `Communication` section via `CommunicationOptions
|
|||||||
| `GrpcKeepAlivePingTimeout` | `00:00:10` | HTTP/2 keepalive PING timeout. |
|
| `GrpcKeepAlivePingTimeout` | `00:00:10` | HTTP/2 keepalive PING timeout. |
|
||||||
| `GrpcMaxStreamLifetime` | `04:00:00` | Per-stream session timeout; forces reconnect of zombie streams. |
|
| `GrpcMaxStreamLifetime` | `04:00:00` | Per-stream session timeout; forces reconnect of zombie streams. |
|
||||||
| `GrpcMaxConcurrentStreams` | `100` | Max concurrent `SubscribeInstance` streams per site node. |
|
| `GrpcMaxConcurrentStreams` | `100` | Max concurrent `SubscribeInstance` streams per site node. |
|
||||||
| `TransportHeartbeatInterval` | `00:00:05` | `SiteCommunicationActor` heartbeat cadence. |
|
| `ApplicationHeartbeatInterval` | `00:00:05` | `SiteCommunicationActor` site→central heartbeat cadence. |
|
||||||
|
| `TransportHeartbeatInterval` | `00:00:05` | Akka.Remote transport failure-detector heartbeat interval (emitted into the HOCON by the Host). Distinct from the application heartbeat above. |
|
||||||
| `TransportFailureThreshold` | `00:00:15` | Akka remoting failure-detection threshold. |
|
| `TransportFailureThreshold` | `00:00:15` | Akka remoting failure-detection threshold. |
|
||||||
|
| `CentralFetchBaseUrl` | `""` | Base URL (Traefik/LB) the site uses to fetch deploy configs from central. Carried in `RefreshDeploymentCommand` so sites need no standing config; **empty makes a deploy impossible** — `DeploymentService` fails fast. |
|
||||||
|
| `PendingDeploymentTtl` | `00:05:00` | How long a staged `PendingDeployment` row and its fetch token stay valid. Must comfortably cover both site nodes' fetches within one deploy window. |
|
||||||
|
| `PendingDeploymentPurgeInterval` | `01:00:00` | Cadence of the central `pending-deployment-purge` singleton that sweeps TTL-expired staging rows. Hygiene only — the fetch endpoint already enforces the TTL. |
|
||||||
|
|
||||||
Three layers of dead-client detection protect the gRPC stream path:
|
Three layers of dead-client detection protect the gRPC stream path:
|
||||||
|
|
||||||
@@ -227,16 +253,16 @@ Three layers of dead-client detection protect the gRPC stream path:
|
|||||||
## Dependencies & Interactions
|
## Dependencies & Interactions
|
||||||
|
|
||||||
- [Commons (#16)](./Commons.md) — owns all message contracts used by this component: `DeployInstanceCommand`, `SiteEnvelope`, `HeartbeatMessage`, `SiteHealthReport`, `SiteHealthReportReplica`, `RegisterNotificationOutbox`, `RegisterAuditIngest`, `IngestAuditEventsCommand`, `IngestCachedTelemetryCommand`, and all other request/response records. Commons does not hold an Akka package reference, so `RegisterAuditIngest` (which carries an `IActorRef`) lives in this project.
|
- [Commons (#16)](./Commons.md) — owns all message contracts used by this component: `DeployInstanceCommand`, `SiteEnvelope`, `HeartbeatMessage`, `SiteHealthReport`, `SiteHealthReportReplica`, `RegisterNotificationOutbox`, `RegisterAuditIngest`, `IngestAuditEventsCommand`, `IngestCachedTelemetryCommand`, and all other request/response records. Commons does not hold an Akka package reference, so `RegisterAuditIngest` (which carries an `IActorRef`) lives in this project.
|
||||||
- [Cluster Infrastructure (#13)](./ClusterInfrastructure.md) — provides `ClusterClientReceptionist` registration and the active/standby leader model that `SiteCommunicationActor`'s `IsActive` check and `CentralCommunicationActor`'s `DistributedPubSub` fanout both depend on.
|
- [Cluster Infrastructure (#13)](./ClusterInfrastructure.md) — provides `ClusterClientReceptionist` registration and the oldest-`Up` active/standby model that `SiteCommunicationActor`'s `IsActive` stamp depends on, plus the single `"scadabridge"` `ActorSystem` name that makes cross-cluster `ClusterClient` addressing possible at all. `CentralCommunicationActor`'s `DistributedPubSub` fanout keeps both central nodes in sync regardless of which one a site's report landed on.
|
||||||
- [Configuration Database (#17)](./ConfigurationDatabase.md) — provides `ISiteRepository.GetAllSitesAsync` for address loading; site records carry `NodeAAddress`, `NodeBAddress`, `GrpcNodeAAddress`, `GrpcNodeBAddress`.
|
- [Configuration Database (#17)](./ConfigurationDatabase.md) — provides `ISiteRepository.GetAllSitesAsync` for address loading; site records carry `NodeAAddress`, `NodeBAddress`, `GrpcNodeAAddress`, `GrpcNodeBAddress`.
|
||||||
- [Deployment Manager (#2)](./DeploymentManager.md) — the primary consumer of command/control patterns 1–3. `CommunicationService` is injected into the Deployment Manager actor to send deployments, lifecycle commands, and artifact deployments to sites.
|
- [Deployment Manager (#2)](./DeploymentManager.md) — the primary consumer of command/control patterns 1–3. `CommunicationService` is injected into the Deployment Manager actor to send deploy notifies, lifecycle commands, and artifact deployments to sites. It also owns the staging half of the notify-and-fetch HTTP path (`PendingDeployment` rows + fetch tokens); the endpoint itself is served by the Management Service.
|
||||||
- [Site Runtime (#3)](./SiteRuntime.md) — `SiteCommunicationActor` forwards inbound commands to the `DeploymentManager` singleton proxy. `SiteStreamManager` (in Site Runtime) implements `ISiteStreamSubscriber` so `SiteStreamGrpcServer` can subscribe relay actors to instance event feeds without referencing Site Runtime directly.
|
- [Site Runtime (#3)](./SiteRuntime.md) — `SiteCommunicationActor` forwards inbound commands to the `DeploymentManager` singleton proxy. `SiteStreamManager` (in Site Runtime) implements `ISiteStreamSubscriber` so `SiteStreamGrpcServer` can subscribe relay actors to instance event feeds without referencing Site Runtime directly.
|
||||||
- [Health Monitoring (#11)](./HealthMonitoring.md) — `CentralCommunicationActor` calls `ICentralHealthAggregator.MarkHeartbeat` and `ProcessReport` for every inbound heartbeat and health report. `DistributedPubSub` fanout keeps both central nodes' aggregators in sync.
|
- [Health Monitoring (#11)](./HealthMonitoring.md) — `CentralCommunicationActor` calls `ICentralHealthAggregator.MarkHeartbeat` and `ProcessReport` for every inbound heartbeat and health report. `DistributedPubSub` fanout keeps both central nodes' aggregators in sync.
|
||||||
- [Audit Log (#23)](./AuditLog.md) — `SiteStreamGrpcServer` hosts `IngestAuditEvents`, `IngestCachedTelemetry`, and `PullAuditEvents` RPCs. `CentralCommunicationActor` routes `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` ClusterClient messages to the `AuditLogIngestActor` proxy.
|
- [Audit Log (#23)](./AuditLog.md) — `SiteStreamGrpcServer` hosts the `IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents` and `PullSiteCalls` RPCs. Because there is no central gRPC server, the `Ingest*` pair is unused in the shipped topology: sites push audit telemetry over ClusterClient, and `CentralCommunicationActor` routes `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` to the `AuditLogIngestActor` proxy. The `Pull*` reconciliation RPCs run the other way, with the central `site-audit-reconciliation` singleton dialling each site.
|
||||||
- [Notification Outbox (#21)](./NotificationOutbox.md) — `CentralCommunicationActor` routes `NotificationSubmit` / `NotificationStatusQuery` messages from sites to the `NotificationOutboxActor` proxy. `CommunicationService` Asks the proxy directly for central-UI outbox management calls.
|
- [Notification Outbox (#21)](./NotificationOutbox.md) — `CentralCommunicationActor` routes `NotificationSubmit` / `NotificationStatusQuery` messages from sites to the `NotificationOutboxActor` proxy. `CommunicationService` Asks the proxy directly for central-UI outbox management calls.
|
||||||
- [Site Call Audit (#22)](./SiteCallAudit.md) — `CommunicationService` Asks the `SiteCallAuditActor` proxy directly for query and relay operations. `SiteCallAuditActor` issues `RetryParkedOperation` / `DiscardParkedOperation` relay commands to sites via `SiteEnvelope`; `SiteCommunicationActor` dispatches them to `_parkedMessageHandler`.
|
- [Site Call Audit (#22)](./SiteCallAudit.md) — `CommunicationService` Asks the `SiteCallAuditActor` proxy directly for query and relay operations. `SiteCallAuditActor` issues `RetryParkedOperation` / `DiscardParkedOperation` relay commands to sites via `SiteEnvelope`; `SiteCommunicationActor` dispatches them to `_parkedMessageHandler`.
|
||||||
- [Store-and-Forward Engine (#6)](./StoreAndForward.md) — the site S&F Engine drives `NotificationSubmit` forwarding and cached-call telemetry emission through `SiteCommunicationActor`. Parked-message queries and retry/discard relay commands flow back the other way.
|
- [Store-and-Forward Engine (#6)](./StoreAndForward.md) — the site S&F Engine drives `NotificationSubmit` forwarding and cached-call telemetry emission through `SiteCommunicationActor`. Parked-message queries and retry/discard relay commands flow back the other way.
|
||||||
- [Management Service (#18)](./ManagementService.md) — `ManagementActor` is registered with `ClusterClientReceptionist` at `/user/management` on central; the CLI connects via its own separate `ClusterClient`. This is a distinct `ClusterClient` usage from the inter-cluster hub-and-spoke connections managed by this component.
|
- [Management Service (#18)](./ManagementService.md) — `ManagementActor` is registered with `ClusterClientReceptionist` at `/user/management` on central; the CLI connects via its own separate `ClusterClient`. This is a distinct `ClusterClient` usage from the inter-cluster hub-and-spoke connections managed by this component. Management Service also hosts `DeploymentConfigEndpoints` — the `GET /api/internal/deployments/{id}/config` route that terminates the third (HTTP) transport, mapped in the central-role block alongside `/api/audit/*` and `/management`.
|
||||||
- Design spec: [Component-Communication.md](../requirements/Component-Communication.md).
|
- Design spec: [Component-Communication.md](../requirements/Component-Communication.md).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -257,6 +283,10 @@ A `Warning` at the `Status.Failure` handler in `CentralCommunicationActor` means
|
|||||||
|
|
||||||
After a site node failover, the `DebugStreamBridgeActor` attempts to reconnect to the other node endpoint (`_useNodeA` flips on each error). If both nodes are unreachable, the actor exhausts its 3-retry budget and calls `onTerminated`. The engineer must restart the debug session.
|
After a site node failover, the `DebugStreamBridgeActor` attempts to reconnect to the other node endpoint (`_useNodeA` flips on each error). If both nodes are unreachable, the actor exhausts its 3-retry budget and calls `onTerminated`. The engineer must restart the debug session.
|
||||||
|
|
||||||
|
### Deployments fail immediately with a config-fetch error
|
||||||
|
|
||||||
|
The site received the `RefreshDeploymentCommand` over ClusterClient but could not complete the HTTP leg. Check `CentralFetchBaseUrl` first — it must be reachable *from the site*, so a value that only resolves inside the central network fails every deploy. A `404` from the fetch means the staged row was unknown, superseded, or past `PendingDeploymentTtl`; a `401` means the row is live but the token did not match. Because the endpoint hides existence, a `404` cannot distinguish "wrong id" from "expired".
|
||||||
|
|
||||||
### Heartbeats arrive but health reports do not
|
### Heartbeats arrive but health reports do not
|
||||||
|
|
||||||
`SiteCommunicationActor` sends heartbeats and health reports via separate paths. Health reports are sent only when the site's `HealthReportSender` publishes them (every 30 s by default). If heartbeats arrive but reports do not, the health-report sender on the site may have faulted — check site-side logs for errors in `HealthReportSender`.
|
`SiteCommunicationActor` sends heartbeats and health reports via separate paths. Health reports are sent only when the site's `HealthReportSender` publishes them (every 30 s by default). If heartbeats arrive but reports do not, the health-report sender on the site may have faulted — check site-side logs for errors in `HealthReportSender`.
|
||||||
|
|||||||
@@ -26,10 +26,20 @@ Every instance deployment carries two correlated identifiers:
|
|||||||
- **`DeploymentId`** — a new `Guid` (formatted `"N"`) minted by `DeploymentService` at the start of each `DeployInstanceAsync` call.
|
- **`DeploymentId`** — a new `Guid` (formatted `"N"`) minted by `DeploymentService` at the start of each `DeployInstanceAsync` call.
|
||||||
- **`RevisionHash`** — computed by the Template Engine's `RevisionHashService` over the fully resolved `FlattenedConfiguration`. The hash captures the template state at the moment of flattening, so concurrent last-write-wins template edits do not affect an in-flight deployment.
|
- **`RevisionHash`** — computed by the Template Engine's `RevisionHashService` over the fully resolved `FlattenedConfiguration`. The hash captures the template state at the moment of flattening, so concurrent last-write-wins template edits do not affect an in-flight deployment.
|
||||||
|
|
||||||
The pair travels inside `DeployInstanceCommand` to the site. The site uses the `DeploymentId` to detect an already-applied identical command (idempotent re-delivery) and uses the `RevisionHash` to reject a stale configuration that predates what is already running.
|
The pair travels to the site inside the `RefreshDeploymentCommand` notify and is echoed back on the fetched config. The site uses the `DeploymentId` to detect an already-applied identical command (idempotent re-delivery) and uses the `RevisionHash` to reject a stale configuration that predates what is already running.
|
||||||
|
|
||||||
Central stores the `RevisionHash` on `DeploymentRecord` and, after a confirmed success, on `DeployedConfigSnapshot`. Comparing the snapshot hash against the current-template hash determines whether an instance is stale without a site round-trip.
|
Central stores the `RevisionHash` on `DeploymentRecord` and, after a confirmed success, on `DeployedConfigSnapshot`. Comparing the snapshot hash against the current-template hash determines whether an instance is stale without a site round-trip.
|
||||||
|
|
||||||
|
### Notify-and-fetch: the config does not travel in the Akka message
|
||||||
|
|
||||||
|
A deployment crosses the central↔site boundary over **two** transports, not one. Central stages the flattened configuration in a `PendingDeployment` row (config JSON, a generated `DeploymentFetchToken`, and an expiry of `CommunicationOptions.PendingDeploymentTtl`, default 5 minutes) and then sends only a small `RefreshDeploymentCommand` over ClusterClient carrying the deployment id, instance name, revision hash, `CentralFetchBaseUrl` and the fetch token. The site's Deployment Manager singleton fetches the config back over plain HTTP — `GET {CentralFetchBaseUrl}/api/internal/deployments/{deploymentId}/config` with an `X-Deployment-Token` header — and only then runs its normal apply path.
|
||||||
|
|
||||||
|
This exists because a flattened configuration can exceed the default 128 KB Akka frame size, and an over-limit message is dropped silently without tearing down the association — the deploy then simply hangs to its Ask timeout. `CentralFetchBaseUrl` is therefore mandatory: `DeployInstanceAsync` fails fast with "CentralFetchBaseUrl is not configured — required for deployment (notify-and-fetch)" rather than attempting a deploy that cannot complete. Note that `DeployArtifactsCommand` was **not** moved to this path — artifact deployment still carries its payload inline and remains exposed to the frame limit.
|
||||||
|
|
||||||
|
Staged rows are cleaned up by **TTL only** — they are deliberately not deleted on success or in the failure path. Three things keep that safe: `AddPendingDeploymentAsync` supersedes (deletes) any prior pending row for the same instance before inserting, so at most one row exists per instance; the fetch endpoint enforces the TTL itself, so an un-purged row is not a usable one; and the central `pending-deployment-purge` singleton sweeps expired rows on `PendingDeploymentPurgeInterval` (default 1 hour).
|
||||||
|
|
||||||
|
The site's **startup reconciliation** path uses the same endpoint but stages its own rows: a site node reports its local instance→revision-hash map on boot, and central's `ReconcileService` diffs it against the expected deployed set, stages a fresh `PendingDeployment` (with a new token) for each missing or stale instance, and returns the gap plus `CentralFetchBaseUrl` for the node to fetch. Intra-site replication to the standby node does **not** use this path — `deployed_configurations` is a replicated LocalDb table, so the active node's write reaches the peer as an ordinary row change.
|
||||||
|
|
||||||
### Per-instance operation lock
|
### Per-instance operation lock
|
||||||
|
|
||||||
`OperationLockManager` holds a `Dictionary<string, LockEntry>` keyed by instance `UniqueName`. Each `LockEntry` wraps a `SemaphoreSlim(1,1)` with a reference count so the semaphore is created on first contention and disposed when the last waiter clears. The lock covers all four mutating operations — deploy, disable, enable, delete — so they can never interleave on a single instance. Operations on different instances proceed in parallel.
|
`OperationLockManager` holds a `Dictionary<string, LockEntry>` keyed by instance `UniqueName`. Each `LockEntry` wraps a `SemaphoreSlim(1,1)` with a reference count so the semaphore is created on first contention and disposed when the last waiter clears. The lock covers all four mutating operations — deploy, disable, enable, delete — so they can never interleave on a single instance. Operations on different instances proceed in parallel.
|
||||||
@@ -67,7 +77,7 @@ The operation lock is in-memory. If the active central node fails mid-deployment
|
|||||||
3. **Flatten and validate** — `IFlatteningPipeline.FlattenAndValidateAsync` runs the Template Engine pipeline and returns a `FlatteningPipelineResult` containing the `FlattenedConfiguration`, `RevisionHash`, and a `ValidationResult`. Semantic validation failures (call targets, argument types, trigger operand types, connection binding completeness) are returned to the caller before any record is written.
|
3. **Flatten and validate** — `IFlatteningPipeline.FlattenAndValidateAsync` runs the Template Engine pipeline and returns a `FlatteningPipelineResult` containing the `FlattenedConfiguration`, `RevisionHash`, and a `ValidationResult`. Semantic validation failures (call targets, argument types, trigger operand types, connection binding completeness) are returned to the caller before any record is written.
|
||||||
4. **Pre-deploy site reconciliation** — when the prior `DeploymentRecord` for the instance is `InProgress` or `Failed` with a timeout marker (`"Communication failure:"`), the service queries the site via `CommunicationService.QueryDeploymentStateAsync`. If the site already holds the target revision hash, the prior record is updated to `Success` and no new deployment is sent.
|
4. **Pre-deploy site reconciliation** — when the prior `DeploymentRecord` for the instance is `InProgress` or `Failed` with a timeout marker (`"Communication failure:"`), the service queries the site via `CommunicationService.QueryDeploymentStateAsync`. If the site already holds the target revision hash, the prior record is updated to `Success` and no new deployment is sent.
|
||||||
5. **Write `InProgress` record** — a single `DeploymentRecord` insert directly at `InProgress` status (no transient `Pending` hop). `IDeploymentStatusNotifier.NotifyStatusChanged` fires to push the status to the UI.
|
5. **Write `InProgress` record** — a single `DeploymentRecord` insert directly at `InProgress` status (no transient `Pending` hop). `IDeploymentStatusNotifier.NotifyStatusChanged` fires to push the status to the UI.
|
||||||
6. **Send `DeployInstanceCommand`** — the command carries `DeploymentId`, `InstanceUniqueName`, `RevisionHash`, `FlattenedConfigurationJson`, `DeployedBy`, and `Timestamp`.
|
6. **Stage and notify** — insert a `PendingDeployment` row holding the flattened config JSON and a fresh fetch token, then send `RefreshDeploymentCommand` (`DeploymentId`, `InstanceUniqueName`, `RevisionHash`, `DeployedBy`, staging timestamp, `CentralFetchBaseUrl`, `FetchToken`) via `CommunicationService.RefreshDeploymentAsync`. The site fetches the config over HTTP and replies with the same `DeploymentStatusResponse` as before.
|
||||||
7. **Commit terminal status** — the `DeploymentRecord` is updated to `Success` or `Failed` and saved before any post-success side effects run. This ordering ensures the recorded outcome can never be lost if a post-success write fails.
|
7. **Commit terminal status** — the `DeploymentRecord` is updated to `Success` or `Failed` and saved before any post-success side effects run. This ordering ensures the recorded outcome can never be lost if a post-success write fails.
|
||||||
8. **Post-success side effects** — `ApplyPostSuccessSideEffectsAsync` sets `Instance.State = Enabled` (or preserves `Disabled` on the reconciliation path) and upserts the `DeployedConfigSnapshot`. These writes are best-effort: a failure here is logged at `Error` but does not flip the already-committed `Success` record back to `Failed`.
|
8. **Post-success side effects** — `ApplyPostSuccessSideEffectsAsync` sets `Instance.State = Enabled` (or preserves `Disabled` on the reconciliation path) and upserts the `DeployedConfigSnapshot`. These writes are best-effort: a failure here is logged at `Error` but does not flip the already-committed `Success` record back to `Failed`.
|
||||||
9. **Audit log** — `IAuditService.LogAsync` records `Deploy` / `DeployFailed` / `DeployReconciled` with the `DeploymentId`, status, and user.
|
9. **Audit log** — `IAuditService.LogAsync` records `Deploy` / `DeployFailed` / `DeployReconciled` with the `DeploymentId`, status, and user.
|
||||||
@@ -76,7 +86,7 @@ Any exception in the site round-trip (steps 6–7) writes `DeploymentStatus.Fail
|
|||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// DeploymentService.DeployInstanceAsync — exception handler
|
// DeploymentService.DeployInstanceAsync — exception handler
|
||||||
var isTimeout = ex is TimeoutException or OperationCanceledException;
|
var isTimeout = ex is TimeoutException or OperationCanceledException or Akka.Actor.AskTimeoutException;
|
||||||
|
|
||||||
record.Status = DeploymentStatus.Failed;
|
record.Status = DeploymentStatus.Failed;
|
||||||
record.ErrorMessage = isTimeout
|
record.ErrorMessage = isTimeout
|
||||||
@@ -171,11 +181,11 @@ Options are registered via `AddDeploymentManager` and bound from `ScadaBridge:De
|
|||||||
|
|
||||||
- [Template Engine (#1)](./TemplateEngine.md) — `FlatteningPipeline` delegates to `FlatteningService`, `ValidationService`, and `RevisionHashService`. Template state is captured at flatten time; last-write-wins edits made after flatten do not affect the in-flight deployment. `DiffService.ComputeDiff` powers the deployment diff view.
|
- [Template Engine (#1)](./TemplateEngine.md) — `FlatteningPipeline` delegates to `FlatteningService`, `ValidationService`, and `RevisionHashService`. Template state is captured at flatten time; last-write-wins edits made after flatten do not affect the in-flight deployment. `DiffService.ComputeDiff` powers the deployment diff view.
|
||||||
- [Configuration Database (#17)](./ConfigurationDatabase.md) — owns the EF Core implementation of `IDeploymentManagerRepository`, which stores `DeploymentRecord`, `DeployedConfigSnapshot`, and `SystemArtifactDeploymentRecord`. `IAuditService` (also registered by the Configuration Database component) writes all deployment audit rows.
|
- [Configuration Database (#17)](./ConfigurationDatabase.md) — owns the EF Core implementation of `IDeploymentManagerRepository`, which stores `DeploymentRecord`, `DeployedConfigSnapshot`, and `SystemArtifactDeploymentRecord`. `IAuditService` (also registered by the Configuration Database component) writes all deployment audit rows.
|
||||||
- [Central–Site Communication (#5)](./Communication.md) — `CommunicationService` provides `DeployInstanceAsync`, `QueryDeploymentStateAsync`, `DeployArtifactsAsync`, `DisableInstanceAsync`, `EnableInstanceAsync`, and `DeleteInstanceAsync`. The communication layer routes by `SiteIdentifier` (string), not DB id; `DeploymentService.ResolveSiteIdentifierAsync` resolves the numeric `SiteId` before each cross-cluster call and treats a missing site row as a hard failure.
|
- [Central–Site Communication (#5)](./Communication.md) — `CommunicationService` provides `RefreshDeploymentAsync`, `QueryDeploymentStateAsync`, `DeployArtifactsAsync`, `DisableInstanceAsync`, `EnableInstanceAsync`, and `DeleteInstanceAsync`, all over the ClusterClient command/control transport. The communication layer routes by `SiteIdentifier` (string), not DB id; `DeploymentService.ResolveSiteIdentifierAsync` resolves the numeric `SiteId` before each cross-cluster call and treats a missing site row as a hard failure. `CommunicationOptions.CentralFetchBaseUrl` / `PendingDeploymentTtl` (also owned by that component) parameterise the notify-and-fetch HTTP leg.
|
||||||
- [Commons (#16)](./Commons.md) — owns `DeploymentRecord`, `DeployedConfigSnapshot`, `SystemArtifactDeploymentRecord`, `DeploymentStatus`, `InstanceState`, `DeployInstanceCommand`, `DeployArtifactsCommand`, `DeploymentStateQueryRequest/Response`, `InstanceLifecycleResponse`, and the `IDeploymentManagerRepository` interface.
|
- [Commons (#16)](./Commons.md) — owns `DeploymentRecord`, `DeployedConfigSnapshot`, `SystemArtifactDeploymentRecord`, `PendingDeployment`, `DeploymentFetchToken`, `DeploymentStatus`, `InstanceState`, `RefreshDeploymentCommand`, `DeployInstanceCommand` (retained as the site-side in-process apply DTO), `DeployArtifactsCommand`, `DeploymentStateQueryRequest/Response`, `InstanceLifecycleResponse`, and the `IDeploymentManagerRepository` interface.
|
||||||
- [Site Runtime (#3)](./SiteRuntime.md) — receives `DeployInstanceCommand` and `DeployArtifactsCommand` via the Communication Layer. Site-side apply is all-or-nothing per instance: the Deployment Manager singleton at the site stores the config, compiles all scripts, and creates or replaces the Instance Actor as a unit. A failure at any step is reported back with the specific error message and the previous configuration remains active.
|
- [Site Runtime (#3)](./SiteRuntime.md) — receives `DeployInstanceCommand` and `DeployArtifactsCommand` via the Communication Layer. Site-side apply is all-or-nothing per instance: the Deployment Manager singleton at the site stores the config, compiles all scripts, and creates or replaces the Instance Actor as a unit. A failure at any step is reported back with the specific error message and the previous configuration remains active.
|
||||||
- [Central UI (#9)](./CentralUI.md) — engineers trigger deployments, view diffs, manage instance lifecycle, and deploy system-wide artifacts through the UI. The deployment status page subscribes to `IDeploymentStatusNotifier.StatusChanged` for real-time push updates via Blazor Server SignalR.
|
- [Central UI (#9)](./CentralUI.md) — engineers trigger deployments, view diffs, manage instance lifecycle, and deploy system-wide artifacts through the UI. The deployment status page subscribes to `IDeploymentStatusNotifier.StatusChanged` for real-time push updates via Blazor Server SignalR.
|
||||||
- [Management Service (#18)](./ManagementService.md) — the actor-layer entry point for deployment commands received over ClusterClient. It resolves `DeploymentService` and `ArtifactDeploymentService` from a per-message DI scope and forwards `MgmtDeployArtifactsCommand`, `GetDeploymentDiffCommand`, and instance lifecycle requests.
|
- [Management Service (#18)](./ManagementService.md) — the actor-layer entry point for deployment commands received over ClusterClient. It resolves `DeploymentService` and `ArtifactDeploymentService` from a per-message DI scope and forwards `MgmtDeployArtifactsCommand`, `GetDeploymentDiffCommand`, and instance lifecycle requests. It also hosts `DeploymentConfigEndpoints` — the `GET /api/internal/deployments/{id}/config` route a site calls to fetch a staged config. That endpoint is `AllowAnonymous`; the per-deployment token, compared in constant time, is the entire security boundary, and existence/TTL are checked before the token so unknown, superseded and expired ids are indistinguishable `404`s.
|
||||||
- [Security & Auth (#10)](./Security.md) — the Deployment role is required for all deploy and artifact operations; site-scoped permissions are enforced by the Central UI and Management Service before commands reach `DeploymentService`.
|
- [Security & Auth (#10)](./Security.md) — the Deployment role is required for all deploy and artifact operations; site-scoped permissions are enforced by the Central UI and Management Service before commands reach `DeploymentService`.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -188,6 +198,10 @@ The operation lock is in-memory. On failover the new active node has no lock ent
|
|||||||
|
|
||||||
The site round-trip timed out or was cancelled before a response arrived. The site may or may not have applied the config. On the next deploy attempt the reconciliation query determines the ground truth. If the query also fails (site unreachable), a new `DeployInstanceCommand` is sent; the site rejects it with "already applied" if it ran the previous one.
|
The site round-trip timed out or was cancelled before a response arrived. The site may or may not have applied the config. On the next deploy attempt the reconciliation query determines the ground truth. If the query also fails (site unreachable), a new `DeployInstanceCommand` is sent; the site rejects it with "already applied" if it ran the previous one.
|
||||||
|
|
||||||
|
### A deployment fails with a config-fetch error
|
||||||
|
|
||||||
|
The notify reached the site but the HTTP leg did not complete. `CentralFetchBaseUrl` must be resolvable and reachable **from the site** — a value that only works inside the central network fails every deploy. A `404` from the fetch means the staged row was unknown, superseded, or past `PendingDeploymentTtl` (existence is hidden, so those are indistinguishable); a `401` means the row is live but the presented token did not match. A fetch failure applies nothing, and the site replies `Failed` rather than letting central's Ask hang to timeout.
|
||||||
|
|
||||||
### DeleteOrphaned audit entry
|
### DeleteOrphaned audit entry
|
||||||
|
|
||||||
The site destroyed the Instance Actor but the central DB removal failed. The instance record exists in the central DB but has no corresponding site actor. It cannot be deleted through the normal UI path (the site will reject the delete command because the instance does not exist). Reconcile by removing the central record directly via the Management API or database, referencing the `CommandId` in the audit entry.
|
The site destroyed the Instance Actor but the central DB removal failed. The instance record exists in the central DB but has no corresponding site actor. It cannot be deleted through the normal UI path (the site will reject the delete command because the instance does not exist). Reconcile by removing the central record directly via the Management API or database, referencing the `CommandId` in the audit entry.
|
||||||
|
|||||||
@@ -88,17 +88,19 @@ Both central nodes must be configured as seed nodes for each other:
|
|||||||
},
|
},
|
||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadabridge@central-01.example.com:8081",
|
"akka.tcp://scadabridge@central-02.example.com:8081",
|
||||||
"akka.tcp://scadabridge@central-02.example.com:8081"
|
"akka.tcp://scadabridge@central-01.example.com:8081"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Seed order is load-bearing — each node lists ITSELF first** (decision 2026-07-22). Note Node B's list is the reverse of Node A's. Akka only lets `seed-nodes[0]` form a *new* cluster, so a node listing its partner first can never boot while that partner is down. `StartupValidator` rejects the boot if the ordering is wrong, comparing host **and** port; use the same spelling of the hostname in `NodeHostname` and in the seed URI, since Akka does no DNS canonicalisation (`central-02` and `central-02.example.com` are different seed identities). See `docs/requirements/Component-ClusterInfrastructure.md` → Seed Node Ordering.
|
||||||
|
|
||||||
### Cluster Behavior
|
### Cluster Behavior
|
||||||
|
|
||||||
- **Split-brain resolver**: Keep-oldest with `down-if-alone = on`, 15-second stable-after.
|
- **Split-brain resolver**: `auto-down` (`AutoDowning` provider, `auto-down-unreachable-after` = 15s) since the 2026-07-21 availability-over-partition-safety decision — the leader among the *reachable* members downs the unreachable peer, so a hard crash of **either** node fails over. Accepted trade: a real partition leaves both sides active until an operator restarts one. `keep-oldest` (with `down-if-alone = on`) remains a supported `SplitBrainResolverStrategy` value, but in a two-node cluster it cannot survive a crash of the oldest node. See `docs/plans/2026-07-21-auto-down-availability-decision.md`.
|
||||||
- **Minimum members**: `min-nr-of-members = 1` — a single node can form a cluster.
|
- **Minimum members**: `min-nr-of-members = 1` — a single node can form a cluster.
|
||||||
- **Failure detection**: 2-second heartbeat interval, 10-second threshold.
|
- **Failure detection**: 2-second heartbeat interval, 10-second threshold.
|
||||||
- **Total failover time**: ~25 seconds from node failure to singleton migration.
|
- **Total failover time**: ~25 seconds from node failure to singleton migration.
|
||||||
@@ -145,6 +147,8 @@ Each site has its own two-node cluster:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Site Node B reverses this list** — `site-01-b` first, `site-01-a` second — per the self-first seed rule above. It applies to site pairs exactly as it does to the central pair: without it, `site-01-b` cannot boot while `site-01-a` is down.
|
||||||
|
|
||||||
### Site Cluster Behavior
|
### Site Cluster Behavior
|
||||||
|
|
||||||
- Same split-brain resolver as central (keep-oldest).
|
- Same split-brain resolver as central (keep-oldest).
|
||||||
|
|||||||
@@ -48,5 +48,5 @@ Two live items previously tracked ONLY in `archreview/plans/00-MASTER-TRACKER.md
|
|||||||
|
|
||||||
| # | Item | Where noted | Rationale for deferral | Revisit trigger |
|
| # | Item | Where noted | Rationale for deferral | Revisit trigger |
|
||||||
|---|------|-------------|------------------------|-----------------|
|
|---|------|-------------|------------------------|-----------------|
|
||||||
| SBR | **SBR oldest-crash total-outage gap** — 2-node `keep-oldest` downs the partition *without* the oldest, so a hard crash of the ACTIVE (oldest) central node makes the standby self-down (~10s) → total central outage until the crashed node restarts; only a younger-node crash fails over. | `archreview/plans/00-MASTER-TRACKER.md:194` + auto-memory `sbr-keep-oldest-2node-active-crash-gap` | Remedy is a production SBR topology/strategy decision (keep-majority + a 3rd/lighthouse seed node, static-quorum, or an accepted-risk note) — **owner: user decision**, not silently changeable. | Before the next production deployment that adds a central node, or the first real active-node crash. |
|
| SBR | ~~**SBR oldest-crash total-outage gap**~~ **RESOLVED 2026-07-21 (owner decision — availability over partition-safety).** All clusters switched from `keep-oldest` to the `auto-down` downing strategy (Akka `AutoDowning`, `auto-down-unreachable-after` = 15s): a hard crash of EITHER node — active/oldest included — now fails over to the survivor in ~25s with no operator action. Accepted trade: a real network partition produces dual-active until an operator restarts one side. Decision record + evidence (live keep-oldest `DownReachable … including myself` log, Akka.NET 1.5.62 `KeepOldest.OldestDecision` source, rejected alternatives incl. the static-quorum-1 `DownAll` trap): `docs/plans/2026-07-21-auto-down-availability-decision.md`. | `archreview/plans/00-MASTER-TRACKER.md:194` + auto-memory `sbr-keep-oldest-2node-active-crash-gap` (both now historical) | — | Closed. Residual: seed-node boot-alone constraint (unchanged, documented in `Component-ClusterInfrastructure.md`); dual-active recovery is operator-driven. |
|
||||||
| vd03 | **`deploy/wonder-app-vd03/` overlay edits unapplied** — `appsettings.Central.json` needs `AllowSingleNodeCluster: true` + phantom-seed removal + `NodeName: central-a`; `install.ps1` needs `sc.exe failure` recovery actions. The `deploy/wonder-app-vd03/` artifact directory is intentionally untracked (production config out of source control), so the repo cannot ship the fix. | `archreview/plans/00-MASTER-TRACKER.md:198` (PLAN-01 T16/T20/T23) | Needs on-host access; without `NodeName` that deployment's audit rows stamp NULL `SourceNode` — **partially mitigated once PLAN-R2-08 Task 7 lands: the host now FAILS AT BOOT with a key-naming error instead of silently NULLing, so applying the overlay becomes mandatory at the next upgrade.** Owner: whoever maintains the host (user). | Next wonder-app-vd03 deployment/upgrade — **the Task 7 validator makes this row unskippable then.** |
|
| vd03 | **`deploy/wonder-app-vd03/` overlay edits unapplied** — `appsettings.Central.json` needs `AllowSingleNodeCluster: true` + phantom-seed removal + `NodeName: central-a`; `install.ps1` needs `sc.exe failure` recovery actions. The `deploy/wonder-app-vd03/` artifact directory is intentionally untracked (production config out of source control), so the repo cannot ship the fix. | `archreview/plans/00-MASTER-TRACKER.md:198` (PLAN-01 T16/T20/T23) | Needs on-host access; without `NodeName` that deployment's audit rows stamp NULL `SourceNode` — **partially mitigated once PLAN-R2-08 Task 7 lands: the host now FAILS AT BOOT with a key-naming error instead of silently NULLing, so applying the overlay becomes mandatory at the next upgrade.** Owner: whoever maintains the host (user). | Next wonder-app-vd03 deployment/upgrade — **the Task 7 validator makes this row unskippable then.** |
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Auto-Down Downing Strategy — Availability Over Partition-Safety (Decision, 2026-07-21)
|
||||||
|
|
||||||
|
**Status: DECIDED and implemented (owner decision, 2026-07-21).** Resolves the registered
|
||||||
|
deferred "keep-oldest topology/strategy" question (master tracker 2026-07-08;
|
||||||
|
`docs/plans/2026-07-08-deferred-work-register.md` → SBR row).
|
||||||
|
|
||||||
|
## The decision
|
||||||
|
|
||||||
|
All two-node ScadaBridge clusters (central and every site pair) switch their downing
|
||||||
|
strategy from the SBR **keep-oldest** resolver to Akka's **`AutoDowning`** provider
|
||||||
|
(`ClusterOptions.SplitBrainResolverStrategy: "auto-down"`, now the default):
|
||||||
|
|
||||||
|
- `downing-provider-class = "Akka.Cluster.AutoDowning, Akka.Cluster"`
|
||||||
|
- `auto-down-unreachable-after` = `ClusterOptions.StableAfter` (15s production)
|
||||||
|
|
||||||
|
The leader among the **reachable** members downs the unreachable peer after the
|
||||||
|
stability window. Consequence: a hard crash of **either** node — the active/oldest
|
||||||
|
included — fails over to the survivor in ~25s (10s failure detection + 15s window),
|
||||||
|
with no operator action and no victim restart required.
|
||||||
|
|
||||||
|
**The owner's stated rationale, verbatim in effect:** the pairs run one node per VM at
|
||||||
|
each site with no Kubernetes and no SQL available site-side, and "network partitions are
|
||||||
|
less of a risk than if this stops working." Availability wins.
|
||||||
|
|
||||||
|
## The accepted trade (read this before debugging a dual-active)
|
||||||
|
|
||||||
|
In a **real network partition** (both nodes alive, link cut) each side downs the other
|
||||||
|
and continues as a one-node cluster: **both run active** — two oldest-Up members, two
|
||||||
|
sets of singletons, `/health/active` = 200 on both. The pre-decision keep-oldest
|
||||||
|
resolver would instead have sacrificed the younger side. Recovery from dual-active is
|
||||||
|
operator-driven: after the partition heals, restart ONE side; the restarted node rejoins
|
||||||
|
its peer as a fresh incarnation and becomes standby. (The two sides do not merge on
|
||||||
|
their own — the mutual downing quarantines the association.)
|
||||||
|
|
||||||
|
## Why the crashed-oldest direction was unsurvivable before (evidence)
|
||||||
|
|
||||||
|
Live drill on the docker rig, 2026-07-21, `keep-oldest` + `down-if-alone = on`
|
||||||
|
(config verified live): killing the active/oldest `central-a` produced, on `central-b`:
|
||||||
|
|
||||||
|
```
|
||||||
|
SBR took decision Akka.Cluster.SBR.DownReachable and is downing
|
||||||
|
[akka.tcp://scadabridge@scadabridge-central-b:8081] including myself,
|
||||||
|
[1] unreachable of [2] members
|
||||||
|
```
|
||||||
|
|
||||||
|
The survivor downed ITSELF, exited (`run-coordinated-shutdown-when-down`), and its
|
||||||
|
restarted incarnation looped on `InitJoin` (non-first-seed cannot self-form) until the
|
||||||
|
victim returned. Root cause in Akka.NET 1.5.62 `KeepOldest.OldestDecision`
|
||||||
|
(`src/core/Akka.Cluster/SBR/DowningStrategy.cs`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// oldest is on the OTHER (unreachable) side:
|
||||||
|
if (DownIfAlone && otherSide == 1 && thisSide >= 2) // survivor side must be >= 2
|
||||||
|
return DownUnreachable.Instance;
|
||||||
|
return DownReachable.Instance; // 1-vs-1 → down MYSELF
|
||||||
|
```
|
||||||
|
|
||||||
|
`down-if-alone` is designed for ≥3-node clusters; with 1-vs-1 it deliberately keeps the
|
||||||
|
oldest side ("the node on the other side is no better" — upstream comment). So two-node
|
||||||
|
keep-oldest can never survive an oldest crash. This corrected an earlier
|
||||||
|
mis-explanation in the repo ("the alone-oldest is dead and cannot down itself").
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
| Option | Why not |
|
||||||
|
|---|---|
|
||||||
|
| keep-oldest (status quo) | Oldest crash = total outage (proven above). Remains a supported `SplitBrainResolverStrategy` value for deployments preferring partition-safety. |
|
||||||
|
| static-quorum, quorum 1 | Akka's `IsTooManyMembers` guard (`2 > 2*1-1`) returns **DownAll** on any unreachability — total shutdown, strictly worse. |
|
||||||
|
| static-quorum, quorum 2 | Survivor (1 < 2) downs itself on any crash. |
|
||||||
|
| keep-majority | 1-vs-1 tie keeps the lowest-address side — moves the fatal crash from "oldest" to "lowest address", same hole. |
|
||||||
|
| lease-majority | Needs a shared lease store (K8s API, SQL, …) reachable by both nodes — not available at sites. |
|
||||||
|
| third arbiter node | Would make `down-if-alone` work, but there is no third VM at sites. |
|
||||||
|
| custom downing provider | Would reimplement exactly what `AutoDowning` already does, tested upstream. If a future Akka.NET release removes `AutoDowning`, port it then. |
|
||||||
|
|
||||||
|
## What changed (implementation slice, same session)
|
||||||
|
|
||||||
|
- `ClusterOptions`: `SplitBrainResolverStrategy` default → `"auto-down"`; docs rewritten.
|
||||||
|
`DownIfAlone` kept (keep-oldest-only knob, validated only under keep-oldest).
|
||||||
|
- `ClusterOptionsValidator`: allows `auto-down` | `keep-oldest`; `DownIfAlone` requirement
|
||||||
|
scoped to keep-oldest.
|
||||||
|
- `AkkaHostedService.BuildHocon`: downing block branches on the strategy (AutoDowning
|
||||||
|
provider + `auto-down-unreachable-after` vs the SBR block).
|
||||||
|
- All 16 `appsettings` (src Host ×2, `docker/` ×8, `docker-env2/` ×4, and the gitignored
|
||||||
|
`deploy/wonder-app-vd03/` ×2 on-disk overlay) flipped to `auto-down`.
|
||||||
|
**Owner action: sync the wonder-app-vd03 overlay to the host and restart both services
|
||||||
|
together.**
|
||||||
|
- `docker/failover-drill.sh`: `active` mode now asserts the survivor TAKES OVER while
|
||||||
|
the victim is down (previously it asserted the outage).
|
||||||
|
- Tests: HOCON emission (`HoconBuilderTests`), validator/default tests, and two new
|
||||||
|
real-cluster tests in `SbrFailoverTests` — `AutoDown_HardCrashOfOldestNode_
|
||||||
|
YoungerSurvivorTakesOverSingleton` (the direction keep-oldest could never pass) and
|
||||||
|
`AutoDown_HardCrashOfYoungerNode_OldestKeepsSingleton`. `TwoNodeClusterFixture` gained
|
||||||
|
a `strategy` parameter (default `auto-down`).
|
||||||
|
- Docs: `Component-ClusterInfrastructure.md` (Downing Strategy section rewritten),
|
||||||
|
`docker/README.md` (drill docs + results), `CLAUDE.md`, deferred-work register entry
|
||||||
|
resolved.
|
||||||
|
|
||||||
|
## Residual operational notes
|
||||||
|
|
||||||
|
- **Seed-node bootstrap constraint still applies to boot-alone**: only the first seed
|
||||||
|
may self-form a cluster. Auto-down removes the active-crash outage (the survivor never
|
||||||
|
restarts), but a node that must BOOT alone while its peer is dead (cold start of only
|
||||||
|
the non-first-seed VM, or the survivor crashing while the peer is still down) still
|
||||||
|
waits in `InitJoin` for its peer. Operator recovery unchanged (restart first seed, or
|
||||||
|
self-first seed override).
|
||||||
|
- Monitoring already surfaces dual-active if it ever happens: both nodes report
|
||||||
|
`IsActive` in heartbeats / both `/health/active` = 200 — the Health dashboard shows
|
||||||
|
two Primaries.
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# ClusterClient → gRPC-only cross-cluster transport — implementation plan
|
||||||
|
|
||||||
|
**Date:** 2026-07-22. **Design:** `~/Desktop/scadaproj/scadabridge_clusterclient_to_grpc.md`
|
||||||
|
(read it first, §7 especially — it contains the deep-dive corrections this plan builds on).
|
||||||
|
**Goal:** all site↔central traffic rides gRPC with PSK auth from `ZB.MOM.WW.Secrets`;
|
||||||
|
`ClusterClient`/`ClusterClientReceptionist` are deleted; Akka remoting never crosses the
|
||||||
|
site↔central boundary.
|
||||||
|
|
||||||
|
Everything below was code-verified 2026-07-22. If a cited line has drifted, re-locate by the
|
||||||
|
quoted identifier, never by line number.
|
||||||
|
|
||||||
|
## How to execute this plan (read before starting)
|
||||||
|
|
||||||
|
- **Phase order:** 0 → (1A ∥ 1B) → (2 ∥ 3) → 4 → 5. Phases marked ∥ are parallel-safe **only in
|
||||||
|
separate git worktrees** (`git worktree add ../ScadaBridge-1B feat/grpc-sitecommand`) — never
|
||||||
|
run two agents against one working tree (destructive git races are a known family incident).
|
||||||
|
Designated merge order when tracks meet: 1A lands first, 1B rebases (expected conflicts are
|
||||||
|
confined to `ZB.MOM.WW.ScadaBridge.Communication.csproj` proto ItemGroups and `Program.cs`
|
||||||
|
service/Map blocks — resolve as union).
|
||||||
|
- **Branches:** `feat/grpc-phase0-psk`, `feat/grpc-central-control` (1A),
|
||||||
|
`feat/grpc-sitecommand` (1B), then per-phase branches; PR each phase to `main` with its DoD met.
|
||||||
|
- **Build/test:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` (0 warnings — TreatWarningsAsErrors),
|
||||||
|
`dotnet test ZB.MOM.WW.ScadaBridge.slnx`. Tests are xunit **v2** (2.9.3) + `Akka.TestKit.Xunit2`
|
||||||
|
1.5.62 + NSubstitute. TestKit tests inherit `TestKit` — never hand-roll
|
||||||
|
`ActorSystem.Create`+join.
|
||||||
|
- **Proto codegen is CHECKED-IN, not build-time** (protoc segfaults in the linux_arm64 image).
|
||||||
|
For every new/changed proto follow the sitestream recipe documented in
|
||||||
|
`src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj`:
|
||||||
|
temporarily uncomment/add the `<Protobuf Include=... GrpcServices="Both" />` item, delete stale
|
||||||
|
generated files, `dotnet build` on macOS, copy `obj/**/Protos/*.cs` into a committed folder
|
||||||
|
(mirror `SiteStreamGrpc/`), re-comment the item. `docker/regen-proto.sh` exists.
|
||||||
|
- **Rig:** `docker/deploy.sh` rebuilds the 2-central + 3×2-site cluster (+ traefik). Central UI
|
||||||
|
`9001/9002:5000`, site gRPC `90x3/90x4:8083`. **External sites↔central rules:** never
|
||||||
|
host-`sqlite3` a live WAL DB; `aspnet:10.0` has no `curl`; seeding via `docker/seed-sites.sh`.
|
||||||
|
- **Coexistence rule:** every migrated path sits behind a config flag with the Akka
|
||||||
|
implementation as default until Phase 4. Rollback at any point = flip the flag.
|
||||||
|
|
||||||
|
## The two choke points (where all transport code changes)
|
||||||
|
|
||||||
|
- **Site→central:** `SiteCommunicationActor`
|
||||||
|
(`src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs`) — 7 message types,
|
||||||
|
all `ClusterClient.Send` to `/user/central-communication` (Ask, except `HeartbeatMessage` Tell).
|
||||||
|
- **Central→site:** `CentralCommunicationActor` (same dir) — the `SiteEnvelope` handler
|
||||||
|
(`:452-467`) unwraps and routes via per-site ClusterClient. ALL producers
|
||||||
|
(`CommunicationService`'s 27 + `SiteCallAuditActor`'s 2 relays) go through it.
|
||||||
|
**`CommunicationService` gets NO interface extraction; its ~20 consumers are untouched.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — PSK auth + dead-code removal (standalone hardening; parallel-safe with 1A/1B prep)
|
||||||
|
|
||||||
|
### T0.1 Delete the vestigial `/user/management` receptionist registration
|
||||||
|
`AkkaHostedService.cs:459` (`RegisterService` of `ManagementActor`) — delete the registration
|
||||||
|
only (the actor stays; `ManagementEndpoints.cs:117` asks it in-process via
|
||||||
|
`ManagementActorHolder`). Grep-verify nothing sends to `/user/management`. Update
|
||||||
|
`docs/requirements/Component-Host.md` REQ-HOST-6a to record the removal.
|
||||||
|
|
||||||
|
### T0.2 File the dead-integration issue
|
||||||
|
`IntegrationCallRequest` (`CommunicationService.cs:238`) is unwired in production —
|
||||||
|
`RegisterLocalHandler(Integration, …)` exists only in `SiteCommunicationActorTests.cs:102`, so
|
||||||
|
production always replies "Integration handler not available" (`SiteCommunicationActor.cs:134-136`).
|
||||||
|
File a Gitea issue (decide: wire or delete); **exclude it from the gRPC contract** (28 of 29
|
||||||
|
commands migrate). Do not change its behavior in this program.
|
||||||
|
|
||||||
|
### T0.3 PSK interceptor + options
|
||||||
|
New `src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs`, copied from
|
||||||
|
`LocalDbSyncAuthInterceptor.cs` (same file layout: seal, 4 server handlers → shared
|
||||||
|
`Authorize`, `authorization: Bearer` metadata extraction, `CryptographicOperations.FixedTimeEquals`
|
||||||
|
over UTF-8, **fail-closed when the expected key is unset**, reject with
|
||||||
|
`StatusCode.PermissionDenied`). Differences from the template:
|
||||||
|
|
||||||
|
- **Path scope:** gates a *set* of service prefixes (constructor-provided), initially the
|
||||||
|
sitestream service prefix (read the real package/service name from `sitestream.proto` —
|
||||||
|
`/…SiteStreamService/`); later phases add the new services. LocalDb sync keeps its own
|
||||||
|
interceptor + key untouched.
|
||||||
|
- **Key source, site side (server on `:8083`):** new option
|
||||||
|
`CommunicationOptions.GrpcPsk` (`ScadaBridge:Communication:GrpcPsk`), production value
|
||||||
|
`${secret:SB-GRPC-PSK-<siteId>}` resolved by the existing pre-host `SecretReferenceExpander`
|
||||||
|
(`Program.cs:56-65`) — zero new resolution code.
|
||||||
|
- **Key source, central side (clients today; server in 1A):** central's site set is dynamic, so
|
||||||
|
central resolves secret **`SB-GRPC-PSK-{siteId}`** at channel-build time via the runtime
|
||||||
|
`ISecretResolver` (copy the fail-closed lazy pattern from
|
||||||
|
`DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:82-98`), cached per site, cache
|
||||||
|
invalidated on site remove. New helper `SitePskProvider` in Communication (interface) +
|
||||||
|
Host (implementation over `ISecretResolver`).
|
||||||
|
|
||||||
|
Wire: add the interceptor to the site's existing `AddGrpc` (`Program.cs:526-527`, alongside the
|
||||||
|
LocalDb one). Attach the PSK on central's existing site-dialing clients as call-level
|
||||||
|
`Metadata` `Authorization: Bearer <psk>` (the LocalDb sync client pattern,
|
||||||
|
`SyncBackgroundService.cs:82-86`): `SiteStreamGrpcClient` (subscribe calls),
|
||||||
|
`GrpcPullAuditEventsClient`, `GrpcPullSiteCallsClient` — each already flows through a channel/
|
||||||
|
invoker creation point where the site id is known.
|
||||||
|
|
||||||
|
### T0.4 Rig + tests
|
||||||
|
- Rig: mirror the LocalDb dev-key pattern — literal `ScadaBridge:Communication:GrpcPsk:
|
||||||
|
"dev-grpc-psk-site-a"` in BOTH `docker/site-a-node-*/appsettings.Site.json` (and site-b/c with
|
||||||
|
their own keys, since unlike LocalDb this is not optional), plus central-side dev secrets: seed
|
||||||
|
`SB-GRPC-PSK-site-{a,b,c}` into both centrals' secret stores (secret CLI seed flow, or dev-KEK
|
||||||
|
env as the rig's Secrets setup already does).
|
||||||
|
- Tests: interceptor unit tests (wrong key / missing header / unset expected key ⇒
|
||||||
|
`PermissionDenied`; non-gated service path passes; constant-time compare exercised);
|
||||||
|
`SitePskProvider` fail-closed test; one Host wiring test asserting the interceptor is
|
||||||
|
registered.
|
||||||
|
|
||||||
|
**Phase 0 DoD:** suite green; on the rig, an unauthenticated `grpcurl`/test client gets
|
||||||
|
`PermissionDenied` on SiteStream, authenticated streaming + pull still work end-to-end. PR merged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1A — Central control plane (site→central) — worktree A
|
||||||
|
|
||||||
|
### T1A.1 `central_control.proto`
|
||||||
|
New `Protos/central_control.proto` in the Communication project (checked-in codegen per recipe),
|
||||||
|
package `scadabridge.centralcontrol.v1`, service `CentralControlService`:
|
||||||
|
|
||||||
|
| RPC | Wraps DTO (source file under `Commons/Messages/`) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `SubmitNotification` | `NotificationSubmit`/`NotificationSubmitAck` (`Notification/NotificationMessages.cs:30,47`) | 11 fields incl. `Guid?` execution ids → string |
|
||||||
|
| `QueryNotificationStatus` | `NotificationStatusQuery`/`Response` (`:55,62`) | |
|
||||||
|
| `IngestAuditEvents` | reuse existing `AuditEventBatch`/`IngestAck` from sitestream.proto | import, don't duplicate; `ForwardState`/`IngestedAtUtc` stay off-wire (`AuditEventDtoMapper.cs:23-27`) |
|
||||||
|
| `IngestCachedTelemetry` | reuse `CachedTelemetryBatch`/`IngestAck` | same |
|
||||||
|
| `ReconcileSite` | `ReconcileSiteRequest`/`Response` (`Deployment/ReconcileSiteRequest.cs`, `ReconcileSiteResponse.cs` incl. `ReconcileGapItem`) | map<string,string> for name→hash |
|
||||||
|
| `ReportSiteHealth` | `SiteHealthReport`/`SiteHealthReportAck` (`Health/SiteHealthReport.cs`) | the big one: ~30 fields incl. maps of `ConnectionHealth` enum, `TagResolutionStatus`, `TagQualityCounts`, `NodeStatus` list, `SiteAuditBacklogSnapshot`; model nullable ints/doubles with wrappers; keep `SequenceNumber` |
|
||||||
|
| `Heartbeat` | `HeartbeatMessage` (`Health/HeartbeatMessage.cs`) → `google.protobuf.Empty` reply | fire-and-forget semantics preserved client-side (don't await failure into caller) |
|
||||||
|
|
||||||
|
Mappers in `Communication/Grpc/CentralControlDtoMapper.cs` with **round-trip golden tests**
|
||||||
|
(construct DTO → proto → DTO, assert deep-equal; include null-optional cases).
|
||||||
|
|
||||||
|
### T1A.2 Central hosting
|
||||||
|
Central branch of `Program.cs` (~`:262` services, `:449-469` Map block):
|
||||||
|
`builder.Services.AddGrpc(o => o.Interceptors.Add<ControlPlaneAuthInterceptor>())` — central's
|
||||||
|
interceptor variant verifies `Bearer` against the per-site PSK looked up by the **required
|
||||||
|
`x-scadabridge-site` metadata header** via `SitePskProvider` (fail-closed: missing header ⇒
|
||||||
|
`PermissionDenied`). Add an **explicit Kestrel listener** for h2c gRPC: new option
|
||||||
|
`ScadaBridge:Node:CentralGrpcPort` (default **8083**, symmetric with sites), configured like the
|
||||||
|
site branch does (`Program.cs:507-512`, `HttpProtocols.Http2`); central's `:5000` stays as-is
|
||||||
|
(Traefik is HTTP/1 — gRPC does NOT go through traefik). `MapGrpcService<CentralControlGrpcService>()`.
|
||||||
|
|
||||||
|
`CentralControlGrpcService` (Host or Communication): decode proto → the SAME message types →
|
||||||
|
`Ask` the existing `CentralCommunicationActor` handlers (they already handle all 7 — zero
|
||||||
|
handler logic changes) → encode reply. Reuse the readiness convention: reject `Unavailable`
|
||||||
|
until the central actor system is up (mirror `SiteStreamGrpcServer.SetReady`).
|
||||||
|
|
||||||
|
### T1A.3 Site-side client + transport seam
|
||||||
|
- New `ICentralTransport` (Communication): one method per the 7 sends. Implementations:
|
||||||
|
`AkkaCentralTransport` (extracted verbatim from today's `SiteCommunicationActor` send blocks)
|
||||||
|
and `GrpcCentralTransport` (new).
|
||||||
|
- `GrpcCentralTransport`: **channel pair with sticky failover + failback** per design §3.5 — new
|
||||||
|
shared `CentralChannelProvider`: endpoints from new option
|
||||||
|
`ScadaBridge:Communication:CentralGrpcEndpoints` (List<string>, e.g.
|
||||||
|
`["http://scadabridge-central-a:8083","http://scadabridge-central-b:8083"]`; validator: required
|
||||||
|
when transport=Grpc); sticky-until-failure; flip on connect-fail/`Unavailable`; background
|
||||||
|
failback probe every 30–60 s (gRPC health or a `Heartbeat` ping); reconnect backoff copied from
|
||||||
|
`SyncBackgroundService.cs:151` (1 s doubling, cap 60 s). Attach PSK (`GrpcPsk` option) +
|
||||||
|
`x-scadabridge-site` on every call; per-call deadlines from the matching `CommunicationOptions`
|
||||||
|
timeout (`NotificationForwardTimeout`, `HealthReportTimeout`, etc. — today's Ask timeouts,
|
||||||
|
unchanged values). **Cross-node auto-retry only on connect-fail/`Unavailable`** — never on
|
||||||
|
`DeadlineExceeded`.
|
||||||
|
- `SiteCommunicationActor` selects the implementation from new option
|
||||||
|
`ScadaBridge:Communication:CentralTransport` (`Akka` | `Grpc`, **default `Akka`**). The 7
|
||||||
|
handler bodies delegate to the injected transport; reply/fault semantics identical (timeout or
|
||||||
|
non-OK status ⇒ same `Status.Failure` the S&F/audit layers already treat as transient).
|
||||||
|
|
||||||
|
### T1A.4 Tests
|
||||||
|
Extend `Communication.Tests` (TestKit): `SiteCommunicationActor` with an NSubstitute
|
||||||
|
`ICentralTransport` — all 7 paths, fault propagation (transport throw ⇒ same failure the S&F
|
||||||
|
tests expect). `GrpcCentralTransport` unit tests with an in-process `TestServer` gRPC host:
|
||||||
|
failover flip, sticky behavior, failback probe, PSK attached, deadline set, no-retry-on-deadline.
|
||||||
|
Reuse/extend `DirectActorSiteStreamAuditClient` for the ingest integration harness.
|
||||||
|
`NotificationForwarderTests`/`SiteAuditTelemetryActorTests`/`HealthReportSenderTests` must pass
|
||||||
|
unmodified (they sit above the seam — if they need edits, the seam is wrong).
|
||||||
|
|
||||||
|
**1A DoD:** suite green; on the rig with site-a flipped to `CentralTransport=Grpc`
|
||||||
|
(central gRPC port published, e.g. `9013/9014:8083`): notification e2e, audit rows land, health
|
||||||
|
page live, heartbeat drives active flag, reconcile works after site restart — while site-b/c
|
||||||
|
still run Akka (coexistence proven).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1B — Site command plane (central→site) — worktree B
|
||||||
|
|
||||||
|
### T1B.1 `site_command.proto`
|
||||||
|
Package `scadabridge.sitecommand.v1`, service `SiteCommandService` — **28 commands** (29 minus
|
||||||
|
dead `IntegrationCallRequest`), grouped into domain RPCs with `oneof` request/response
|
||||||
|
envelopes (full command list + reply types + `CommunicationService.cs` line refs in the design
|
||||||
|
doc §7 / recon inventory):
|
||||||
|
|
||||||
|
| RPC | Commands (count) | Deadline source |
|
||||||
|
|---|---|---|
|
||||||
|
| `ExecuteLifecycle` | RefreshDeployment, Enable/Disable/DeleteInstance, DeploymentStateQuery, DeployArtifacts (6) | `DeploymentTimeout`/`LifecycleTimeout`/`ArtifactDeploymentTimeout` |
|
||||||
|
| `ExecuteOpcUa` | BrowseNode, SearchAddressSpace, ReadTagValues, VerifyEndpoint, Trust/List/RemoveServerCert, WriteTag (8) | `QueryTimeout` (browse/search per existing Ask usage) |
|
||||||
|
| `ExecuteQuery` | EventLogQuery, DebugSnapshot, Subscribe/UnsubscribeDebugView (4) | `QueryTimeout`/`DebugViewTimeout` |
|
||||||
|
| `ExecuteParked` | ParkedMessageQuery/Retry/Discard, RetryParkedOperation, DiscardParkedOperation (5) | `QueryTimeout`; relay callers keep `RelayTimeout`(10s) < `QueryTimeout`(30s) ordering |
|
||||||
|
| `ExecuteRoute` | RouteToCall/GetAttributes/SetAttributes/WaitForAttribute (4) | `IntegrationTimeout`; WaitForAttribute uses its dynamic timeout |
|
||||||
|
| `TriggerFailover` | TriggerSiteFailover (1) | `LifecycleTimeout` |
|
||||||
|
|
||||||
|
Mappers `SiteCommandDtoMapper.cs` + round-trip golden tests for every command/reply (the bulk of
|
||||||
|
this track — budget accordingly; enums, `TrackedOperationId` struct → string guid, nullable
|
||||||
|
wrappers).
|
||||||
|
|
||||||
|
### T1B.2 Site server: shared dispatcher
|
||||||
|
Refactor `SiteCommunicationActor`'s receive table into `SiteCommandDispatcher` (pure routing:
|
||||||
|
message → `_deploymentManagerProxy` / `_artifactHandler` / `_eventLogHandler` /
|
||||||
|
`_parkedMessageHandler` / failover handler — preserving EXACTLY today's targets, including the
|
||||||
|
node-local parked-message handler; see design §7.3, the replicated-store semantics are
|
||||||
|
deliberate). The actor and a new `SiteCommandGrpcService` (mapped in the site branch next to
|
||||||
|
`SiteStreamGrpcServer`, gated by `ControlPlaneAuthInterceptor` + the readiness flag) both call
|
||||||
|
the dispatcher — one routing truth, both transports.
|
||||||
|
|
||||||
|
### T1B.3 Central client + transport seam
|
||||||
|
`ISiteCommandTransport` (send `SiteEnvelope`-equivalent, Ask or Tell) injected into
|
||||||
|
**`CentralCommunicationActor`**; implementations `AkkaSiteTransport` (today's per-site
|
||||||
|
ClusterClient path, extracted) and `GrpcSiteTransport`. `GrpcSiteTransport` uses a new shared
|
||||||
|
**`SitePairChannelProvider`**: addresses from the `Site` entity's existing
|
||||||
|
`GrpcNodeAAddress`/`GrpcNodeBAddress` (the streaming path's columns — do NOT invent
|
||||||
|
`LoadSiteAddressesFromDb`, it doesn't exist; reuse the `ISiteRepository` reads +
|
||||||
|
`CentralCommunicationActor`'s existing DB-driven cache-refresh loop `:532-598` to build/refresh
|
||||||
|
channels instead of ClusterClients), sticky failover/failback per §3.5, PSK from
|
||||||
|
`SitePskProvider` + deadlines per the table above. Config flag
|
||||||
|
`ScadaBridge:Communication:SiteTransport` (`Akka` | `Grpc`, default `Akka`) selected inside
|
||||||
|
`CentralCommunicationActor` — `CommunicationService` and `SiteCallAuditActor` unchanged.
|
||||||
|
|
||||||
|
### T1B.4 Tests
|
||||||
|
TestKit: `CentralCommunicationActor` with substitute `ISiteCommandTransport` (envelope routing,
|
||||||
|
Ask-sender reply plumbing, per-site transport lifecycle on site add/remove/change);
|
||||||
|
`SiteCommandDispatcher` unit tests (every command → correct target, incl. parked→local handler
|
||||||
|
and failover→local); `SiteCommandGrpcService` via TestServer (auth, readiness, one command per
|
||||||
|
oneof group); existing `CommunicationServiceTests`/`CentralCommunicationActor*Tests` pass with
|
||||||
|
the Akka implementation as default.
|
||||||
|
|
||||||
|
**1B DoD:** suite green; rig central flipped to `SiteTransport=Grpc` for site-a only: from
|
||||||
|
CentralUI — deploy refresh, enable/disable instance, browse, read tag, write tag, event-log
|
||||||
|
query, parked query/retry/discard (run retry against the STANDBY site node explicitly —
|
||||||
|
replicated-store semantics), `TriggerSiteFailover` — all work; site-b/c untouched on Akka.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 (after 1A) ∥ Phase 3 (after 1B) — full cutover on the rig + hardening
|
||||||
|
|
||||||
|
- **Phase 2:** flip all sites to `CentralTransport=Grpc`. Soak: S&F drain under central-a kill
|
||||||
|
(rows stay Pending, resume without loss or duplicates — sequence/dedup layers unchanged),
|
||||||
|
failback observed when central-a returns, health/heartbeat cadence unchanged in
|
||||||
|
`CentralHealthAggregator` (no sequence regressions logged).
|
||||||
|
- **Phase 3:** flip central to `SiteTransport=Grpc` for all sites. Soak: full CentralUI command
|
||||||
|
matrix against each site; site-node kill mid-command returns a clean error (no hang beyond
|
||||||
|
deadline); site pair failover mid-stream of commands.
|
||||||
|
- Both phases: watch for `PermissionDenied` noise (would indicate PSK drift), and confirm zero
|
||||||
|
ClusterClient log activity on flipped paths.
|
||||||
|
|
||||||
|
## Phase 4 — deletion + config cutover (sequential, after 2+3)
|
||||||
|
|
||||||
|
1. Flip both flag defaults to `Grpc`; rig + docs updated; one soak cycle.
|
||||||
|
2. Delete: `AkkaCentralTransport`/`AkkaSiteTransport`, ClusterClient creation
|
||||||
|
(`AkkaHostedService.cs:942-953`), `DefaultSiteClientFactory` (+ its tests), per-site
|
||||||
|
ClusterClient cache in `CentralCommunicationActor` (keep the DB refresh loop — it now feeds
|
||||||
|
`SitePairChannelProvider`), receptionist registrations `:436` and `:935` (T0.1 already removed
|
||||||
|
`:459`), `CommunicationOptions.CentralContactPoints` (+ validator + rig configs), then the
|
||||||
|
flags themselves.
|
||||||
|
3. Grep-gates: `rg -i "clusterclient|receptionist" src tests docker docs` → only historical docs;
|
||||||
|
`rg "CentralContactPoints"` → empty.
|
||||||
|
4. Docs: update `grpc_streams.md` (its "ClusterClient keeps command/control" split is
|
||||||
|
superseded; also fix its `LoadSiteAddressesFromDb` doc-vs-code gap), `Component-Host.md`,
|
||||||
|
`Component-StoreAndForward.md:137`, and add a `docs/known-issues` cross-ref note that the
|
||||||
|
frame-size class is retired. Keep `Akka.Cluster.Tools` (ClusterSingleton still used).
|
||||||
|
|
||||||
|
## Phase 5 — live gate (rig; sequential; every check PASS required)
|
||||||
|
|
||||||
|
1. **PSK negative:** no key / wrong key / missing `x-scadabridge-site` ⇒ `PermissionDenied`;
|
||||||
|
unset server key ⇒ all rejected (fail-closed); LocalDb sync key unaffected.
|
||||||
|
2. **Site→central matrix:** notification e2e (delivered + status query), audit + cached-telemetry
|
||||||
|
rows in `dbo.AuditLog`/site-calls, `/monitoring/health` live per site, heartbeat → active
|
||||||
|
flag, reconcile self-heal after site-node restart.
|
||||||
|
3. **Central→site matrix:** all 6 RPC groups exercised from CentralUI, parked retry/discard on
|
||||||
|
the standby node, failover command drains cleanly.
|
||||||
|
4. **Failover/failback:** kill central-a → sites flip to central-b sticky (S&F uninterrupted);
|
||||||
|
restart central-a → failback within probe cadence; same for a site node from central's side;
|
||||||
|
booting node rejects `Unavailable` until ready and the client fails over.
|
||||||
|
5. **Mid-drain kill:** kill central during an S&F drain burst — zero loss, zero duplicates.
|
||||||
|
6. **Frame-class retirement:** issue a command/reply > 128 KB (large browse/event-log result) —
|
||||||
|
succeeds over gRPC (impossible before).
|
||||||
|
7. **Boundary check:** with everything on gRPC, verify NO Akka association exists between any
|
||||||
|
site container and central (`netstat`/Akka logs) — remoting is pair-internal only.
|
||||||
|
8. **Restart discipline:** full-rig restart (pairs together) comes up clean; no receptionist/
|
||||||
|
ClusterClient log lines anywhere.
|
||||||
|
|
||||||
|
Record results in `docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md` (check-by-check,
|
||||||
|
the family's live-gate format).
|
||||||
|
|
||||||
|
## Effort & parallelization summary
|
||||||
|
|
||||||
|
| Track | Est. | Parallel with |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase 0 | 2–3 d | 1A/1B proto authoring |
|
||||||
|
| 1A | 1.5–2 wk | 1B (separate worktrees; 1A merges first) |
|
||||||
|
| 1B | 2–3 wk (mapper-heavy) | 1A |
|
||||||
|
| 2, 3 | 2–4 d each | each other (independent flags/paths) |
|
||||||
|
| 4 | 2–3 d | — |
|
||||||
|
| 5 | 2–3 d | — |
|
||||||
|
|
||||||
|
Critical path ≈ 1B: **~4–6 weeks total**, matching the design estimate.
|
||||||
|
|
||||||
|
## Task checklist (tick as you go; IDs reference the sections above)
|
||||||
|
|
||||||
|
**Phase 0 — PSK + dead code** (branch `feat/grpc-phase0-psk`)
|
||||||
|
- [ ] T0.1 Delete `/user/management` receptionist registration (`AkkaHostedService.cs:459`) + Component-Host.md update
|
||||||
|
- [ ] T0.2 File dead-`IntegrationCallRequest` issue; record exclusion (28 of 29 migrate)
|
||||||
|
- [ ] T0.3 `ControlPlaneAuthInterceptor` + `CommunicationOptions.GrpcPsk` + `SitePskProvider`; gate SiteStream; PSK attached on central's streaming + pull clients
|
||||||
|
- [ ] T0.4 Rig dev keys (all 3 sites + central store seeds) + interceptor/provider/wiring tests
|
||||||
|
- [ ] Phase 0 DoD: suite green; rig unauthenticated ⇒ `PermissionDenied`, authenticated paths work; PR merged
|
||||||
|
|
||||||
|
**Phase 1A — central control plane** (worktree, `feat/grpc-central-control`)
|
||||||
|
- [ ] T1A.1 `central_control.proto` (7 RPCs; checked-in codegen) + `CentralControlDtoMapper` + round-trip golden tests
|
||||||
|
- [ ] T1A.2 Central hosting: `AddGrpc` + per-site-PSK interceptor (`x-scadabridge-site`), `CentralGrpcPort` h2c listener (8083), `CentralControlGrpcService` (Ask existing handlers), readiness gate
|
||||||
|
- [ ] T1A.3 `ICentralTransport` (Akka extract + Grpc impl), `CentralChannelProvider` (sticky failover/failback, backoff, deadlines, PSK), `CentralTransport` flag default `Akka`, `CentralGrpcEndpoints` option + validator
|
||||||
|
- [ ] T1A.4 Tests: actor-with-fake-transport ×7, TestServer transport tests, S&F/audit/health suites pass unmodified
|
||||||
|
- [ ] 1A DoD: rig site-a on `Grpc` proves all 5 site→central paths while site-b/c stay Akka; PR merged (before 1B)
|
||||||
|
|
||||||
|
**Phase 1B — site command plane** (worktree, `feat/grpc-sitecommand`)
|
||||||
|
- [ ] T1B.1 `site_command.proto` (6 oneof RPCs / 28 commands) + `SiteCommandDtoMapper` + round-trip golden tests (all 28 + replies)
|
||||||
|
- [ ] T1B.2 `SiteCommandDispatcher` refactor (actor + new `SiteCommandGrpcService` share it; parked stays node-local)
|
||||||
|
- [ ] T1B.3 `ISiteCommandTransport` in `CentralCommunicationActor` (Akka extract + Grpc impl), `SitePairChannelProvider` (Site entity Grpc columns + DB refresh loop), `SiteTransport` flag default `Akka`
|
||||||
|
- [ ] T1B.4 Tests: dispatcher routing ×28, actor envelope/reply plumbing, TestServer service tests, existing Communication suites green
|
||||||
|
- [ ] 1B DoD: rig central on `Grpc` for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged
|
||||||
|
|
||||||
|
**Phase 2 ∥ 3 — cutover + soak**
|
||||||
|
- [ ] P2 All sites `CentralTransport=Grpc`; central-kill S&F soak (no loss/dupes), failback observed, health sequences clean
|
||||||
|
- [ ] P3 Central `SiteTransport=Grpc` all sites; full UI command matrix per site; site-kill mid-command clean; no PSK noise; zero ClusterClient log activity on flipped paths
|
||||||
|
|
||||||
|
**Phase 4 — deletion**
|
||||||
|
- [ ] Defaults flip to `Grpc` + soak; then delete Akka transports, ClusterClient creation, `DefaultSiteClientFactory`, receptionist registrations, `CentralContactPoints`, then the flags
|
||||||
|
- [ ] Grep-gates pass (`clusterclient|receptionist` → historical docs only; `CentralContactPoints` → empty)
|
||||||
|
- [ ] Docs updated: `grpc_streams.md`, `Component-Host.md`, `Component-StoreAndForward.md:137`, known-issues cross-ref
|
||||||
|
|
||||||
|
**Phase 5 — live gate** (record in `2026-07-22-clusterclient-to-grpc-live-gate.md`)
|
||||||
|
- [ ] 1 PSK negatives · [ ] 2 site→central matrix · [ ] 3 central→site matrix · [ ] 4 failover/failback both directions · [ ] 5 mid-drain kill · [ ] 6 >128 KB frame-class proof · [ ] 7 no cross-boundary Akka association · [ ] 8 full-rig restart clean
|
||||||
|
|
||||||
|
## Gotchas for the executor (will bite; read twice)
|
||||||
|
|
||||||
|
- Generated proto C# is committed; never add an active `<Protobuf>` item to the csproj in a
|
||||||
|
final commit (linux_arm64 protoc segfault breaks the Docker build).
|
||||||
|
- `HeartbeatMessage` must stay fire-and-forget end-to-end — don't let a gRPC failure surface as
|
||||||
|
a fault to the heartbeat timer path.
|
||||||
|
- Deadline ≠ retry: no automatic cross-node retry on `DeadlineExceeded` for WriteTag/Deploy/
|
||||||
|
Failover; only on provably-unsent failures.
|
||||||
|
- The parked-message handler is node-local **on purpose** (replicated store); do not "fix" it
|
||||||
|
onto the singleton proxy.
|
||||||
|
- Ack-before-Leave on `TriggerSiteFailover` (`SiteCommunicationActor.cs:569`): the gRPC reply
|
||||||
|
must be written before the node leaves — verify the response completes under failover.
|
||||||
|
- Inner-before-outer timeouts: `RelayTimeout`(10 s) < `QueryTimeout`(30 s) must survive the
|
||||||
|
deadline mapping (`CommunicationService.cs:779-786` documents why).
|
||||||
|
- `SiteStreamGrpcServer.AuditIngestAskTimeout` (30 s) is "one source of truth" shared with
|
||||||
|
`CentralCommunicationActor` — keep the new central service on the same constant.
|
||||||
|
- Rig sites reach central by container name (`scadabridge-central-{a,b}:8083`), NOT via traefik
|
||||||
|
(HTTP/1 only).
|
||||||
|
- xunit v2: use `Xunit.SkippableFact` for env-gated tests, not `Assert.Skip`.
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
# ScadaBridge: InitJoin Self-Form Fallback + Manual Failover Control — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||||
|
>
|
||||||
|
> Shared cross-repo design: `~/Desktop/scadaproj/docs/plans/2026-07-22-initjoin-selfform-fallback.md` (design rationale, MNTR assessment, behavior spec). The OtOpcUa half lives in `~/Desktop/OtOpcUa/docs/plans/2026-07-22-selfform-fallback-and-manual-failover.md`. This plan is self-contained for execution.
|
||||||
|
|
||||||
|
**Goal:** (1) Either node of a 2-node ScadaBridge cluster can cold-start alone and become operational, unattended. (2) An admin-only "Trigger failover" control on the Health page performs a graceful, audited role swap of the central pair.
|
||||||
|
|
||||||
|
> ## ⚠️ ARCHITECTURE REVISED DURING EXECUTION (2026-07-22)
|
||||||
|
>
|
||||||
|
> **Part 1 shipped as self-first seed ordering, NOT the `SelfFormAfter` watchdog described below.** Tasks 1–7 as originally written are superseded; what was actually built is in "Part 1 as executed". Tasks 8–10 (manual failover) are unaffected and still apply as written.
|
||||||
|
>
|
||||||
|
> **Why.** A code review of Task 2 raised, and a written test then confirmed, that the watchdog's success signal ("am I `Up` within the window?") cannot distinguish *no seed answered InitJoin* from *a seed answered and the join is in flight* — it sits outside Akka's join handshake. On a routine standby restart the peer is alive but the join stalls behind removal of the restarting node's own stale incarnation; a `Join(self)` issued during `TryingToJoin` abandons the in-flight join and forms a second cluster at the same address. **Measured: a permanent split, still unhealed after 90 s** — converting a routine restart into an outage of the previously-healthy node. That is strictly worse than the gap being closed, and it is not the boot-partition trade the design accepted.
|
||||||
|
>
|
||||||
|
> Akka's own `FirstSeedNodeProcess` already implements exactly the intended semantics — InitJoin the other seeds, self-join only if nobody answers — and, being part of the handshake, has no such race. It runs only when `seed-nodes[0]` is the node's own address. So the fix is seed **ordering**, not new runtime code.
|
||||||
|
>
|
||||||
|
> The plan's stated safety property — "a booting node only self-forms when NO seed answers InitJoin" — is true of Akka's native first-seed rule and **false** of the watchdog. That claim also appears in the shared cross-repo design doc (`scadaproj/docs/plans/2026-07-22-initjoin-selfform-fallback.md`) and in the OtOpcUa half; **both still need correcting** (owner deferred, 2026-07-22).
|
||||||
|
|
||||||
|
## Part 1 as executed — self-first seed ordering
|
||||||
|
|
||||||
|
**Architecture:** Every node lists ITSELF as `seed-nodes[0]` and its partner second. No new runtime code, no timer, no new option. `StartupValidator` enforces the ordering at boot (host **and** port comparison) because a broken ordering fails silently. Manual failover (Part 2) is unchanged: graceful `Cluster.Leave(oldest Up member)` via a new `IManualFailoverService` (CentralUI seam, Host implementation) — singleton drain, watchdog process-exit, supervisor restart, rejoin as youngest.
|
||||||
|
|
||||||
|
**Behavior** (all rows covered by `SelfFirstSeedBootstrapTests` — real in-process clusters from production `BuildHocon` at production failure-detection timings):
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Lone cold-start, peer dead | Self-joins after `seed-node-timeout` (~5 s) — operational, unattended |
|
||||||
|
| Restart into a **live** peer | Peer answers `InitJoinAck`; node rejoins, never islands |
|
||||||
|
| Both cold-start simultaneously, mutually reachable | `InitJoin` handshake converges them → **one** 2-member cluster |
|
||||||
|
| Both cold-start during a genuine boot **partition** | Each forms its own cluster — same dual-active class `auto-down` already accepts |
|
||||||
|
| Peer-first ordering (the old config) | Never forms — retained as a falsifiability control in the test suite |
|
||||||
|
|
||||||
|
**Shipped:**
|
||||||
|
- 6 node appsettings reordered (the `*-node-b` configs; `-a` nodes were already self-first). All 14 satisfy the invariant.
|
||||||
|
- `StartupValidator` self-first rule + 3 tests; `SelfFirstSeedBootstrapTests` (4 tests).
|
||||||
|
- Docs corrected: `docker/README.md`, `docs/requirements/Component-ClusterInfrastructure.md` (new **Seed Node Ordering** section), `docs/components/ClusterInfrastructure.md` (3 passages), `docs/deployment/topology-guide.md` (incl. the stale keep-oldest claim Task 6 flagged), `CLAUDE.md`.
|
||||||
|
- Several docs had asserted self-first ordering was *unsafe* because a simultaneous cold start would produce two clusters that never merge. Disproved by test (row 3) and corrected.
|
||||||
|
|
||||||
|
**⚠️ Ops action:** the gitignored `deploy/wonder-app-vd03/` overlay must have its `SeedNodes` reordered self-first before its next deploy, or the node will now **refuse to boot**. The validator rule is a hard gate deliberately — the alternative is the silent wedge it replaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Original Part 1 architecture (SUPERSEDED — kept for the decision record)</summary>
|
||||||
|
|
||||||
|
**Architecture:** New `ClusterOptions.SelfFormAfter` (`TimeSpan?`, default 10 s, `null`/`≤0` disables, appsettings-bound) arms `ClusterBootstrapFallback` right after ActorSystem creation: wait for membership via `RegisterOnMemberUp`; on expiry, `Cluster.Join(SelfAddress)`. Safety gate: fires only when this node's own address is in its own seed list.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, Akka.NET 1.5.62, Blazor Server (CentralUI), bUnit, xunit. No new packages.
|
||||||
|
|
||||||
|
**Branch:** `feat/selfform-fallback` off `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design essentials (from the shared design doc)
|
||||||
|
|
||||||
|
**The defect:** Akka only lets the FIRST listed seed self-join to form a *new* cluster; every other node loops on `InitJoin` forever. Both docker central nodes list `central-a` first, so a lone cold-starting `central-b` never comes Up (the "registered outage gap" — `docker/README.md:289`). `ClusterOptions.SeedNodes`' doc comment claims "either can start first", which the deployed configs do not deliver — this plan makes it true and fixes the comment.
|
||||||
|
|
||||||
|
**Behavior spec:**
|
||||||
|
|
||||||
|
| Scenario | Behavior with fallback |
|
||||||
|
|---|---|
|
||||||
|
| Peer alive (any boot order) | Normal seed join in ms — fallback never fires |
|
||||||
|
| Lone cold-start, self IS in own seed list | After `SelfFormAfter`: warn log + `Cluster.Join(SelfAddress)` → Up alone, singletons start (`min-nr-of-members=1`) |
|
||||||
|
| Lone cold-start, self NOT in own seed list | Fallback inert (info log) — self-forming would island the node from the real seeds |
|
||||||
|
| Peer boots after survivor self-formed | Peer's InitJoin is answered → joins as youngest/standby. No island. |
|
||||||
|
| Both cold-start simultaneously, mutually unreachable | Both self-form → dual-active (same partition class auto-down accepts; restart one side) |
|
||||||
|
| `SelfFormAfter` null/`≤0` | Disabled — today's wait-forever behavior |
|
||||||
|
| Window expires mid-join-handshake | Benign: Akka ignores `Join` once joined |
|
||||||
|
|
||||||
|
**Manual failover rules:** graceful `Leave`, never `Down`; admin-only (`AuthorizationPolicies.RequireAdmin`); peer guard (disabled when <2 Up `Central` members); confirmation dialog warning the Blazor circuit will drop (Traefik routes the UI to the active node — triggering failover disconnects your own page, which reconnects against the new active); audited via the app's **central** audit writer (`ICentralAuditWriter` — NOT the shared seam, see the dual-seam gotcha) before the Leave is issued. No interplay with `SelfFormAfter` (the peer is alive on this path, so the restarted node rejoins normally).
|
||||||
|
|
||||||
|
**Multi-node TestKit:** assessed and NOT used — in-process real clusters via `TwoNodeClusterFixture` (production `BuildHocon`) cover every deterministic scenario; MNTR would need a dedicated no-parallelization test project for no added coverage. See the shared design doc for the full verdict.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1 (A1): `SelfFormAfter` option + validator
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (first task)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs`
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs`
|
||||||
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsTests.cs`
|
||||||
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsValidatorTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Create the branch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Desktop/ScadaBridge && git checkout main && git checkout -b feat/selfform-fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Write the failing tests** (append to the existing test classes, matching their assertion style — read them first)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ClusterOptionsTests.cs
|
||||||
|
[Fact]
|
||||||
|
public void SelfFormAfter_defaults_to_ten_seconds()
|
||||||
|
{
|
||||||
|
new ClusterOptions().SelfFormAfter.ShouldBe(TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterOptionsValidatorTests.cs
|
||||||
|
[Fact] public void SelfFormAfter_null_passes_validation() { /* valid options + null → Succeeded */ }
|
||||||
|
[Fact] public void SelfFormAfter_zero_passes_validation() { /* zero = explicit disable → Succeeded */ }
|
||||||
|
[Fact] public void SelfFormAfter_negative_fails_validation() { /* -1s → Failed, message mentions SelfFormAfter */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run to verify failure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests --filter "FullyQualifiedName~SelfFormAfter"
|
||||||
|
```
|
||||||
|
Expected: FAIL (compile error — property doesn't exist).
|
||||||
|
|
||||||
|
**Step 4: Implement.** `ClusterOptions.cs` — add after `AllowSingleNodeCluster` (line 104):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Bootstrap self-form fallback window (decision 2026-07-22, scadaproj/akka_failover.md §6.1).
|
||||||
|
/// Akka only lets the FIRST listed seed form a new cluster; a non-first seed cold-starting
|
||||||
|
/// while its peer is down loops on InitJoin forever. When this node has waited longer than
|
||||||
|
/// this window without becoming a cluster member, it forms a cluster on itself
|
||||||
|
/// (<c>Cluster.Join(SelfAddress)</c>) — but ONLY if its own address is in its seed list
|
||||||
|
/// (a non-seed node self-forming would create a permanent island). Default 10s: the pair
|
||||||
|
/// shares a datacenter, so a live peer answers InitJoin in milliseconds and waiting longer
|
||||||
|
/// buys nothing. <c>null</c> or a non-positive value disables the fallback (wait-forever).
|
||||||
|
/// Accepted trade: both nodes cold-starting within the window while mutually unreachable
|
||||||
|
/// form two clusters — the same partition class the auto-down strategy already accepts.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan? SelfFormAfter { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ClusterOptionsValidator.cs` — inside `Validate`, after the `FailureDetectionThreshold` rules:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.RequireThat(options.SelfFormAfter is null || options.SelfFormAfter.Value >= TimeSpan.Zero,
|
||||||
|
"ClusterOptions.SelfFormAfter must be null (disabled), zero (disabled) or a positive duration; "
|
||||||
|
+ "a negative value is always a configuration mistake.");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Run tests → PASS**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "feat(cluster): SelfFormAfter option — bootstrap self-form fallback window"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2 (A2): `ClusterBootstrapFallback` + first integration test
|
||||||
|
|
||||||
|
**Classification:** high-risk (cluster formation behavior)
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.ScadaBridge.Host/Actors/ClusterBootstrapFallback.cs`
|
||||||
|
- Create: `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SelfFormBootstrapTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using Akka.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the InitJoin self-form fallback (decision 2026-07-22): Akka only lets the FIRST
|
||||||
|
/// listed seed form a new cluster, so without the fallback a non-first seed cold-starting
|
||||||
|
/// alone waits on InitJoin forever — the "registered outage gap". These tests build REAL
|
||||||
|
/// single/dual-node clusters from the production BuildHocon output, exactly like
|
||||||
|
/// TwoNodeClusterFixture, and arm the production fallback.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SelfFormBootstrapTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly List<ActorSystem> _systems = new();
|
||||||
|
|
||||||
|
/// <summary>Starts a node whose seed list puts the PEER first (self second, or absent),
|
||||||
|
/// so Akka's own first-seed rule can never self-form it — only the fallback can.</summary>
|
||||||
|
private ActorSystem StartNode(int selfPort, int peerPort, TimeSpan? selfFormAfter, bool selfInSeeds = true)
|
||||||
|
{
|
||||||
|
var nodeOptions = new NodeOptions { Role = "Central", NodeHostname = "127.0.0.1", RemotingPort = selfPort };
|
||||||
|
var clusterOptions = new ClusterOptions
|
||||||
|
{
|
||||||
|
SeedNodes = selfInSeeds
|
||||||
|
? new List<string>
|
||||||
|
{
|
||||||
|
$"akka.tcp://scadabridge@127.0.0.1:{peerPort}",
|
||||||
|
$"akka.tcp://scadabridge@127.0.0.1:{selfPort}",
|
||||||
|
}
|
||||||
|
: new List<string> { $"akka.tcp://scadabridge@127.0.0.1:{peerPort}" },
|
||||||
|
SelfFormAfter = selfFormAfter,
|
||||||
|
StableAfter = TimeSpan.FromSeconds(3),
|
||||||
|
HeartbeatInterval = TimeSpan.FromMilliseconds(500),
|
||||||
|
FailureDetectionThreshold = TimeSpan.FromSeconds(2),
|
||||||
|
MinNrOfMembers = 1,
|
||||||
|
AllowSingleNodeCluster = !selfInSeeds,
|
||||||
|
};
|
||||||
|
var hocon = AkkaHostedService.BuildHocon(
|
||||||
|
nodeOptions, clusterOptions, new[] { "Central" },
|
||||||
|
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3));
|
||||||
|
var system = ActorSystem.Create("scadabridge", ConfigurationFactory.ParseString(hocon));
|
||||||
|
_systems.Add(system);
|
||||||
|
ClusterBootstrapFallback.Arm(system, clusterOptions, NullLogger.Instance);
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Lone_non_first_seed_self_forms_after_the_window()
|
||||||
|
{
|
||||||
|
var selfPort = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var deadPeerPort = TwoNodeClusterFixture.GetFreeTcpPort(); // nothing listening
|
||||||
|
var node = StartNode(selfPort, deadPeerPort, selfFormAfter: TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
// Without the fallback this waits forever (Akka first-seed rule). With it, the node
|
||||||
|
// must be a 1-member Up cluster shortly after the 2s window.
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(node, 1, TimeSpan.FromSeconds(20));
|
||||||
|
Cluster.Get(node).SelfMember.Status.ShouldBe(MemberStatus.Up);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InitializeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var s in _systems)
|
||||||
|
{
|
||||||
|
try { await s.Terminate().WaitAsync(TimeSpan.FromSeconds(10)); } catch { /* teardown */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Match the assertion library to `SbrFailoverTests.cs` — Shouldly vs xunit `Assert`.)
|
||||||
|
|
||||||
|
**Step 2: Run to verify failure** — compile error (`ClusterBootstrapFallback` doesn't exist):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ZB.MOM.WW.ScadaBridge.IntegrationTests --filter "FullyQualifiedName~SelfFormBootstrapTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Implement `ClusterBootstrapFallback.cs`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Akka.Actor;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// InitJoin self-form fallback (decision 2026-07-22, scadaproj/akka_failover.md §6.1).
|
||||||
|
/// Akka only lets the FIRST listed seed form a NEW cluster; every other node retries InitJoin
|
||||||
|
/// forever. So "both nodes are seed nodes" (ClusterOptions.SeedNodes) does NOT mean either can
|
||||||
|
/// cold-start alone — a non-first seed booting while its peer is down waits indefinitely (the
|
||||||
|
/// "registered outage gap", docker/README.md). This watchdog waits <see cref="ClusterOptions.SelfFormAfter"/>
|
||||||
|
/// for membership; on expiry it forms a cluster on itself.
|
||||||
|
///
|
||||||
|
/// <para><b>Island safety.</b> Fires ONLY when this node's own address is in its own seed list.
|
||||||
|
/// A node that is not a seed (never legitimately first) must keep waiting: if it self-formed,
|
||||||
|
/// a later-booting real seed would form a second cluster and the two can never merge. For nodes
|
||||||
|
/// that ARE seeds, sequential recovery is island-free — Akka's join protocol prefers an existing
|
||||||
|
/// cluster (a booting node only self-forms when NO seed answers InitJoin), so a peer booting
|
||||||
|
/// after this node self-formed simply joins it.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Races are benign.</b> If the join completes between window expiry and
|
||||||
|
/// <c>Cluster.Join(SelfAddress)</c>, Akka ignores the join — a node joins a cluster at most once
|
||||||
|
/// per incarnation. The residual risk is both nodes cold-starting inside the window while
|
||||||
|
/// mutually unreachable (a boot-time partition): both self-form, the same dual-active class the
|
||||||
|
/// auto-down downing strategy already accepts, with the same recovery (restart one side).</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class ClusterBootstrapFallback
|
||||||
|
{
|
||||||
|
public static void Arm(ActorSystem system, ClusterOptions options, ILogger logger)
|
||||||
|
{
|
||||||
|
if (options.SelfFormAfter is not { } window || window <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Cluster self-form fallback disabled (SelfFormAfter not set) — a node cold-starting "
|
||||||
|
+ "while its peer is down will wait on InitJoin indefinitely.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cluster = Akka.Cluster.Cluster.Get(system);
|
||||||
|
var self = cluster.SelfAddress;
|
||||||
|
var isSeed = options.SeedNodes.Any(s => TryParseAddress(s, out var a) && a.Equals(self));
|
||||||
|
if (!isSeed)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Cluster self-form fallback inactive: this node ({Self}) is not in its own seed list "
|
||||||
|
+ "[{Seeds}] — self-forming here would island it from the real seeds.",
|
||||||
|
self, string.Join(", ", options.SeedNodes));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var joined = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
cluster.RegisterOnMemberUp(() => joined.TrySetResult());
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var winner = await Task.WhenAny(joined.Task, Task.Delay(window));
|
||||||
|
if (winner == joined.Task || system.WhenTerminated.IsCompleted)
|
||||||
|
return;
|
||||||
|
logger.LogWarning(
|
||||||
|
"No cluster membership after {Window} — no seed answered InitJoin (peer down at boot). "
|
||||||
|
+ "Self-forming a cluster at {Self} so this node becomes operational; if the peer was "
|
||||||
|
+ "merely partitioned (not dead), the pair is now dual-active — restart one side after "
|
||||||
|
+ "the partition heals (accepted availability-first trade, decision 2026-07-22).",
|
||||||
|
window, self);
|
||||||
|
cluster.Join(self);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseAddress(string seed, out Address address)
|
||||||
|
{
|
||||||
|
try { address = Address.Parse(seed); return true; }
|
||||||
|
catch { address = default!; return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Add the `Microsoft.Extensions.Logging` using. If non-generic `TaskCompletionSource` is unavailable, use `TaskCompletionSource<bool>` + `TrySetResult(true)`.)
|
||||||
|
|
||||||
|
**Step 4: Run → PASS** (~5–10 s). **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "feat(cluster): InitJoin self-form fallback — lone non-first seed becomes Up"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3 (A3): Remaining fallback tests — disabled / late-peer merge / non-seed
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SelfFormBootstrapTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Append three tests** (each absence assertion carries an in-test positive control — repo convention):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Disabled_fallback_keeps_waiting_and_the_node_was_otherwise_formable()
|
||||||
|
{
|
||||||
|
var selfPort = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var deadPeerPort = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var node = StartNode(selfPort, deadPeerPort, selfFormAfter: null);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(6)); // 3x the window used in the enabled test
|
||||||
|
var cluster = Cluster.Get(node);
|
||||||
|
cluster.State.Members.ShouldBeEmpty(); // still InitJoin-looping — today's behavior
|
||||||
|
|
||||||
|
// POSITIVE CONTROL: prove the node COULD have formed; only the fallback was missing.
|
||||||
|
cluster.Join(cluster.SelfAddress);
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(node, 1, TimeSpan.FromSeconds(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Peer_booting_after_self_form_joins_the_existing_cluster_no_island()
|
||||||
|
{
|
||||||
|
var portA = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var portB = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
|
||||||
|
// B cold-starts alone (A dead), self-forms after 2s.
|
||||||
|
var nodeB = StartNode(portB, peerPort: portA, selfFormAfter: TimeSpan.FromSeconds(2));
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 1, TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
// A boots later with the pair seed list. B answers InitJoin, so A must JOIN B's
|
||||||
|
// cluster instead of islanding. Generous window on A so its fallback can't race.
|
||||||
|
var nodeA = StartNode(portA, peerPort: portB, selfFormAfter: TimeSpan.FromSeconds(30));
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(20));
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Non_seed_node_never_self_forms()
|
||||||
|
{
|
||||||
|
var selfPort = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var deadSeedPort = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var node = StartNode(selfPort, deadSeedPort, selfFormAfter: TimeSpan.FromSeconds(1), selfInSeeds: false);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5)); // 5x the window
|
||||||
|
var cluster = Cluster.Get(node);
|
||||||
|
cluster.State.Members.ShouldBeEmpty(); // guard refused to island a non-seed
|
||||||
|
|
||||||
|
// Positive control: the guard (not the environment) prevented formation.
|
||||||
|
cluster.Join(cluster.SelfAddress);
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(node, 1, TimeSpan.FromSeconds(20));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run** — all 4 PASS. **Step 3: Commit** `test(cluster): self-form fallback — disabled, late-peer merge, non-seed guard`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4 (A4): Production wiring + fix the misleading `SeedNodes` doc comment
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` (inside `GetOrCreateActorSystem`, after the `WhenTerminated` continuation ending ~line 218, before `_actorSystem = system;`)
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs:33-38`
|
||||||
|
|
||||||
|
**Step 1: Wire the fallback:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// InitJoin self-form fallback (decision 2026-07-22): without it a non-first seed
|
||||||
|
// cold-starting while its peer is down loops on InitJoin forever — auto-down closed
|
||||||
|
// the crash-failover gap, this closes the cold-start-alone gap. Guarded inside Arm:
|
||||||
|
// disabled when SelfFormAfter is unset, inert when this node is not its own seed.
|
||||||
|
ClusterBootstrapFallback.Arm(system, _clusterOptions, _logger);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Fix the `SeedNodes` doc comment** (replace the `<summary>`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Akka.NET cluster seed nodes. Both nodes are seed nodes — each node lists itself and its
|
||||||
|
/// partner. NOTE: listing both is NOT sufficient for "either can start first": Akka only lets
|
||||||
|
/// the FIRST listed seed form a new cluster, so a lone non-first seed waits on InitJoin until
|
||||||
|
/// <see cref="SelfFormAfter"/> expires and the self-form fallback fires (decision 2026-07-22).
|
||||||
|
/// Must contain at least one entry.
|
||||||
|
/// </summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Build + targeted tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build ZB.MOM.WW.ScadaBridge.slnx # 0 warnings (TreatWarningsAsErrors)
|
||||||
|
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests
|
||||||
|
dotnet test tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit** `feat(cluster): arm self-form fallback at ActorSystem creation; honest SeedNodes doc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5 (A5): appsettings sweep
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 6
|
||||||
|
|
||||||
|
Add `"SelfFormAfter": "00:00:10"` next to `"SplitBrainResolverStrategy"` in the `ScadaBridge:Cluster` section of each (explicit for operator visibility; matches the code default):
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json`, `appsettings.Site.json`
|
||||||
|
- `docker/central-node-a/appsettings.Central.json`, `docker/central-node-b/appsettings.Central.json`
|
||||||
|
- `docker/site-a-node-a/appsettings.Site.json`, `docker/site-a-node-b/appsettings.Site.json`
|
||||||
|
- `docker/site-b-node-a/appsettings.Site.json`, `docker/site-b-node-b/appsettings.Site.json`
|
||||||
|
- `docker/site-c-node-a/appsettings.Site.json`, `docker/site-c-node-b/appsettings.Site.json`
|
||||||
|
- `docker-env2/` — the 4 node appsettings files
|
||||||
|
|
||||||
|
Then `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests` and commit `config(cluster): SelfFormAfter=10s explicit in all node appsettings`.
|
||||||
|
|
||||||
|
> Ops note (do NOT edit here): the gitignored `deploy/wonder-app-vd03/` overlay gets the same key on the next production deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6 (A6): Docs
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 5
|
||||||
|
|
||||||
|
- `docs/requirements/Component-ClusterInfrastructure.md` — replace the "registered outage gap" recovery text (env-var override) with `SelfFormAfter` semantics + island guard + boot-partition trade.
|
||||||
|
- `docker/README.md` (~line 289) — same replacement; keep the partition-trade note.
|
||||||
|
- `docs/deployment/topology-guide.md:101` — **fix the stale keep-oldest claim** (still says "Keep-oldest with `down-if-alone = on`"): rewrite to auto-down default + `SelfFormAfter`, pointing at the decision records.
|
||||||
|
- `CLAUDE.md` (~line 222) — update the boot-order note: pairs no longer require the first seed for cold start; note the 10 s window.
|
||||||
|
|
||||||
|
Commit: `docs(cluster): SelfFormAfter fallback; fix stale keep-oldest note in topology guide`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7 (A7): Full verification + optional docker live gate
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min (suite runtime dominates)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Step 1:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Desktop/ScadaBridge
|
||||||
|
dotnet build ZB.MOM.WW.ScadaBridge.slnx # expect 0 warnings
|
||||||
|
dotnet test ZB.MOM.WW.ScadaBridge.slnx # expect green vs pre-existing baseline
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 (LIVE GATE — run if the docker rig is available; else record deferred-live):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker && bash deploy.sh # rebuild with the fallback
|
||||||
|
docker compose stop central-a central-b # verify service names in docker-compose.yml first
|
||||||
|
docker compose start central-b # cold-start ONLY the non-first seed
|
||||||
|
docker compose logs -f central-b | grep -m1 "Self-forming a cluster" # ≈10s after start
|
||||||
|
curl -fsS http://localhost:9002/health/active # expect 200
|
||||||
|
docker compose start central-a # first seed returns → must JOIN, not island
|
||||||
|
docker compose logs central-a | grep -i "Welcome"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `central-b` self-forms ≈10 s and serves (previously a permanent wedge); `central-a` rejoins as youngest/standby.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8 (D1): `IManualFailoverService` + cluster-level test
|
||||||
|
|
||||||
|
**Classification:** high-risk (cluster behavior)
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IManualFailoverService.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.ScadaBridge.Host/Health/AkkaManualFailoverService.cs`
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (register in the Central branch, next to `IActiveNodeGate` ~line 330)
|
||||||
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/ManualFailoverTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Failing tests** on `TwoNodeClusterFixture`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Failover_makes_the_oldest_leave_and_the_survivor_take_over()
|
||||||
|
{
|
||||||
|
await using var f = await TwoNodeClusterFixture.StartAsync();
|
||||||
|
var oldest = Akka.Cluster.Cluster.Get(f.NodeA); // NodeA started first = oldest
|
||||||
|
var target = AkkaManualFailoverService.FailOverCore(f.NodeB, "Central"); // issued from the OTHER node
|
||||||
|
target.ShouldBe(oldest.SelfAddress);
|
||||||
|
|
||||||
|
// Graceful exit path: the left node's own ActorSystem terminates…
|
||||||
|
await f.NodeA.WhenTerminated.WaitAsync(TimeSpan.FromSeconds(30));
|
||||||
|
// …and the survivor becomes a 1-member cluster and the oldest-Up active node.
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(f.NodeB, oldest.SelfAddress, TimeSpan.FromSeconds(30));
|
||||||
|
ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(f.NodeB)).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Failover_refuses_when_no_peer_exists()
|
||||||
|
{ /* 1-node cluster → FailOverCore returns null, node still Up afterwards (positive assert) */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2:** Run → FAIL (service missing).
|
||||||
|
|
||||||
|
**Step 3: Implement.** Interface in CentralUI (CentralUI stays Akka-free):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IManualFailoverService
|
||||||
|
{
|
||||||
|
/// <summary>Gracefully fails over the central cluster: the current active (oldest Up)
|
||||||
|
/// member leaves, restarts via its supervisor, and rejoins as standby. Returns the
|
||||||
|
/// address string acted on, or null when there is no peer to fail over to.</summary>
|
||||||
|
Task<string?> FailOverCentralAsync(string actor);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Host implementation — static testable core + thin DI wrapper:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class AkkaManualFailoverService : IManualFailoverService
|
||||||
|
{
|
||||||
|
// ctor: (AkkaHostedService akka, ICentralAuditWriter audit, ILogger<AkkaManualFailoverService> logger)
|
||||||
|
|
||||||
|
public async Task<string?> FailOverCentralAsync(string actor)
|
||||||
|
{
|
||||||
|
var system = _akka.GetOrCreateActorSystem();
|
||||||
|
var target = FailOverCore(system, role: "Central", dryRun: true);
|
||||||
|
if (target is null) return null; // peer guard
|
||||||
|
await _audit.WriteAsync(/* canonical AuditEvent: Action=cluster.manual-failover,
|
||||||
|
Actor=actor, DetailsJson={"target": target}, Outcome=Success — copy the exact
|
||||||
|
call shape from an existing audited admin action (e.g. the Sites admin service);
|
||||||
|
use the CENTRAL audit writer, not the shared seam */);
|
||||||
|
FailOverCore(system, role: "Central"); // Cluster.Leave(target)
|
||||||
|
_logger.LogWarning("Manual failover triggered by {Actor}: {Target} is leaving the cluster.", actor, target);
|
||||||
|
return target.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Oldest Up member with the role leaves — mirrors ActiveNodeEvaluator's oldest-Up
|
||||||
|
/// rule so the node acted on is exactly the one hosting the singletons. Returns null when
|
||||||
|
/// fewer than 2 Up members carry the role (no peer = failover would be an outage).</summary>
|
||||||
|
public static Address? FailOverCore(ActorSystem system, string role, bool dryRun = false)
|
||||||
|
{
|
||||||
|
var cluster = Akka.Cluster.Cluster.Get(system);
|
||||||
|
var withRole = cluster.State.Members
|
||||||
|
.Where(m => m.Status == MemberStatus.Up && m.HasRole(role))
|
||||||
|
.OrderBy(m => m, Member.AgeOrdering)
|
||||||
|
.ToList();
|
||||||
|
if (withRole.Count < 2) return null;
|
||||||
|
var oldest = withRole[0];
|
||||||
|
if (!dryRun) cluster.Leave(oldest.Address);
|
||||||
|
return oldest.Address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4:** Register in the Central branch of `Program.cs`. **Step 5:** Tests PASS → commit `feat(ui): manual central failover service — graceful Leave of the oldest Up member`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9 (D2): Health page button + bUnit tests + runbook
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor` (central-cluster card, near the Nodes column ~line 233)
|
||||||
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/HealthFailoverButtonTests.cs` (follow the project's existing bUnit page-test pattern)
|
||||||
|
- Modify: `docs/requirements/Component-ClusterInfrastructure.md` + `docker/README.md` — manual-failover runbook paragraph
|
||||||
|
|
||||||
|
**Step 1: Failing bUnit tests:** (a) button absent without the admin policy; (b) present + enabled for admin with ≥2 online central nodes; (c) disabled with tooltip at 1 node; (d) confirm flow calls `IManualFailoverService.FailOverCentralAsync` exactly once (fake service).
|
||||||
|
|
||||||
|
**Step 2: Implement:**
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||||
|
<button class="btn btn-outline-warning btn-sm"
|
||||||
|
disabled="@(!CentralHasPeer)"
|
||||||
|
title="@(CentralHasPeer ? "Gracefully restart the active node; the standby takes over."
|
||||||
|
: "No standby available — failover would be an outage.")"
|
||||||
|
@onclick="() => _showFailoverConfirm = true">
|
||||||
|
Trigger failover
|
||||||
|
</button>
|
||||||
|
</AuthorizeView>
|
||||||
|
```
|
||||||
|
|
||||||
|
plus a confirmation modal (copy the page's existing dialog idiom). The warning text MUST state: the active node restarts, roles swap, and this page will briefly disconnect and reconnect against the new active node (Traefik routes to the active). On confirm: call the service with the authenticated user name; surface the returned target address.
|
||||||
|
|
||||||
|
**Step 3:** Tests PASS → commit `feat(ui): admin manual-failover control on the health page`.
|
||||||
|
|
||||||
|
**Live check (fold into the Task 7 gate when the rig is up):** press the button, watch `central-a` restart and `central-b`'s badge flip to Primary; audit row lands in `dbo.AuditLog`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10 (D3, OPTIONAL — confirm with the user before executing): site-pair failover from the central UI
|
||||||
|
|
||||||
|
**Classification:** high-risk (new cross-cluster message contract)
|
||||||
|
**Estimated implement time:** exploration first; likely 2–3 tasks if approved
|
||||||
|
|
||||||
|
The Health page also shows per-site node cards (Primary/Standby from heartbeats), but central and sites are **separate Akka clusters** — a site failover needs a `TriggerSiteFailover(siteId)` command over the existing central→site transport (same channel as the Retry/Discard relay), handled on the site's active node by `Cluster.Leave(SelfAddress)`. That adds a versioned message contract (rolling-upgrade surface). If approved: explore `ZB.MOM.WW.ScadaBridge.Communication` for the command path, mirror an existing command end-to-end, per-site button with the same guard/confirm/audit rules. Otherwise: file a follow-up issue and skip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
- Merge decision via the finishing-a-development-branch flow (family convention: ff-merge to `main` + push to gitea, or PR — ask the user).
|
||||||
|
- After merge: update `scadaproj/akka_failover.md` §6.1 status, `scadaproj/CLAUDE.md` index row, and memory `ha-availability-over-partition-safety` (tracked as the family-docs task in the scadaproj index plan).
|
||||||
|
- Verification-before-completion applies throughout: no task is done without its command output; live gates may be recorded deferred-live if the rig is down.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-07-22-selfform-fallback-and-manual-failover.md",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"subject": "Part 1 (SUPERSEDED Tasks 1-4): SelfFormAfter watchdog - implemented, review+test rejected it, reverted",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"subject": "Part 1 as executed: self-first seed ordering + StartupValidator rule + SelfFirstSeedBootstrapTests + 14-config sweep + docs",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"subject": "Task 7: full solution verification + optional docker live gate",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"subject": "Task 8: IManualFailoverService + cluster-level test",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"subject": "Task 9: Health page failover button + bUnit tests + runbook",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
3
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"subject": "Task 10: site-pair failover via central\u2192site transport (BUILT \u2014 user approved 2026-07-22)",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
4
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"subject": "FOLLOW-UP: OtOpcUa converged on self-first seed ordering (2 local commits, unpushed); scadaproj CLAUDE.md index rows still owner-held",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"subject": "OPS: reorder SeedNodes self-first in gitignored deploy/wonder-app-vd03/ before next deploy (hard boot gate)",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-07-22T00:00:00Z"
|
||||||
|
}
|
||||||
@@ -92,27 +92,83 @@ Akka.NET cluster singletons run on the active node of their cluster and migrate
|
|||||||
- Health reporting resumes from the new active node.
|
- Health reporting resumes from the new active node.
|
||||||
- Alarm states are re-evaluated from incoming values (alarm state is in-memory only).
|
- Alarm states are re-evaluated from incoming values (alarm state is in-memory only).
|
||||||
|
|
||||||
## Split-Brain Resolution
|
## Downing Strategy (auto-down — availability-first)
|
||||||
|
|
||||||
The system uses the Akka.NET **keep-oldest** split-brain resolver strategy:
|
**Decision 2026-07-21 (owner decision, resolves the deferred keep-oldest topology/strategy question):** the clusters run the **`auto-down`** downing strategy — Akka's `AutoDowning` provider with `auto-down-unreachable-after` = `StableAfter` (15s). The leader among the *reachable* members downs the unreachable peer after the stability window:
|
||||||
|
|
||||||
- On a network partition, the node that has been in the cluster longest remains active. The younger node downs itself.
|
- **Either-node crash is survivable.** If the standby crashes, the active node downs it and continues (as before). If the **active/oldest** node crashes, the younger survivor becomes leader among the reachable members, downs the dead oldest, becomes the oldest itself, re-hosts every cluster singleton, and `/health/active` flips to it — **no operator action and no victim restart required**. This closes the keep-oldest total-outage gap.
|
||||||
- **Stable-after duration**: 15 seconds. The cluster membership must remain stable (no changes) for 15 seconds before the resolver acts to down unreachable nodes. This prevents premature downing during startup or rolling restarts.
|
- **The accepted trade: dual-active during a real network partition.** With both nodes alive but partitioned, each side downs the other and continues as a one-node cluster — both claim active until the partition heals and an operator restarts one side (the restarted node rejoins the other as a fresh incarnation). This trade was chosen deliberately: site pairs run one node per VM with no shared lease infrastructure (no Kubernetes, no SQL at sites) to arbitrate, and a stalled system is a bigger operational risk than a rare LAN partition.
|
||||||
- **`down-if-alone = on`**: The keep-oldest resolver is configured with `down-if-alone` enabled. If the oldest node finds itself alone (no other reachable members), it downs itself rather than continuing as a single-node cluster. This prevents the oldest node from running in isolation during a network partition while the younger node also forms its own cluster.
|
- **Stable-after duration**: 15 seconds of sustained unreachability before downing. This prevents premature downing during startup, rolling restarts, or transient network blips.
|
||||||
- **Why keep-oldest**: With only two nodes, quorum-based strategies (static-quorum, keep-majority) cannot distinguish "one node crashed" from "network partition" — both sides see fewer than quorum and both would down themselves, resulting in total cluster shutdown. Keep-oldest with `down-if-alone` provides safe singleton ownership — at most one node runs the cluster singleton at any time.
|
|
||||||
|
|
||||||
### Down-if-alone recovery
|
### Why not the alternatives (all verified against Akka.NET 1.5.62 source, 2026-07-21)
|
||||||
|
|
||||||
When a node downs itself (via `down-if-alone`, or any other SBR decision), the resolver is configured with `run-coordinated-shutdown-when-down = on`, so the self-down runs `CoordinatedShutdown` and **terminates that node's `ActorSystem`**. The Host process must not keep running with a dead actor system — it would serve nothing and be restarted by nobody. The recovery contract is:
|
- **keep-oldest** (used until 2026-07-21): partition-safe, but in a two-node cluster a crash of the **oldest** is a total outage. `KeepOldest.OldestDecision` only lets `down-if-alone` rescue the survivor when the surviving side has **≥ 2 members** (`otherSide == 1 && thisSide >= 2`); with 1-vs-1 the younger survivor takes `DownReachable` — it downs *itself*. Verified live on the docker rig: the survivor logged `SBR took decision Akka.Cluster.SBR.DownReachable … including myself`, exited, and looped on `InitJoin`. The strategy remains supported in `ClusterOptions` (`SplitBrainResolverStrategy: keep-oldest`) for deployments that prefer partition-safety over availability.
|
||||||
|
- **static-quorum (quorum-size 1)**: Akka's `IsTooManyMembers` guard (`members > quorum*2-1`, i.e. `2 > 1`) returns **DownAll** on any unreachability — total shutdown, strictly worse.
|
||||||
|
- **keep-majority**: a 1-vs-1 split keeps the side with the lowest address, which just moves the fatal crash from "the oldest" to "the lowest-address node".
|
||||||
|
- **lease-majority**: needs a shared lease store (Kubernetes API, SQL, …) reachable from both nodes — not available at sites (one node per VM, no shared infrastructure).
|
||||||
|
|
||||||
1. Self-down ⇒ `CoordinatedShutdown` ⇒ `ActorSystem` termination.
|
### Downed-node recovery
|
||||||
|
|
||||||
|
When a node is downed (auto-downed by its peer after a partition heals, or a keep-oldest self-down where that strategy is configured), `run-coordinated-shutdown-when-down = on` runs `CoordinatedShutdown` and **terminates that node's `ActorSystem`**. The Host process must not keep running with a dead actor system — it would serve nothing and be restarted by nobody. The recovery contract is:
|
||||||
|
|
||||||
|
1. Down ⇒ `CoordinatedShutdown` ⇒ `ActorSystem` termination.
|
||||||
2. The Host watches `ActorSystem.WhenTerminated`; a termination that is **not** the host's own `StopAsync` triggers `IHostApplicationLifetime.StopApplication()`, so **the process exits**.
|
2. The Host watches `ActorSystem.WhenTerminated`; a termination that is **not** the host's own `StopAsync` triggers `IHostApplicationLifetime.StopApplication()`, so **the process exits**.
|
||||||
3. The service supervisor restarts it — docker `restart: unless-stopped`, or Windows service recovery actions (`sc.exe failure … restart/…`).
|
3. The service supervisor restarts it — docker `restart: unless-stopped`, or Windows service recovery actions (`sc.exe failure … restart/…`).
|
||||||
4. The restarted process rejoins as a **fresh incarnation** (the keep-oldest resolver handles the rejoin cleanly; there is no stale membership to reconcile) — **but only while a peer still holding cluster state is reachable**. A lone restarted node that is *not* the first seed cannot re-form a cluster on its own (see the seed-node bootstrap constraint below); it waits for its peer.
|
4. The restarted process rejoins as a **fresh incarnation**, and can re-form the cluster on its own if no peer is reachable — see Seed Node Ordering below.
|
||||||
|
|
||||||
**Seed-node bootstrap constraint.** Only the FIRST seed listed in `Cluster:SeedNodes` may self-join to form a *new* cluster. All nodes list the same first seed (e.g. `scadabridge-central-a`), so a lone restarted non-first-seed node (with the first seed still down) loops on `InitJoin` forever — never `Up`, never routable. This is why the two-node keep-oldest **oldest/active-node crash is a total-outage gap**: after the oldest dies the younger survivor self-downs, and it cannot re-bootstrap alone. Recovery is operator-driven — either restart the dead first-seed node (preferred) or restart the survivor with a self-first seed override (`ScadaBridge__Cluster__SeedNodes__0` = self, `__1` = peer). The repo does not ship self-first ordering per node: with both nodes self-first a simultaneous cold start risks two independent one-node clusters that never merge. Removing the gap itself is the **registered deferred keep-oldest topology/strategy decision** (master tracker 2026-07-08, owner: user).
|
### Seed Node Ordering
|
||||||
|
|
||||||
The docker failover drill (`docker/failover-drill.sh`) exercises this per direction: `standby` mode proves SBR downing + singleton continuity on the oldest; `active` mode measures the registered total-outage gap and the recovery-on-restart path.
|
**Every node lists ITSELF as `seed-nodes[0]` and its partner second (decision 2026-07-22).**
|
||||||
|
|
||||||
|
Akka runs two different bootstrap processes depending on that first entry. When `seed-nodes[0]` is the node's own address it runs `FirstSeedNodeProcess`: it `InitJoin`s the *other* seeds and self-joins only after `seed-node-timeout` elapses with nobody answering. When it is not, the node runs `JoinSeedNodeProcess`, which can never form a new cluster — it retries `InitJoin` indefinitely.
|
||||||
|
|
||||||
|
Until 2026-07-22 every node listed the same first seed, so a node that had to **boot alone** — a cold start of only the non-first-seed VM, or the survivor crashing while its peer was still dead — never reached `Up` and was never routable. That was the **registered outage gap**, and recovery was operator-driven. Self-first ordering closes it using Akka's own protocol, and `StartupValidator` fails the boot if a node config ever breaks the ordering (the invariant is silent when violated, so it is enforced loudly).
|
||||||
|
|
||||||
|
Behavior, covered by `SelfFirstSeedBootstrapTests` (real in-process clusters built from `BuildHocon` at production failure-detection timings):
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Lone cold-start, peer dead | Self-joins after `seed-node-timeout` (~5s) — operational, unattended |
|
||||||
|
| Restart into a **live** peer | The peer answers `InitJoinAck`; the node joins the existing cluster and never islands |
|
||||||
|
| Both cold-start simultaneously, mutually reachable | The `InitJoin` handshake resolves it *before* either self-joins → **one** 2-member cluster |
|
||||||
|
| Both cold-start during a genuine boot-time **partition** | Each forms its own cluster — the same dual-active class `auto-down` already accepts, same recovery (restart one side) |
|
||||||
|
|
||||||
|
**Rejected alternative — an external self-form timer.** A watchdog that waited a configurable window for membership and then called `Cluster.Join(SelfAddress)` was implemented and discarded. Its success signal ("am I `Up` yet?") cannot distinguish *no seed answered* from *a seed answered and the join is in flight*, because it sits outside Akka's join handshake. On a routine standby restart the peer is alive but the join stalls behind removal of the restarting node's own stale incarnation (the peer must down it, then wait for the failure detector and a leader action); a `Join(self)` issued during `TryingToJoin` abandons the in-flight join and forms a second cluster at the same address. Measured: a permanent split that had not healed after 90s, converting a routine restart into an outage of the previously-healthy node. Akka's first-seed process has no such race because it is part of the handshake, which is why the ordering — not a timer — is the mechanism.
|
||||||
|
|
||||||
|
The docker failover drill (`docker/failover-drill.sh`) proves both directions: `standby` mode kills the younger node (active untouched, zero routing blips); `active` mode kills the active/oldest node and asserts the survivor **takes over while the victim is still down**.
|
||||||
|
|
||||||
|
### Manual Failover (admin-triggered)
|
||||||
|
|
||||||
|
An Administrator can swap the central pair's roles deliberately — for planned maintenance on the active node, or to move singletons off a node behaving badly without waiting for a crash. The control is the **Trigger failover** button on the central-cluster card of the Health dashboard (`/monitoring/health`).
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
|
||||||
|
- **Graceful `Leave`, never `Down`.** The active node leaves the cluster so `ClusterSingletonManager` hands its singletons to the standby before the member is removed. A `Down` would skip the hand-off.
|
||||||
|
- **Target = the oldest Up member with the `Central` role**, the same rule `ActiveNodeEvaluator` uses, so the node acted on is exactly the one hosting the singletons — never Akka's cluster *leader*, whose address-ordered definition diverges from singleton placement after a restart.
|
||||||
|
- **Peer guard.** Refused when fewer than two Up `Central` members exist: failing over a lone node is an outage, not a failover. Enforced server-side in `AkkaManualFailoverService`; the button is also disabled client-side when the pair has no online standby.
|
||||||
|
- **Admin-only** (`AuthorizationPolicies.RequireAdmin`). The Health page itself is all-roles, so the gate lives on the control.
|
||||||
|
- **Audited before acting.** One `AuditChannel.Cluster` / `AuditKind.ManualFailover` row is written through `ICentralAuditWriter` *before* the Leave is issued, naming the actor and the target address — the acting node can be the one that goes away, and an audit written afterwards could be lost to the shutdown it describes. Audit failure never blocks the failover.
|
||||||
|
- **Your own page will disconnect.** Traefik routes the UI to the active node, so triggering a failover drops the admin's Blazor circuit; it reconnects against the new active node. The confirmation dialog says so.
|
||||||
|
|
||||||
|
After the Leave the node's `ActorSystem` terminates, the `WhenTerminated` watchdog exits the process, the service supervisor restarts it, and it rejoins as the youngest member (the new standby). Recovery is the normal restart contract above — no interaction with seed ordering, since the peer is alive on this path.
|
||||||
|
|
||||||
|
#### Site-pair failover
|
||||||
|
|
||||||
|
The same control appears on each **site** card. Central and each site are separate Akka clusters, so central cannot act on a site's membership — it *asks*, over the existing ClusterClient command/control channel:
|
||||||
|
|
||||||
|
1. `CommunicationService.TriggerSiteFailoverAsync` sends a `TriggerSiteFailover` inside a `SiteEnvelope`.
|
||||||
|
2. The site's `SiteCommunicationActor` (registered per node, so contact rotation reaches whichever answers) resolves the target from cluster state and issues the graceful `Leave` locally.
|
||||||
|
3. It replies `SiteFailoverAck` — sent **before** the Leave takes effect, so the ack still arrives when the acking node is the one leaving.
|
||||||
|
|
||||||
|
Differences from central failover, all deliberate:
|
||||||
|
|
||||||
|
- **Role scope is `site-{SiteId}`, not the base `Site` role.** Site singletons (the Deployment Manager) are placed on the site-specific role; using the base role would move the wrong node. Pinned by both a unit test asserting the role string and a real-cluster test.
|
||||||
|
- **Misroute is refused.** A command whose `SiteId` does not match the receiving node's is rejected rather than acted on — a misroute must never fail over a site the operator did not select.
|
||||||
|
- **A fault is acked, not thrown.** Reporting through the ack keeps the reason; letting it reach supervision would restart the communication actor and reduce central's Ask to a bare timeout.
|
||||||
|
- **The operator's own session is unaffected** — a different cluster entirely. The confirmation dialog therefore does *not* carry the "this page will disconnect" warning that the central one does.
|
||||||
|
- **Refusal vs unreachable are distinct.** A `false` ack is a definitive answer from the site (peer guard, misroute); a timeout means the site never answered. Both surface to the operator with their own wording, because only one of them means "nothing happened".
|
||||||
|
|
||||||
|
**Rolling upgrade.** A site running a binary older than this contract has no handler for `TriggerSiteFailover`; the message dead-letters and central's Ask times out, reported as "site did not respond". That is the correct user-facing outcome — an old site genuinely cannot honour the request. Message evolution stays additive-only.
|
||||||
|
|
||||||
## Single-Node Operation
|
## Single-Node Operation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.Security
|
||||||
|
@inject IManualFailoverService Failover
|
||||||
|
@inject IDialogService Dialog
|
||||||
|
|
||||||
|
@* Admin-only manual failover of the central pair (decision 2026-07-22). Rendered on the
|
||||||
|
Health dashboard's central-cluster card. The page itself is all-roles, so the gate lives
|
||||||
|
here rather than on the page's [Authorize] attribute. *@
|
||||||
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||||
|
<span class="d-inline-flex align-items-center gap-2">
|
||||||
|
<button class="btn btn-outline-warning btn-sm"
|
||||||
|
disabled="@(!HasPeer || _busy)"
|
||||||
|
title="@(HasPeer
|
||||||
|
? (IsSite
|
||||||
|
? $"Gracefully restart site {SiteId}'s active node; its standby takes over."
|
||||||
|
: "Gracefully restart the active node; the standby takes over.")
|
||||||
|
: "No standby available — failing over a lone node would be an outage, not a failover.")"
|
||||||
|
@onclick="TriggerAsync">
|
||||||
|
@(_busy ? "Failing over…" : "Trigger failover")
|
||||||
|
</button>
|
||||||
|
@if (_message is not null)
|
||||||
|
{
|
||||||
|
<small class="@(_failed ? "text-danger" : "text-muted")" role="status">@_message</small>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>
|
||||||
|
/// Number of central nodes currently reporting online. Fewer than two means there is no
|
||||||
|
/// standby to take over, so the control is disabled — the guard is enforced again
|
||||||
|
/// server-side in the failover service, which is the authoritative check.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public int OnlineCentralNodeCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, this control fails over the named SITE pair instead of the central pair.
|
||||||
|
/// Sites are separate Akka clusters reached over the command/control relay, so the site
|
||||||
|
/// path cannot disturb the admin's own session — which is why the confirmation text
|
||||||
|
/// differs. <c>null</c> (the default) means the central pair.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? SiteId { get; set; }
|
||||||
|
|
||||||
|
private bool IsSite => !string.IsNullOrWhiteSpace(SiteId);
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState>? AuthState { get; set; }
|
||||||
|
|
||||||
|
private bool HasPeer => OnlineCentralNodeCount >= 2;
|
||||||
|
|
||||||
|
private bool _busy;
|
||||||
|
private bool _failed;
|
||||||
|
private string? _message;
|
||||||
|
|
||||||
|
private async Task TriggerAsync()
|
||||||
|
{
|
||||||
|
// Central: the admin is almost certainly connected THROUGH the node about to restart
|
||||||
|
// (Traefik routes to the active node), so the dialog must set that expectation or a
|
||||||
|
// working failover reads as a crash they caused.
|
||||||
|
// Site: a different cluster entirely — this page is unaffected, and claiming otherwise
|
||||||
|
// would train operators to ignore the warning that does matter.
|
||||||
|
var confirmed = IsSite
|
||||||
|
? await Dialog.ConfirmAsync(
|
||||||
|
$"Trigger failover for site {SiteId}?",
|
||||||
|
$"The active node of site {SiteId} will leave its cluster and restart; the site's "
|
||||||
|
+ "standby takes over and its Deployment Manager singleton hands over gracefully. "
|
||||||
|
+ "In-flight work on that site node is interrupted, and the site is briefly "
|
||||||
|
+ "unavailable while the handover completes.",
|
||||||
|
danger: true)
|
||||||
|
: await Dialog.ConfirmAsync(
|
||||||
|
"Trigger central failover?",
|
||||||
|
"The active central node will leave the cluster and restart; the standby takes over "
|
||||||
|
+ "and becomes active. Cluster singletons hand over gracefully, but in-flight work on "
|
||||||
|
+ "the active node is interrupted. This page is served by the active node, so it will "
|
||||||
|
+ "briefly disconnect and reconnect against the new active node.",
|
||||||
|
danger: true);
|
||||||
|
|
||||||
|
if (!confirmed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
_failed = false;
|
||||||
|
_message = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var actor = await ResolveActorAsync();
|
||||||
|
|
||||||
|
if (IsSite)
|
||||||
|
{
|
||||||
|
var outcome = await Failover.FailOverSiteAsync(SiteId!, actor);
|
||||||
|
if (outcome.Accepted)
|
||||||
|
{
|
||||||
|
_message = $"Failover triggered — {outcome.TargetAddress} is leaving the site cluster.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Carry the site's own words through: a peer-guard refusal and an
|
||||||
|
// unreachable site are different situations for the operator.
|
||||||
|
_failed = true;
|
||||||
|
_message = outcome.ErrorMessage ?? "Refused by the site.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var target = await Failover.FailOverCentralAsync(actor);
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
// The server-side peer guard refused. Never report a failover that did not happen.
|
||||||
|
_failed = true;
|
||||||
|
_message = "Refused: no standby available to take over.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_message = $"Failover triggered — {target} is leaving the cluster.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_failed = true;
|
||||||
|
_message = $"Failover failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Authenticated user name recorded on the audit row.</summary>
|
||||||
|
private async Task<string> ResolveActorAsync()
|
||||||
|
{
|
||||||
|
if (AuthState is null)
|
||||||
|
{
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = await AuthState;
|
||||||
|
return state.User.Identity?.Name
|
||||||
|
?? state.User.FindFirst(JwtTokenService.UsernameClaimType)?.Value
|
||||||
|
?? "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,12 +218,19 @@
|
|||||||
<small class="text-muted ms-2">offline since @changedAt.ToString("u")</small>
|
<small class="text-muted ms-2">offline since @changedAt.ToString("u")</small>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
@* Admin-only; the control gates itself, and disables when the pair has
|
||||||
|
no online standby to take over. Central acts on the local cluster;
|
||||||
|
a site is asked over the command/control relay. *@
|
||||||
|
<CentralFailoverControl OnlineCentralNodeCount="@OnlineNodeCount(state)"
|
||||||
|
SiteId="@(isCentral ? null : siteId)" />
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
|
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
|
||||||
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
|
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
|
||||||
| Seq: @state.LastSequenceNumber
|
| Seq: @state.LastSequenceNumber
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
@if (state.LatestReport != null)
|
@if (state.LatestReport != null)
|
||||||
{
|
{
|
||||||
@@ -442,6 +449,15 @@
|
|||||||
private string StaleTimeoutDisplay =>
|
private string StaleTimeoutDisplay =>
|
||||||
FormatDuration(HealthOptions.Value.MetricsStaleTimeout);
|
FormatDuration(HealthOptions.Value.MetricsStaleTimeout);
|
||||||
|
|
||||||
|
// Online central nodes, from the same ClusterNodes list the Nodes column renders.
|
||||||
|
// Fewer than two means no standby, which disables the manual-failover control. This is
|
||||||
|
// a display-side guard only — AkkaManualFailoverService re-checks against live cluster
|
||||||
|
// membership, which is the authoritative answer.
|
||||||
|
private static int OnlineNodeCount(SiteHealthState state) =>
|
||||||
|
state.LatestReport?.ClusterNodes is { Count: > 0 } nodes
|
||||||
|
? nodes.Count(n => n.IsOnline)
|
||||||
|
: (state.IsOnline ? 1 : 0);
|
||||||
|
|
||||||
private static string FormatDuration(TimeSpan span) =>
|
private static string FormatDuration(TimeSpan span) =>
|
||||||
span.TotalMinutes >= 1 && span == TimeSpan.FromMinutes(Math.Round(span.TotalMinutes))
|
span.TotalMinutes >= 1 && span == TimeSpan.FromMinutes(Math.Round(span.TotalMinutes))
|
||||||
? $"{span.TotalMinutes:0} minute{(span.TotalMinutes == 1 ? "" : "s")}"
|
? $"{span.TotalMinutes:0} minute{(span.TotalMinutes == 1 ? "" : "s")}"
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin-triggered manual failover of the central pair. Declared here — and in terms of
|
||||||
|
/// plain strings — so CentralUI stays Akka-free; the Akka implementation lives in the Host
|
||||||
|
/// (<c>AkkaManualFailoverService</c>) and is registered only in the Central branch.
|
||||||
|
/// </summary>
|
||||||
|
public interface IManualFailoverService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gracefully fails over the central cluster: the current active (oldest Up) member
|
||||||
|
/// leaves, restarts via its supervisor, and rejoins as standby. The Leave is graceful,
|
||||||
|
/// never a Down, so cluster singletons hand over instead of being killed.
|
||||||
|
/// <para>
|
||||||
|
/// The caller is usually connected THROUGH the node being failed over (Traefik routes
|
||||||
|
/// the UI to the active node), so the calling Blazor circuit should expect to drop and
|
||||||
|
/// reconnect against the new active node.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actor">Authenticated user name, recorded on the audit row.</param>
|
||||||
|
/// <returns>The address acted on, or <c>null</c> when there is no peer to fail over to
|
||||||
|
/// (failing over a lone node would be an outage, not a failover).</returns>
|
||||||
|
Task<string?> FailOverCentralAsync(string actor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks a SITE to gracefully fail over its own two-node pair. Central and each site are
|
||||||
|
/// separate Akka clusters, so this is a request relayed over the command/control channel —
|
||||||
|
/// the site performs the Leave itself and reports the outcome. Unlike central failover,
|
||||||
|
/// this does NOT disturb the caller's own UI session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="siteId">The site whose pair should fail over.</param>
|
||||||
|
/// <param name="actor">Authenticated user name, recorded on the audit row.</param>
|
||||||
|
/// <returns>The site's outcome — accepted with a target, or refused with a reason.</returns>
|
||||||
|
Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId, string actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of a site-pair failover request, in UI terms. Deliberately distinct from the wire
|
||||||
|
/// ack so CentralUI stays free of the Akka message contract.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Accepted"><c>true</c> when the site issued the graceful Leave.</param>
|
||||||
|
/// <param name="TargetAddress">Address of the leaving site node; <c>null</c> when refused.</param>
|
||||||
|
/// <param name="ErrorMessage">
|
||||||
|
/// Why the request was refused or failed. A refusal (peer guard, misroute) is a definitive
|
||||||
|
/// answer FROM the site; an unreachable site surfaces here as a timeout message. Both are
|
||||||
|
/// reported to the operator rather than being flattened into a generic failure.
|
||||||
|
/// </param>
|
||||||
|
public sealed record SiteFailoverOutcome(bool Accepted, string? TargetAddress, string? ErrorMessage);
|
||||||
@@ -31,19 +31,46 @@ public class ClusterOptions
|
|||||||
// when the binding sites can be updated in the same commit.
|
// when the binding sites can be updated in the same commit.
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Akka.NET cluster seed nodes. Both nodes are seed nodes — each node lists
|
/// Akka.NET cluster seed nodes. Both nodes are seed nodes — each node lists itself and its
|
||||||
/// itself and its partner — so either can start first and form the cluster.
|
/// partner.
|
||||||
|
/// <para>
|
||||||
|
/// <b>ORDER IS LOAD-BEARING (decision 2026-07-22): every node must list ITSELF first.</b>
|
||||||
|
/// Akka runs <c>FirstSeedNodeProcess</c> — the only bootstrap path that can form a NEW
|
||||||
|
/// cluster when no peer answers <c>InitJoin</c> — exclusively when <c>seed-nodes[0]</c> is
|
||||||
|
/// this node's own address; any other node runs <c>JoinSeedNodeProcess</c> and retries
|
||||||
|
/// <c>InitJoin</c> forever. So merely listing both nodes does NOT mean either can start
|
||||||
|
/// first: a node that lists its partner first can never cold-start while that partner is
|
||||||
|
/// down (the "registered outage gap", <c>docker/README.md</c>). Self-first ordering closes
|
||||||
|
/// it using Akka's own protocol, which — unlike an external self-form timer — is part of
|
||||||
|
/// the join handshake and so cannot mistake an in-flight join for an absent peer.
|
||||||
|
/// Enforced at boot by <c>StartupValidator</c>.
|
||||||
|
/// </para>
|
||||||
/// Must contain at least one entry.
|
/// Must contain at least one entry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> SeedNodes { get; set; } = new();
|
public List<string> SeedNodes { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split-brain resolver strategy. Must be <c>keep-oldest</c> for the two-node
|
/// Downing strategy for unreachable members. Two supported values:
|
||||||
/// clusters ScadaBridge uses: quorum strategies (<c>keep-majority</c>,
|
/// <list type="bullet">
|
||||||
/// <c>static-quorum</c>) cannot distinguish a crash from a partition with only
|
/// <item><c>auto-down</c> (default, decision 2026-07-21) — availability-first: each
|
||||||
/// two nodes and would shut down the whole cluster.
|
/// side downs the unreachable peer after <see cref="StableAfter"/>, so a hard crash
|
||||||
|
/// of EITHER node (oldest included) fails over to the survivor. The accepted trade:
|
||||||
|
/// a true network partition produces two live one-node clusters (dual-active) until
|
||||||
|
/// an operator restarts one side. Chosen because ScadaBridge pairs run one node per
|
||||||
|
/// VM with no shared lease infrastructure, and a stalled system is a bigger risk
|
||||||
|
/// than a rare partition.</item>
|
||||||
|
/// <item><c>keep-oldest</c> — partition-safe SBR: downs the side without the oldest
|
||||||
|
/// member. In a TWO-node cluster this makes a crash of the oldest/active node a
|
||||||
|
/// total outage: Akka's <c>down-if-alone</c> only rescues the survivor when its own
|
||||||
|
/// side has ≥2 members (verified against Akka.NET 1.5.62 <c>KeepOldest.Decide</c>
|
||||||
|
/// and live on the docker rig, 2026-07-21).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Other SBR strategies are rejected: <c>static-quorum</c> with quorum 1 hits Akka's
|
||||||
|
/// <c>IsTooManyMembers</c> guard (2 > 2*1-1) and downs ALL on any unreachability;
|
||||||
|
/// <c>keep-majority</c> just moves the fatal crash from the oldest to the
|
||||||
|
/// lowest-address node.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string SplitBrainResolverStrategy { get; set; } = "keep-oldest";
|
public string SplitBrainResolverStrategy { get; set; } = "auto-down";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Time the cluster membership must remain stable before the split-brain
|
/// Time the cluster membership must remain stable before the split-brain
|
||||||
@@ -71,9 +98,12 @@ public class ClusterOptions
|
|||||||
public int MinNrOfMembers { get; set; } = 1;
|
public int MinNrOfMembers { get; set; } = 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The keep-oldest resolver's <c>down-if-alone</c> flag. When <c>true</c> (the
|
/// The keep-oldest resolver's <c>down-if-alone</c> flag; only consulted when
|
||||||
/// design-doc requirement), the oldest node downs itself if it finds it has no
|
/// <see cref="SplitBrainResolverStrategy"/> is <c>keep-oldest</c>. When <c>true</c>,
|
||||||
/// other reachable members, rather than running as an isolated single-node cluster.
|
/// the oldest node downs itself if it finds it has no other reachable members,
|
||||||
|
/// rather than running as an isolated single-node cluster. Note that in a two-node
|
||||||
|
/// cluster this does NOT let the younger survivor take over from a crashed oldest —
|
||||||
|
/// Akka's alone-check requires the surviving side to have ≥2 members.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool DownIfAlone { get; set; } = true;
|
public bool DownIfAlone { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
|
public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
|
||||||
{
|
{
|
||||||
/// <summary>Split-brain resolver strategies safe for ScadaBridge's two-node clusters.</summary>
|
/// <summary>
|
||||||
|
/// Downing strategies supported for ScadaBridge's two-node clusters.
|
||||||
|
/// <c>auto-down</c> (default) survives a crash of either node at the accepted cost
|
||||||
|
/// of dual-active during a real partition; <c>keep-oldest</c> is partition-safe but
|
||||||
|
/// cannot survive a crash of the oldest node. Quorum strategies are rejected:
|
||||||
|
/// <c>static-quorum</c> quorum-size 1 trips Akka's IsTooManyMembers guard (DownAll
|
||||||
|
/// on any unreachability in a 2-node cluster) and <c>keep-majority</c> keys the
|
||||||
|
/// fatal crash to the lowest-address node instead of the oldest.
|
||||||
|
/// </summary>
|
||||||
private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
|
"auto-down",
|
||||||
"keep-oldest"
|
"keep-oldest"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,8 +46,9 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
|
|||||||
builder.RequireThat(
|
builder.RequireThat(
|
||||||
!string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy)
|
!string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy)
|
||||||
&& AllowedStrategies.Contains(options.SplitBrainResolverStrategy),
|
&& AllowedStrategies.Contains(options.SplitBrainResolverStrategy),
|
||||||
$"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
|
$"ClusterOptions.SplitBrainResolverStrategy must be 'auto-down' or 'keep-oldest' for a " +
|
||||||
$"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
|
$"two-node cluster; '{options.SplitBrainResolverStrategy}' would risk a total cluster " +
|
||||||
|
"shutdown on a partition or an unreachability event.");
|
||||||
|
|
||||||
builder.RequireThat(options.MinNrOfMembers == 1,
|
builder.RequireThat(options.MinNrOfMembers == 1,
|
||||||
$"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
|
$"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
|
||||||
@@ -58,7 +68,11 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
|
|||||||
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
|
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
|
||||||
"declared unreachable before a heartbeat can arrive.");
|
"declared unreachable before a heartbeat can arrive.");
|
||||||
|
|
||||||
builder.RequireThat(options.DownIfAlone,
|
// DownIfAlone is a keep-oldest knob; under auto-down each side downs the
|
||||||
|
// unreachable peer regardless, so the flag is inert and any value is fine.
|
||||||
|
var isKeepOldest = string.Equals(
|
||||||
|
options.SplitBrainResolverStrategy, "keep-oldest", StringComparison.OrdinalIgnoreCase);
|
||||||
|
builder.RequireThat(!isKeepOldest || options.DownIfAlone,
|
||||||
"ClusterOptions.DownIfAlone must be true for the keep-oldest resolver "
|
"ClusterOptions.DownIfAlone must be true for the keep-oldest resolver "
|
||||||
+ "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the "
|
+ "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the "
|
||||||
+ "oldest node can run as an isolated single-node cluster during a partition while the "
|
+ "oldest node can run as an isolated single-node cluster during a partition while the "
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central → site relay command: gracefully fail over the owning site's two-node cluster.
|
||||||
|
/// Sent over the command/control channel when an Administrator clicks "Trigger failover" on a
|
||||||
|
/// site card in the Central UI Health dashboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Central and each site are SEPARATE Akka clusters, so central cannot act on a site's
|
||||||
|
/// membership directly — it can only ask. The site's own <c>SiteCommunicationActor</c> performs
|
||||||
|
/// the <c>Cluster.Leave</c> against its site-specific <c>site-{SiteId}</c> role, which is the
|
||||||
|
/// role its singletons (the Deployment Manager) are scoped to.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Either site node may receive this: <c>SiteCommunicationActor</c> is registered per node
|
||||||
|
/// (not as a singleton), and ClusterClient contact rotation reaches whichever answers. That is
|
||||||
|
/// fine — <c>Cluster.Leave(address)</c> is valid from any member, and the target is resolved
|
||||||
|
/// from cluster state rather than from who received the message.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Rolling upgrade:</b> a site running a binary older than this contract has no handler for
|
||||||
|
/// this message; it becomes an unhandled message / dead letter and central's Ask times out.
|
||||||
|
/// The timeout is reported to the operator as "site did not respond", which is the correct
|
||||||
|
/// user-facing outcome — an old site genuinely cannot honour the request. Message evolution
|
||||||
|
/// stays additive-only.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="CorrelationId">Correlation id echoed on the ack.</param>
|
||||||
|
/// <param name="SiteId">The site whose pair should fail over. Carried explicitly so a
|
||||||
|
/// misrouted command is detectable at the site rather than silently acted on.</param>
|
||||||
|
public sealed record TriggerSiteFailover(
|
||||||
|
string CorrelationId,
|
||||||
|
string SiteId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site → central ack for a <see cref="TriggerSiteFailover"/> relay command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="CorrelationId">Correlation id of the originating relay command.</param>
|
||||||
|
/// <param name="Accepted">
|
||||||
|
/// <c>true</c> when the site resolved a target and issued the graceful Leave.
|
||||||
|
/// <c>false</c> is a definitive refusal from the site — most often the peer guard (fewer than
|
||||||
|
/// two Up members in the site role, so a failover would be an outage) or a site-id mismatch.
|
||||||
|
/// A <c>false</c> ack is NOT a transport failure and must be distinguished from an
|
||||||
|
/// unreachable-site timeout.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="TargetAddress">Address of the node that is leaving; <c>null</c> when refused.</param>
|
||||||
|
/// <param name="ErrorMessage">Reason for a refusal, or a fault message; <c>null</c> on success.</param>
|
||||||
|
public sealed record SiteFailoverAck(
|
||||||
|
string CorrelationId,
|
||||||
|
bool Accepted,
|
||||||
|
string? TargetAddress,
|
||||||
|
string? ErrorMessage);
|
||||||
@@ -3,7 +3,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Top-level Audit Log channel — the trust boundary the audited action crosses.
|
/// Top-level Audit Log channel — the trust boundary the audited action crosses.
|
||||||
/// One of: outbound API call, outbound DB write, notification send/deliver, inbound API request,
|
/// One of: outbound API call, outbound DB write, notification send/deliver, inbound API request,
|
||||||
/// or a two-person ("secured") write through its submit/approve/reject/execute lifecycle.
|
/// a two-person ("secured") write through its submit/approve/reject/execute lifecycle, or an
|
||||||
|
/// operator-initiated cluster topology action.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum AuditChannel
|
public enum AuditChannel
|
||||||
{
|
{
|
||||||
@@ -11,5 +12,14 @@ public enum AuditChannel
|
|||||||
DbOutbound,
|
DbOutbound,
|
||||||
Notification,
|
Notification,
|
||||||
ApiInbound,
|
ApiInbound,
|
||||||
SecuredWrite
|
SecuredWrite,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An operator-initiated change to cluster topology — currently only the admin-triggered
|
||||||
|
/// manual failover of the central pair. Distinct from the script trust boundary the other
|
||||||
|
/// channels describe: nothing here crosses into user script, but a human deliberately
|
||||||
|
/// restarted the active node, which is exactly the kind of act an audit log exists to
|
||||||
|
/// attribute. (decision 2026-07-22)
|
||||||
|
/// </summary>
|
||||||
|
Cluster
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,5 +40,14 @@ public enum AuditKind
|
|||||||
/// <c>EventId</c>, source site, and final error) so the loss is queryable in
|
/// <c>EventId</c>, source site, and final error) so the loss is queryable in
|
||||||
/// the Audit Log itself, not only in a rotating log file.
|
/// the Audit Log itself, not only in a rotating log file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
ReconciliationAbandoned
|
ReconciliationAbandoned,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An administrator triggered a manual failover of the central pair from the Health page:
|
||||||
|
/// the active (oldest Up) node was asked to leave the cluster gracefully so its singletons
|
||||||
|
/// hand over and the standby takes over. One row per invocation, written BEFORE the Leave
|
||||||
|
/// is issued (the acting node is usually not the one that goes away, but the audit must
|
||||||
|
/// survive either outcome). (decision 2026-07-22)
|
||||||
|
/// </summary>
|
||||||
|
ManualFailover
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
|||||||
/// do not need a real cluster.
|
/// do not need a real cluster.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Func<bool> _isActiveCheck;
|
private readonly Func<bool> _isActiveCheck;
|
||||||
|
private readonly Func<string, string?> _failOverRole;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reference to the local Deployment Manager singleton proxy.
|
/// Reference to the local Deployment Manager singleton proxy.
|
||||||
@@ -76,12 +77,14 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
|||||||
string siteId,
|
string siteId,
|
||||||
CommunicationOptions options,
|
CommunicationOptions options,
|
||||||
IActorRef deploymentManagerProxy,
|
IActorRef deploymentManagerProxy,
|
||||||
Func<bool>? isActiveCheck = null)
|
Func<bool>? isActiveCheck = null,
|
||||||
|
Func<string, string?>? failOverRole = null)
|
||||||
{
|
{
|
||||||
_siteId = siteId;
|
_siteId = siteId;
|
||||||
_options = options;
|
_options = options;
|
||||||
_deploymentManagerProxy = deploymentManagerProxy;
|
_deploymentManagerProxy = deploymentManagerProxy;
|
||||||
_isActiveCheck = isActiveCheck ?? DefaultIsActiveCheck;
|
_isActiveCheck = isActiveCheck ?? DefaultIsActiveCheck;
|
||||||
|
_failOverRole = failOverRole ?? DefaultFailOverRole;
|
||||||
|
|
||||||
// Registration
|
// Registration
|
||||||
Receive<RegisterCentralClient>(msg =>
|
Receive<RegisterCentralClient>(msg =>
|
||||||
@@ -263,6 +266,14 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Central→site manual failover relay. Central and the site are separate clusters,
|
||||||
|
// so central can only ask — this node performs the graceful Leave locally, scoped to
|
||||||
|
// the SITE-SPECIFIC role, because that is what site singletons (the Deployment
|
||||||
|
// Manager) are placed on. Either node may receive this (the actor is per-node, not a
|
||||||
|
// singleton, and contact rotation picks whichever answers); the target is resolved
|
||||||
|
// from cluster state, not from who received the message.
|
||||||
|
Receive<TriggerSiteFailover>(HandleTriggerSiteFailover);
|
||||||
|
|
||||||
// Notification Outbox: forward a buffered notification submitted by the site
|
// Notification Outbox: forward a buffered notification submitted by the site
|
||||||
// Store-and-Forward Engine to the central cluster. The original Sender (the
|
// Store-and-Forward Engine to the central cluster. The original Sender (the
|
||||||
// S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the
|
// S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the
|
||||||
@@ -517,6 +528,69 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
|||||||
private bool DefaultIsActiveCheck() =>
|
private bool DefaultIsActiveCheck() =>
|
||||||
ClusterState.ActiveNodeEvaluator.SelfIsOldestUp(Cluster.Get(Context.System));
|
ClusterState.ActiveNodeEvaluator.SelfIsOldestUp(Cluster.Get(Context.System));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a central-initiated site failover. Refuses a command addressed to a different
|
||||||
|
/// site (a misroute must never silently fail over a site the operator did not select) and
|
||||||
|
/// refuses when the site pair has no peer to take over. The ack is sent BEFORE the Leave
|
||||||
|
/// takes effect on the wire, so it still reaches central even when this node is the one
|
||||||
|
/// leaving.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleTriggerSiteFailover(TriggerSiteFailover msg)
|
||||||
|
{
|
||||||
|
if (!string.Equals(msg.SiteId, _siteId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_log.Warning(
|
||||||
|
"Refusing TriggerSiteFailover addressed to site {Requested}; this node serves {Actual}",
|
||||||
|
msg.SiteId, _siteId);
|
||||||
|
Sender.Tell(new SiteFailoverAck(
|
||||||
|
msg.CorrelationId, Accepted: false, TargetAddress: null,
|
||||||
|
ErrorMessage: $"Command addressed to site '{msg.SiteId}' but this node serves '{_siteId}'."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var role = $"site-{_siteId}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var target = _failOverRole(role);
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
_log.Warning(
|
||||||
|
"Refusing TriggerSiteFailover for {SiteId}: fewer than 2 Up members in role {Role}, "
|
||||||
|
+ "so there is no standby to take over", _siteId, role);
|
||||||
|
Sender.Tell(new SiteFailoverAck(
|
||||||
|
msg.CorrelationId, Accepted: false, TargetAddress: null,
|
||||||
|
ErrorMessage: "No standby available — failing over a lone node would be an outage."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.Warning(
|
||||||
|
"Manual failover requested by central for site {SiteId}: {Target} is leaving the "
|
||||||
|
+ "site cluster gracefully; its singletons hand over to the standby.", _siteId, target);
|
||||||
|
Sender.Tell(new SiteFailoverAck(msg.CorrelationId, Accepted: true, target, ErrorMessage: null));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A fault here must be reported to the operator, not thrown into supervision —
|
||||||
|
// restarting the communication actor would drop central's Ask into a timeout and
|
||||||
|
// lose the reason.
|
||||||
|
_log.Error(ex, "TriggerSiteFailover for {SiteId} faulted", _siteId);
|
||||||
|
Sender.Tell(new SiteFailoverAck(
|
||||||
|
msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production failover action: gracefully Leave the oldest Up member carrying
|
||||||
|
/// <paramref name="role"/>, via the shared <see cref="ClusterState.ClusterFailoverCoordinator"/>
|
||||||
|
/// so the central and site paths cannot drift. Injected in tests for the same reason
|
||||||
|
/// <see cref="DefaultIsActiveCheck"/> is — a real Leave needs Akka.Cluster in the
|
||||||
|
/// ActorSystem, which the TestKit system does not load.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="role">Site-specific role scope.</param>
|
||||||
|
/// <returns>Address of the leaving node, or null when there is no peer.</returns>
|
||||||
|
private string? DefaultFailOverRole(string role) =>
|
||||||
|
ClusterState.ClusterFailoverCoordinator.FailOverOldest(Context.System, role)?.ToString();
|
||||||
|
|
||||||
// ── Internal messages ──
|
// ── Internal messages ──
|
||||||
|
|
||||||
internal record SendHeartbeat;
|
internal record SendHeartbeat;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deliberate, operator-initiated failover of a two-node cluster: the current active member
|
||||||
|
/// leaves GRACEFULLY so <c>ClusterSingletonManager</c> hands its singletons to the survivor.
|
||||||
|
///
|
||||||
|
/// <para>Lives beside <see cref="ActiveNodeEvaluator"/> — the two share one definition of
|
||||||
|
/// "active" (oldest Up member in a role scope), and they must not drift: the node asked to
|
||||||
|
/// leave has to be exactly the node hosting the singletons. Placed in Communication rather
|
||||||
|
/// than Host because BOTH sides need it — the central pair fails over via the Host's
|
||||||
|
/// <c>AkkaManualFailoverService</c>, and a site pair fails over inside
|
||||||
|
/// <c>SiteCommunicationActor</c>, which cannot reference Host.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Leave, never Down.</b> A Down skips singleton hand-off and hands the outcome to
|
||||||
|
/// the downing strategy — the wrong tool for a planned role swap.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class ClusterFailoverCoordinator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Asks the oldest Up member carrying <paramref name="role"/> to leave the cluster.
|
||||||
|
/// Mirrors <see cref="ActiveNodeEvaluator.SelfIsOldestUp"/>'s rule, so the node acted on is
|
||||||
|
/// the singleton host — never Akka's cluster <i>leader</i>, whose address-ordered definition
|
||||||
|
/// diverges from singleton placement once the original first node restarts and rejoins.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="system">Actor system whose cluster is acted on.</param>
|
||||||
|
/// <param name="role">Role scope. Central uses <c>Central</c>; a site pair uses its
|
||||||
|
/// site-specific <c>site-{SiteId}</c> role, since site singletons are scoped to that role.</param>
|
||||||
|
/// <param name="dryRun">Resolve and return the target WITHOUT issuing the Leave. Used to
|
||||||
|
/// name the target in an audit row before acting, and to answer "would this work?".</param>
|
||||||
|
/// <returns>The address that leaves (or would leave), or <c>null</c> when fewer than two Up
|
||||||
|
/// members carry the role — failing over a lone node is an outage, not a failover.</returns>
|
||||||
|
public static Address? FailOverOldest(ActorSystem system, string role, bool dryRun = false)
|
||||||
|
{
|
||||||
|
var cluster = Akka.Cluster.Cluster.Get(system);
|
||||||
|
var withRole = cluster.State.Members
|
||||||
|
.Where(m => m.Status == MemberStatus.Up && m.HasRole(role))
|
||||||
|
.OrderBy(m => m, Member.AgeOrdering)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (withRole.Count < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldest = withRole[0];
|
||||||
|
if (!dryRun)
|
||||||
|
{
|
||||||
|
cluster.Leave(oldest.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldest.Address;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -809,6 +809,34 @@ public class CommunicationService
|
|||||||
return await GetSiteCallAudit().Ask<DiscardSiteCallResponse>(
|
return await GetSiteCallAudit().Ask<DiscardSiteCallResponse>(
|
||||||
request, _options.QueryTimeout, cancellationToken);
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks a site to gracefully fail over its own two-node pair (Task 10). Central and each
|
||||||
|
/// site are SEPARATE Akka clusters, so central cannot act on site membership directly —
|
||||||
|
/// the site's own <c>SiteCommunicationActor</c> performs the <c>Cluster.Leave</c> against
|
||||||
|
/// its site-specific role and acks the outcome.
|
||||||
|
/// <para>
|
||||||
|
/// A site running a binary older than the <see cref="TriggerSiteFailover"/> contract has no
|
||||||
|
/// handler for it, so the message dead-letters and this Ask times out. That surfaces to the
|
||||||
|
/// operator as "site did not respond", which is the honest outcome — an old site genuinely
|
||||||
|
/// cannot honour the request.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="siteId">Target site.</param>
|
||||||
|
/// <param name="correlationId">Correlation id echoed on the ack.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The site's ack.</returns>
|
||||||
|
public async Task<SiteFailoverAck> TriggerSiteFailoverAsync(
|
||||||
|
string siteId, string correlationId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Relaying TriggerSiteFailover to site {SiteId}, correlationId={CorrelationId}",
|
||||||
|
siteId, correlationId);
|
||||||
|
|
||||||
|
var envelope = new SiteEnvelope(siteId, new TriggerSiteFailover(correlationId, siteId));
|
||||||
|
return await GetActor().Ask<SiteFailoverAck>(
|
||||||
|
envelope, _options.QueryTimeout, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+4
@@ -20,6 +20,10 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||||
|
<!-- Direct pin of a transitive dependency of Microsoft.AspNetCore.DataProtection:
|
||||||
|
10.0.7 carries four NU1903 high-severity advisories that break fresh restores
|
||||||
|
under TreatWarningsAsErrors. See Directory.Packages.props for the rationale. -->
|
||||||
|
<PackageReference Include="System.Security.Cryptography.Xml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -200,8 +200,9 @@ public class AkkaHostedService : IHostedService
|
|||||||
_communicationOptions.TransportHeartbeatInterval.TotalSeconds,
|
_communicationOptions.TransportHeartbeatInterval.TotalSeconds,
|
||||||
_communicationOptions.TransportFailureThreshold.TotalSeconds);
|
_communicationOptions.TransportFailureThreshold.TotalSeconds);
|
||||||
|
|
||||||
// Down-if-alone recovery watchdog: SBR's keep-oldest down-if-alone plus
|
// Downed-node recovery watchdog: any downing decision against this node
|
||||||
// run-coordinated-shutdown-when-down means a self-downed node terminates
|
// (auto-down by the peer, or an SBR self-down under keep-oldest) plus
|
||||||
|
// run-coordinated-shutdown-when-down means a downed node terminates
|
||||||
// its own ActorSystem. If that happens outside our StopAsync, the Host
|
// its own ActorSystem. If that happens outside our StopAsync, the Host
|
||||||
// process must exit so the service supervisor (docker
|
// process must exit so the service supervisor (docker
|
||||||
// `restart: unless-stopped` / Windows service recovery) restarts it and
|
// `restart: unless-stopped` / Windows service recovery) restarts it and
|
||||||
@@ -228,14 +229,21 @@ public class AkkaHostedService : IHostedService
|
|||||||
/// seed-node URI, role or split-brain strategy containing a quote, backslash or
|
/// seed-node URI, role or split-brain strategy containing a quote, backslash or
|
||||||
/// whitespace cannot corrupt the document or be silently misparsed.
|
/// whitespace cannot corrupt the document or be silently misparsed.
|
||||||
///
|
///
|
||||||
/// The <c>keep-oldest down-if-alone</c> flag is emitted from
|
/// The downing block branches on <see cref="ClusterOptions.SplitBrainResolverStrategy"/>:
|
||||||
|
/// <c>auto-down</c> (default; decision 2026-07-21) installs Akka's
|
||||||
|
/// <c>AutoDowning</c> provider with <c>auto-down-unreachable-after</c> =
|
||||||
|
/// <see cref="ClusterOptions.StableAfter"/> — the leader among the REACHABLE members
|
||||||
|
/// downs the unreachable peer, so a crash of either node (oldest included) fails
|
||||||
|
/// over to the survivor; the accepted trade is dual-active during a real network
|
||||||
|
/// partition. Any other value takes the SBR path, where the
|
||||||
|
/// <c>keep-oldest down-if-alone</c> flag is emitted from
|
||||||
/// <see cref="ClusterOptions.DownIfAlone"/> rather than hard-coded, so the bound
|
/// <see cref="ClusterOptions.DownIfAlone"/> rather than hard-coded, so the bound
|
||||||
/// configuration value is actually consumed.
|
/// configuration value is actually consumed.
|
||||||
///
|
///
|
||||||
/// The split-brain-resolver <c>downing-provider-class</c> is installed
|
/// A <c>downing-provider-class</c> is always installed explicitly: Akka defaults
|
||||||
/// explicitly: Akka defaults to <c>NoDowning</c>, under which the entire
|
/// to <c>NoDowning</c>, under which the downing configuration is inert and
|
||||||
/// split-brain-resolver section is inert and singletons never migrate on a hard
|
/// singletons never migrate on a hard crash or partition. Naming the provider is
|
||||||
/// crash or partition. Naming the SBR provider is what activates automatic downing.
|
/// what activates automatic downing.
|
||||||
///
|
///
|
||||||
/// Every duration is rendered via <see cref="DurationHocon"/> in
|
/// Every duration is rendered via <see cref="DurationHocon"/> in
|
||||||
/// milliseconds, so sub-second cluster timing values (e.g. a 750ms heartbeat) are
|
/// milliseconds, so sub-second cluster timing values (e.g. a 750ms heartbeat) are
|
||||||
@@ -258,6 +266,25 @@ public class AkkaHostedService : IHostedService
|
|||||||
clusterOptions.SeedNodes.Select(QuoteHocon));
|
clusterOptions.SeedNodes.Select(QuoteHocon));
|
||||||
var rolesStr = string.Join(",", roles.Select(QuoteHocon));
|
var rolesStr = string.Join(",", roles.Select(QuoteHocon));
|
||||||
|
|
||||||
|
// auto-down (default): AutoDowning provider — the leader among the reachable
|
||||||
|
// members downs the unreachable peer after StableAfter, so a crash of EITHER
|
||||||
|
// node fails over to the survivor (dual-active during a real partition is the
|
||||||
|
// accepted trade — decision 2026-07-21). Anything else: the SBR provider with
|
||||||
|
// the configured active-strategy (keep-oldest), which is partition-safe but
|
||||||
|
// cannot survive a crash of the oldest node in a two-node cluster.
|
||||||
|
var downingBlock = string.Equals(
|
||||||
|
clusterOptions.SplitBrainResolverStrategy, "auto-down", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $@"downing-provider-class = ""Akka.Cluster.AutoDowning, Akka.Cluster""
|
||||||
|
auto-down-unreachable-after = {DurationHocon(clusterOptions.StableAfter)}"
|
||||||
|
: $@"downing-provider-class = ""Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster""
|
||||||
|
split-brain-resolver {{
|
||||||
|
active-strategy = {QuoteHocon(clusterOptions.SplitBrainResolverStrategy)}
|
||||||
|
stable-after = {DurationHocon(clusterOptions.StableAfter)}
|
||||||
|
keep-oldest {{
|
||||||
|
down-if-alone = {(clusterOptions.DownIfAlone ? "on" : "off")}
|
||||||
|
}}
|
||||||
|
}}";
|
||||||
|
|
||||||
return $@"
|
return $@"
|
||||||
audit-telemetry-dispatcher {{
|
audit-telemetry-dispatcher {{
|
||||||
type = ForkJoinDispatcher
|
type = ForkJoinDispatcher
|
||||||
@@ -287,14 +314,7 @@ akka {{
|
|||||||
seed-nodes = [{seedNodesStr}]
|
seed-nodes = [{seedNodesStr}]
|
||||||
roles = [{rolesStr}]
|
roles = [{rolesStr}]
|
||||||
min-nr-of-members = {clusterOptions.MinNrOfMembers}
|
min-nr-of-members = {clusterOptions.MinNrOfMembers}
|
||||||
downing-provider-class = ""Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster""
|
{downingBlock}
|
||||||
split-brain-resolver {{
|
|
||||||
active-strategy = {QuoteHocon(clusterOptions.SplitBrainResolverStrategy)}
|
|
||||||
stable-after = {DurationHocon(clusterOptions.StableAfter)}
|
|
||||||
keep-oldest {{
|
|
||||||
down-if-alone = {(clusterOptions.DownIfAlone ? "on" : "off")}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
failure-detector {{
|
failure-detector {{
|
||||||
heartbeat-interval = {DurationHocon(clusterOptions.HeartbeatInterval)}
|
heartbeat-interval = {DurationHocon(clusterOptions.HeartbeatInterval)}
|
||||||
acceptable-heartbeat-pause = {DurationHocon(clusterOptions.FailureDetectionThreshold)}
|
acceptable-heartbeat-pause = {DurationHocon(clusterOptions.FailureDetectionThreshold)}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="IManualFailoverService"/> backed by the running Akka.NET cluster.
|
||||||
|
/// Registered only in the Central-role branch of <c>Program.cs</c>.
|
||||||
|
///
|
||||||
|
/// <para><b>Leave, never Down.</b> The target is asked to leave gracefully, so
|
||||||
|
/// <c>ClusterSingletonManager</c> hands its singletons to the survivor before the member is
|
||||||
|
/// removed. A <c>Down</c> would skip that hand-off and leave the pair to the downing strategy
|
||||||
|
/// — the wrong tool for a deliberate, planned role swap.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>The target is the oldest Up member, not the leader.</b> That mirrors
|
||||||
|
/// <c>ActiveNodeEvaluator</c>'s rule, which is where the singletons actually live; Akka's
|
||||||
|
/// cluster leadership is address-ordered and diverges from singleton placement after a
|
||||||
|
/// restart (review 01 [High]).</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Audit before acting.</b> The row is written before the Leave is issued. The node
|
||||||
|
/// serving this call is usually NOT the one leaving, but it can be (an admin routed to the
|
||||||
|
/// active node fails that node over), and an audit written afterwards could be lost to the
|
||||||
|
/// very shutdown it describes.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AkkaManualFailoverService : IManualFailoverService
|
||||||
|
{
|
||||||
|
private readonly AkkaHostedService _akka;
|
||||||
|
private readonly ICentralAuditWriter _audit;
|
||||||
|
private readonly CommunicationService _communication;
|
||||||
|
private readonly ILogger<AkkaManualFailoverService> _logger;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new <see cref="AkkaManualFailoverService"/>.</summary>
|
||||||
|
/// <param name="akka">The Akka hosted service exposing the cluster's actor system.</param>
|
||||||
|
/// <param name="audit">Central direct-write audit writer.</param>
|
||||||
|
/// <param name="communication">Central→site command/control transport, used for site failover.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public AkkaManualFailoverService(
|
||||||
|
AkkaHostedService akka,
|
||||||
|
ICentralAuditWriter audit,
|
||||||
|
CommunicationService communication,
|
||||||
|
ILogger<AkkaManualFailoverService> logger)
|
||||||
|
{
|
||||||
|
_akka = akka;
|
||||||
|
_audit = audit;
|
||||||
|
_communication = communication;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string?> FailOverCentralAsync(string actor)
|
||||||
|
{
|
||||||
|
var system = _akka.GetOrCreateActorSystem();
|
||||||
|
|
||||||
|
// Resolve first so the audit row can name the target, and so the peer guard rejects
|
||||||
|
// before anything observable happens.
|
||||||
|
var target = FailOverCore(system, role: CentralRole, dryRun: true);
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Manual failover requested by {Actor} but refused: fewer than 2 Up '{Role}' members, "
|
||||||
|
+ "so there is no standby to take over.", actor, CentralRole);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteAuditAsync(actor, target);
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Manual failover triggered by {Actor}: {Target} is leaving the cluster gracefully; "
|
||||||
|
+ "its singletons hand over to the standby, it restarts via its supervisor and rejoins "
|
||||||
|
+ "as the youngest member.", actor, target);
|
||||||
|
|
||||||
|
FailOverCore(system, role: CentralRole);
|
||||||
|
return target.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId, string actor)
|
||||||
|
{
|
||||||
|
// Audit BEFORE relaying, for the same reason as central: the row must exist even if
|
||||||
|
// the outcome is never observed. Unlike central, the acting node is never the one
|
||||||
|
// leaving — but a relay can still time out, and an un-acked request that DID take
|
||||||
|
// effect at the site would otherwise be unattributed.
|
||||||
|
await WriteAuditAsync(actor, target: siteId, sourceSiteId: siteId);
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Manual site failover triggered by {Actor} for site {SiteId}; relaying to the site cluster.",
|
||||||
|
actor, siteId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ack = await _communication.TriggerSiteFailoverAsync(siteId, Guid.NewGuid().ToString());
|
||||||
|
return new SiteFailoverOutcome(ack.Accepted, ack.TargetAddress, ack.ErrorMessage);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Central never buffers for an unreachable site — the Ask simply times out. Report
|
||||||
|
// that distinctly from a refusal, which is a definitive answer FROM the site.
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Manual site failover for {SiteId} did not get an ack from the site.", siteId);
|
||||||
|
return new SiteFailoverOutcome(
|
||||||
|
Accepted: false,
|
||||||
|
TargetAddress: null,
|
||||||
|
ErrorMessage: $"Site did not respond: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Akka role scoping central-cluster membership.</summary>
|
||||||
|
private const string CentralRole = "Central";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Oldest Up member with the role leaves. Delegates to
|
||||||
|
/// <see cref="ClusterFailoverCoordinator.FailOverOldest"/>, which lives in Communication so
|
||||||
|
/// the site-pair failover path (inside <c>SiteCommunicationActor</c>, which cannot reference
|
||||||
|
/// Host) shares one implementation of the rule.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="system">The actor system whose cluster is acted on.</param>
|
||||||
|
/// <param name="role">Role scope for membership.</param>
|
||||||
|
/// <param name="dryRun">When true, resolve and return the target without issuing the Leave.</param>
|
||||||
|
/// <returns>The address that leaves (or would leave), or null when there is no peer.</returns>
|
||||||
|
public static Address? FailOverCore(ActorSystem system, string role, bool dryRun = false)
|
||||||
|
=> ClusterFailoverCoordinator.FailOverOldest(system, role, dryRun);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort audit row. Audit failure must never block the failover the admin asked for
|
||||||
|
/// — the same rule the rest of the Audit Log follows (audit is best-effort; the
|
||||||
|
/// user-facing action's own success path is authoritative).
|
||||||
|
/// </summary>
|
||||||
|
private Task WriteAuditAsync(string actor, Address target)
|
||||||
|
=> WriteAuditAsync(actor, target.ToString(), sourceSiteId: null);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="WriteAuditAsync(string, Address)"/>
|
||||||
|
private async Task WriteAuditAsync(string actor, string target, string? sourceSiteId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
|
channel: AuditChannel.Cluster,
|
||||||
|
kind: AuditKind.ManualFailover,
|
||||||
|
status: AuditStatus.Submitted,
|
||||||
|
actor: actor,
|
||||||
|
target: target,
|
||||||
|
sourceSiteId: sourceSiteId,
|
||||||
|
extra: JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
target,
|
||||||
|
// Central rows name the Central role; site rows name the site, so a query
|
||||||
|
// can tell which pair an operator moved.
|
||||||
|
scope = sourceSiteId is null ? CentralRole : $"site-{sourceSiteId}"
|
||||||
|
}));
|
||||||
|
|
||||||
|
await _audit.WriteAsync(evt);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Best-effort manual-failover audit emission failed (actor={Actor}, target={Target}); "
|
||||||
|
+ "the failover itself proceeds.", actor, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -329,6 +329,12 @@ try
|
|||||||
// which node is active.
|
// which node is active.
|
||||||
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.InboundAPI.IActiveNodeGate, ActiveNodeGate>();
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.InboundAPI.IActiveNodeGate, ActiveNodeGate>();
|
||||||
|
|
||||||
|
// Admin-triggered manual failover of the central pair (Health page control,
|
||||||
|
// decision 2026-07-22). Central-only: the seam is declared in CentralUI so that
|
||||||
|
// project stays Akka-free, and only this branch has a cluster to act on.
|
||||||
|
builder.Services.AddSingleton<
|
||||||
|
ZB.MOM.WW.ScadaBridge.CentralUI.Services.IManualFailoverService, AkkaManualFailoverService>();
|
||||||
|
|
||||||
// Cluster node status provider scoped to the Central role — feeds the
|
// Cluster node status provider scoped to the Central role — feeds the
|
||||||
// CentralHealthReportLoop so the central cluster appears on /monitoring/health.
|
// CentralHealthReportLoop so the central cluster appears on /monitoring/health.
|
||||||
builder.Services.AddSingleton<IClusterNodeProvider>(sp =>
|
builder.Services.AddSingleton<IClusterNodeProvider>(sp =>
|
||||||
|
|||||||
@@ -93,6 +93,22 @@ public static class StartupValidator
|
|||||||
.Require("ScadaBridge:Cluster:SeedNodes",
|
.Require("ScadaBridge:Cluster:SeedNodes",
|
||||||
_ => seedNodes != null && seedNodes.Count >= 2,
|
_ => seedNodes != null && seedNodes.Count >= 2,
|
||||||
"must have at least 2 entries")
|
"must have at least 2 entries")
|
||||||
|
// Self-first seed ordering (decision 2026-07-22). Akka runs FirstSeedNodeProcess —
|
||||||
|
// the ONLY bootstrap path that can form a new cluster when no peer answers InitJoin —
|
||||||
|
// exclusively when seed-nodes[0] is this node's own address. Every other node runs
|
||||||
|
// JoinSeedNodeProcess and retries InitJoin forever, so a node listing its partner
|
||||||
|
// first cannot cold-start alone: that is the "registered outage gap"
|
||||||
|
// (docker/README.md), and it is a silent failure at boot rather than a loud one.
|
||||||
|
// Enforced here rather than in ClusterOptionsValidator because only this validator
|
||||||
|
// sees both the node identity and the seed list.
|
||||||
|
.Require("ScadaBridge:Cluster:SeedNodes",
|
||||||
|
_ => seedNodes is not { Count: >= 1 }
|
||||||
|
|| SeedNodeIsSelf(seedNodes[0], nodeSection["NodeHostname"], port),
|
||||||
|
"must list this node itself first: seed-nodes[0] has to be this node's own "
|
||||||
|
+ $"'akka.tcp://scadabridge@{nodeSection["NodeHostname"]}:{port}'. Akka only lets "
|
||||||
|
+ "seed-nodes[0] form a new cluster, so with the partner listed first this node "
|
||||||
|
+ "can never start while its peer is down (Component-ClusterInfrastructure.md → "
|
||||||
|
+ "Seed Node Ordering)")
|
||||||
// The big Site-only block: GrpcPort/MetricsPort validity + cross-field
|
// The big Site-only block: GrpcPort/MetricsPort validity + cross-field
|
||||||
// collisions + seed-node-port loop, in the original order.
|
// collisions + seed-node-port loop, in the original order.
|
||||||
.When(role == "Site", p =>
|
.When(role == "Site", p =>
|
||||||
@@ -155,4 +171,33 @@ public static class StartupValidator
|
|||||||
|
|
||||||
return int.TryParse(seedNode[(lastColon + 1)..], out var port) ? port : -1;
|
return int.TryParse(seedNode[(lastColon + 1)..], out var port) ? port : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the host from an Akka seed-node address of the form
|
||||||
|
/// <c>akka.tcp://system@host:port</c>. Returns an empty string when no host can be parsed.
|
||||||
|
/// </summary>
|
||||||
|
private static string SeedNodeHost(string seedNode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(seedNode))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var at = seedNode.LastIndexOf('@');
|
||||||
|
var lastColon = seedNode.LastIndexOf(':');
|
||||||
|
if (at < 0 || lastColon <= at)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return seedNode[(at + 1)..lastColon];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="seedNode"/> addresses this node itself (host AND port).
|
||||||
|
/// Host comparison is case-insensitive because DNS names are; it is otherwise exact —
|
||||||
|
/// Akka does no DNS canonicalisation either, so <c>node-a</c> and
|
||||||
|
/// <c>node-a.example.com</c> are genuinely different seed identities to the cluster.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SeedNodeIsSelf(string seedNode, string? nodeHostname, int remotingPort)
|
||||||
|
{
|
||||||
|
return SeedNodePort(seedNode) == remotingPort
|
||||||
|
&& string.Equals(SeedNodeHost(seedNode), nodeHostname, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"akka.tcp://scadabridge@localhost:8081",
|
"akka.tcp://scadabridge@localhost:8081",
|
||||||
"akka.tcp://scadabridge@localhost:8082"
|
"akka.tcp://scadabridge@localhost:8082"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"akka.tcp://scadabridge@localhost:8082",
|
"akka.tcp://scadabridge@localhost:8082",
|
||||||
"akka.tcp://scadabridge@localhost:8085"
|
"akka.tcp://scadabridge@localhost:8085"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "auto-down",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for the admin "Trigger failover" control on the Health dashboard
|
||||||
|
/// (decision 2026-07-22). The control is destructive and privileged, so the three
|
||||||
|
/// things that must hold are: only an Administrator sees it, it is refused when
|
||||||
|
/// there is no standby to take over, and it never fires without an explicit
|
||||||
|
/// confirmation.
|
||||||
|
/// </summary>
|
||||||
|
public class HealthFailoverButtonTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly IManualFailoverService _failover = Substitute.For<IManualFailoverService>();
|
||||||
|
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
|
||||||
|
|
||||||
|
private void Arrange(string role)
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
|
new Claim(JwtTokenService.RoleClaimType, role)
|
||||||
|
};
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||||
|
// BunitContext pre-registers a placeholder IAuthorizationService that throws when
|
||||||
|
// AuthorizeView evaluates a policy. Force the real service so the admin gate is
|
||||||
|
// genuinely exercised rather than trivially satisfied (same note as NavMenuTests).
|
||||||
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||||
|
Services.AddSingleton(_failover);
|
||||||
|
Services.AddSingleton(_dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AuthorizeView requires a cascading Task<AuthenticationState>, which the app
|
||||||
|
/// supplies from its layout; a bare component render has to provide it explicitly.
|
||||||
|
/// </summary>
|
||||||
|
private IRenderedComponent<CentralFailoverControl> Render(int onlineNodes)
|
||||||
|
{
|
||||||
|
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||||
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<CentralFailoverControl>(0);
|
||||||
|
builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), onlineNodes);
|
||||||
|
builder.CloseComponent();
|
||||||
|
})));
|
||||||
|
|
||||||
|
return host.FindComponent<CentralFailoverControl>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Button_is_hidden_for_a_non_admin()
|
||||||
|
{
|
||||||
|
Arrange("Viewer");
|
||||||
|
|
||||||
|
var cut = Render(onlineNodes: 2);
|
||||||
|
|
||||||
|
Assert.Empty(cut.FindAll("button"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Button_is_rendered_and_enabled_for_an_admin_with_a_standby()
|
||||||
|
{
|
||||||
|
Arrange("Administrator");
|
||||||
|
|
||||||
|
var cut = Render(onlineNodes: 2);
|
||||||
|
|
||||||
|
var button = cut.Find("button");
|
||||||
|
Assert.False(button.HasAttribute("disabled"));
|
||||||
|
Assert.Contains("failover", button.TextContent, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Button_is_disabled_with_an_explanatory_title_when_there_is_no_standby()
|
||||||
|
{
|
||||||
|
Arrange("Administrator");
|
||||||
|
|
||||||
|
var cut = Render(onlineNodes: 1);
|
||||||
|
|
||||||
|
var button = cut.Find("button");
|
||||||
|
Assert.True(button.HasAttribute("disabled"));
|
||||||
|
// The tooltip must say WHY, not just that it is unavailable.
|
||||||
|
Assert.Contains("outage", button.GetAttribute("title"), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Confirming_triggers_the_failover_exactly_once()
|
||||||
|
{
|
||||||
|
Arrange("Administrator");
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||||
|
_failover.FailOverCentralAsync(Arg.Any<string>())
|
||||||
|
.Returns(Task.FromResult<string?>("akka.tcp://scadabridge@central-a:8081"));
|
||||||
|
|
||||||
|
var cut = Render(onlineNodes: 2);
|
||||||
|
await cut.Find("button").ClickAsync(new());
|
||||||
|
|
||||||
|
await _failover.Received(1).FailOverCentralAsync("tester");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_the_confirmation_does_not_trigger_a_failover()
|
||||||
|
{
|
||||||
|
Arrange("Administrator");
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(false);
|
||||||
|
|
||||||
|
var cut = Render(onlineNodes: 2);
|
||||||
|
await cut.Find("button").ClickAsync(new());
|
||||||
|
|
||||||
|
await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Confirmation_warns_that_this_page_will_disconnect()
|
||||||
|
{
|
||||||
|
// Traefik routes the UI to the ACTIVE node — the very node being failed over — so
|
||||||
|
// the admin's own circuit drops. If the dialog does not say so, a working failover
|
||||||
|
// looks like a crash they caused.
|
||||||
|
Arrange("Administrator");
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||||
|
|
||||||
|
var cut = Render(onlineNodes: 2);
|
||||||
|
await cut.Find("button").ClickAsync(new());
|
||||||
|
|
||||||
|
await _dialog.Received(1).ConfirmAsync(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Is<string>(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
Arg.Any<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_refused_failover_surfaces_the_refusal_instead_of_claiming_success()
|
||||||
|
{
|
||||||
|
// The service returns null when the peer guard refuses. The UI must not report a
|
||||||
|
// failover that never happened.
|
||||||
|
Arrange("Administrator");
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||||
|
_failover.FailOverCentralAsync(Arg.Any<string>()).Returns(Task.FromResult<string?>(null));
|
||||||
|
|
||||||
|
var cut = Render(onlineNodes: 2);
|
||||||
|
await cut.Find("button").ClickAsync(new());
|
||||||
|
|
||||||
|
Assert.Contains("no standby", cut.Markup, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Site-pair failover (Task 10) ------------------------------------------
|
||||||
|
// Same control, SiteId set. The differences that matter: it calls the site path,
|
||||||
|
// and its confirmation must NOT claim the admin's own page will drop (it won't —
|
||||||
|
// the site is a different cluster).
|
||||||
|
|
||||||
|
private IRenderedComponent<CentralFailoverControl> RenderForSite(int onlineNodes, string siteId)
|
||||||
|
{
|
||||||
|
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||||
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<CentralFailoverControl>(0);
|
||||||
|
builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), onlineNodes);
|
||||||
|
builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId);
|
||||||
|
builder.CloseComponent();
|
||||||
|
})));
|
||||||
|
|
||||||
|
return host.FindComponent<CentralFailoverControl>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Site_card_confirming_triggers_the_site_failover_path()
|
||||||
|
{
|
||||||
|
Arrange("Administrator");
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||||
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "akka.tcp://scadabridge@site-a-a:8082", null)));
|
||||||
|
|
||||||
|
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
||||||
|
await cut.Find("button").ClickAsync(new());
|
||||||
|
|
||||||
|
await _failover.Received(1).FailOverSiteAsync("SiteA", "tester");
|
||||||
|
await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Site_card_confirmation_does_not_claim_this_page_disconnects()
|
||||||
|
{
|
||||||
|
// Central failover drops the admin's circuit; a SITE failover does not, and saying
|
||||||
|
// otherwise would train operators to distrust the warning that does matter.
|
||||||
|
Arrange("Administrator");
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||||
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null)));
|
||||||
|
|
||||||
|
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
||||||
|
await cut.Find("button").ClickAsync(new());
|
||||||
|
|
||||||
|
await _dialog.Received(1).ConfirmAsync(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Is<string>(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
Arg.Any<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Site_card_surfaces_a_refusal_from_the_site()
|
||||||
|
{
|
||||||
|
Arrange("Administrator");
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||||
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
||||||
|
false, null, "No standby available — failing over a lone node would be an outage.")));
|
||||||
|
|
||||||
|
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
||||||
|
await cut.Find("button").ClickAsync(new());
|
||||||
|
|
||||||
|
Assert.Contains("No standby available", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Site_card_surfaces_an_unreachable_site_distinctly_from_a_refusal()
|
||||||
|
{
|
||||||
|
// A timeout is not a "no" from the site — the operator must be able to tell the
|
||||||
|
// difference, because the failover may or may not have taken effect.
|
||||||
|
Arrange("Administrator");
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||||
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
||||||
|
false, null, "Site did not respond: Ask timed out")));
|
||||||
|
|
||||||
|
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
||||||
|
await cut.Find("button").ClickAsync(new());
|
||||||
|
|
||||||
|
Assert.Contains("did not respond", cut.Markup);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ using System.Security.Claims;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Security;
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
@@ -105,12 +108,43 @@ public class HealthPageTests : BunitContext
|
|||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
Services.AddAuthorizationCore();
|
Services.AddAuthorizationCore();
|
||||||
|
// The failover control's AuthorizeView evaluates the named RequireAdmin policy, so the
|
||||||
|
// real policy set has to be registered or the page throws on any card that renders it.
|
||||||
|
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||||
|
// BunitContext pre-registers a placeholder IAuthorizationService that throws when a
|
||||||
|
// policy is evaluated; force the real one (same note as NavMenuTests).
|
||||||
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||||
|
|
||||||
|
// The cluster cards embed CentralFailoverControl (Task 10), which injects these two.
|
||||||
|
// The page's DI graph genuinely requires them in production, so the test supplies
|
||||||
|
// them rather than the component making its dependencies optional. Behaviour of the
|
||||||
|
// control itself is covered by HealthFailoverButtonTests.
|
||||||
|
Services.AddSingleton(Substitute.For<IManualFailoverService>());
|
||||||
|
Services.AddSingleton(Substitute.For<IDialogService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the dashboard the way the app does — inside a
|
||||||
|
/// <see cref="CascadingAuthenticationState"/>, which the real layout supplies. The
|
||||||
|
/// cluster cards contain an AuthorizeView (the Task 10 failover control), and
|
||||||
|
/// AuthorizeView throws without that cascading value.
|
||||||
|
/// </summary>
|
||||||
|
private IRenderedComponent<HealthPage> RenderHealthPage()
|
||||||
|
{
|
||||||
|
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||||
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<HealthPage>(0);
|
||||||
|
builder.CloseComponent();
|
||||||
|
})));
|
||||||
|
|
||||||
|
return host.FindComponent<HealthPage>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Renders_OutboxKpiTiles_WithValues()
|
public void Renders_OutboxKpiTiles_WithValues()
|
||||||
{
|
{
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
// KPI data arrives via an async actor Ask after first render.
|
// KPI data arrives via an async actor Ask after first render.
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
@@ -129,7 +163,7 @@ public class HealthPageTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void RendersLinkToTheNotificationKpisPage()
|
public void RendersLinkToTheNotificationKpisPage()
|
||||||
{
|
{
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
var link = cut.Find("a[href='/notifications/kpis']");
|
var link = cut.Find("a[href='/notifications/kpis']");
|
||||||
Assert.Contains("View details", link.TextContent);
|
Assert.Contains("View details", link.TextContent);
|
||||||
}
|
}
|
||||||
@@ -148,7 +182,7 @@ public class HealthPageTests : BunitContext
|
|||||||
AsOfUtc: DateTime.UtcNow)));
|
AsOfUtc: DateTime.UtcNow)));
|
||||||
Services.AddSingleton(auditService);
|
Services.AddSingleton(auditService);
|
||||||
|
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -166,7 +200,7 @@ public class HealthPageTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Renders_SiteCallKpiTiles_WithValues()
|
public void Renders_SiteCallKpiTiles_WithValues()
|
||||||
{
|
{
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
// KPI data arrives via an async actor Ask after first render.
|
// KPI data arrives via an async actor Ask after first render.
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
@@ -186,7 +220,7 @@ public class HealthPageTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void RendersLinkToTheSiteCallsReportPage()
|
public void RendersLinkToTheSiteCallsReportPage()
|
||||||
{
|
{
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
var link = cut.Find("a[href='/site-calls/report']");
|
var link = cut.Find("a[href='/site-calls/report']");
|
||||||
Assert.Contains("View details", link.TextContent);
|
Assert.Contains("View details", link.TextContent);
|
||||||
}
|
}
|
||||||
@@ -197,7 +231,7 @@ public class HealthPageTests : BunitContext
|
|||||||
_siteCallKpiReply = new SiteCallKpiResponse(
|
_siteCallKpiReply = new SiteCallKpiResponse(
|
||||||
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
|
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
|
||||||
|
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -216,7 +250,7 @@ public class HealthPageTests : BunitContext
|
|||||||
_kpiReply = new NotificationKpiResponse(
|
_kpiReply = new NotificationKpiResponse(
|
||||||
"k", false, "outbox repository unavailable", 0, 0, 0, 0, null);
|
"k", false, "outbox repository unavailable", 0, 0, 0, 0, null);
|
||||||
|
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -234,7 +268,7 @@ public class HealthPageTests : BunitContext
|
|||||||
// default-site load produces charts.
|
// default-site load produces charts.
|
||||||
SeedSites("site-a");
|
SeedSites("site-a");
|
||||||
|
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -268,7 +302,7 @@ public class HealthPageTests : BunitContext
|
|||||||
throw new InvalidOperationException("kpi history unavailable"));
|
throw new InvalidOperationException("kpi history unavailable"));
|
||||||
Services.AddSingleton(faulting);
|
Services.AddSingleton(faulting);
|
||||||
|
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -294,7 +328,7 @@ public class HealthPageTests : BunitContext
|
|||||||
LastReportReceivedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
LastReportReceivedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
});
|
});
|
||||||
|
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -316,7 +350,7 @@ public class HealthPageTests : BunitContext
|
|||||||
LastStatusChangeAt = changedAt,
|
LastStatusChangeAt = changedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
var cut = Render<HealthPage>();
|
var cut = RenderHealthPage();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ public class ClusterOptionsTests
|
|||||||
{
|
{
|
||||||
var options = new ClusterOptions();
|
var options = new ClusterOptions();
|
||||||
|
|
||||||
Assert.Equal("keep-oldest", options.SplitBrainResolverStrategy);
|
// 'auto-down' is the default posture (decision 2026-07-21): a crash of either
|
||||||
|
// node fails over to the survivor; dual-active during a real partition is the
|
||||||
|
// accepted trade for pairs with no shared lease infrastructure.
|
||||||
|
Assert.Equal("auto-down", options.SplitBrainResolverStrategy);
|
||||||
Assert.Equal(TimeSpan.FromSeconds(15), options.StableAfter);
|
Assert.Equal(TimeSpan.FromSeconds(15), options.StableAfter);
|
||||||
Assert.Equal(TimeSpan.FromSeconds(2), options.HeartbeatInterval);
|
Assert.Equal(TimeSpan.FromSeconds(2), options.HeartbeatInterval);
|
||||||
Assert.Equal(TimeSpan.FromSeconds(10), options.FailureDetectionThreshold);
|
Assert.Equal(TimeSpan.FromSeconds(10), options.FailureDetectionThreshold);
|
||||||
|
|||||||
+30
-1
@@ -153,9 +153,10 @@ public class ClusterOptionsValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DownIfAloneFalse_FailsValidation()
|
public void DownIfAloneFalse_UnderKeepOldest_FailsValidation()
|
||||||
{
|
{
|
||||||
var options = ValidOptions();
|
var options = ValidOptions();
|
||||||
|
options.SplitBrainResolverStrategy = "keep-oldest";
|
||||||
options.DownIfAlone = false;
|
options.DownIfAlone = false;
|
||||||
|
|
||||||
var result = new ClusterOptionsValidator().Validate(null, options);
|
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||||
@@ -164,6 +165,34 @@ public class ClusterOptionsValidatorTests
|
|||||||
Assert.Contains("DownIfAlone", result.FailureMessage);
|
Assert.Contains("DownIfAlone", result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoDownStrategy_Passes()
|
||||||
|
{
|
||||||
|
// Decision 2026-07-21: 'auto-down' is the supported availability-first
|
||||||
|
// posture — either-node crash fails over; dual-active on a real partition
|
||||||
|
// is the accepted trade.
|
||||||
|
var options = ValidOptions();
|
||||||
|
options.SplitBrainResolverStrategy = "auto-down";
|
||||||
|
|
||||||
|
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded, result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DownIfAloneFalse_UnderAutoDown_Passes()
|
||||||
|
{
|
||||||
|
// DownIfAlone is a keep-oldest knob; under auto-down it is inert and must
|
||||||
|
// not block startup.
|
||||||
|
var options = ValidOptions();
|
||||||
|
options.SplitBrainResolverStrategy = "auto-down";
|
||||||
|
options.DownIfAlone = false;
|
||||||
|
|
||||||
|
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded, result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_AccumulatesAllFailures()
|
public void Validate_AccumulatesAllFailures()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public class AuditEnumTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AuditChannel_HasExactlyExpectedMembers()
|
public void AuditChannel_HasExactlyExpectedMembers()
|
||||||
{
|
{
|
||||||
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite" };
|
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite", "Cluster" };
|
||||||
var actual = Enum.GetValues(typeof(AuditChannel))
|
var actual = Enum.GetValues(typeof(AuditChannel))
|
||||||
.Cast<AuditChannel>()
|
.Cast<AuditChannel>()
|
||||||
.Select(x => x.ToString())
|
.Select(x => x.ToString())
|
||||||
@@ -23,7 +23,7 @@ public class AuditEnumTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditKind_HasExactlySixteenExpectedMembers()
|
public void AuditKind_HasExactlySeventeenExpectedMembers()
|
||||||
{
|
{
|
||||||
var expected = new[]
|
var expected = new[]
|
||||||
{
|
{
|
||||||
@@ -33,13 +33,14 @@ public class AuditEnumTests
|
|||||||
"SecuredWriteSubmit", "SecuredWriteApprove", "SecuredWriteReject", "SecuredWriteExecute",
|
"SecuredWriteSubmit", "SecuredWriteApprove", "SecuredWriteReject", "SecuredWriteExecute",
|
||||||
"SecuredWriteExpire",
|
"SecuredWriteExpire",
|
||||||
"ReconciliationAbandoned",
|
"ReconciliationAbandoned",
|
||||||
|
"ManualFailover",
|
||||||
};
|
};
|
||||||
var actual = Enum.GetValues(typeof(AuditKind))
|
var actual = Enum.GetValues(typeof(AuditKind))
|
||||||
.Cast<AuditKind>()
|
.Cast<AuditKind>()
|
||||||
.Select(x => x.ToString())
|
.Select(x => x.ToString())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
Assert.Equal(16, actual.Length);
|
Assert.Equal(17, actual.Length);
|
||||||
Assert.Equal(expected, actual);
|
Assert.Equal(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -372,4 +372,89 @@ public class SiteCommunicationActorTests : TestKit
|
|||||||
Assert.Equal(isActive, heartbeat.IsActive);
|
Assert.Equal(isActive, heartbeat.IsActive);
|
||||||
Assert.Equal("site1", heartbeat.SiteId);
|
Assert.Equal("site1", heartbeat.SiteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Central→site failover relay (Task 10) ----------------------------------
|
||||||
|
// The failover action is injected for the same reason isActiveCheck is: performing a
|
||||||
|
// real Cluster.Leave would require Akka.Cluster in the TestKit ActorSystem. These
|
||||||
|
// tests pin the ROUTING and GUARD logic; the actual Leave against a real two-node
|
||||||
|
// site cluster is covered by SiteFailoverRelayTests in the integration suite.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TriggerSiteFailover_IssuesTheLeave_AndAcksWithTheTarget()
|
||||||
|
{
|
||||||
|
var dmProbe = CreateTestProbe();
|
||||||
|
string? roleAskedFor = null;
|
||||||
|
Func<string, string?> failOver = role =>
|
||||||
|
{
|
||||||
|
roleAskedFor = role;
|
||||||
|
return "akka.tcp://scadabridge@site1-a:8082";
|
||||||
|
};
|
||||||
|
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref, null, failOver)));
|
||||||
|
|
||||||
|
siteActor.Tell(new TriggerSiteFailover("corr-1", "site1"));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<SiteFailoverAck>();
|
||||||
|
Assert.True(ack.Accepted);
|
||||||
|
Assert.Equal("corr-1", ack.CorrelationId);
|
||||||
|
Assert.Equal("akka.tcp://scadabridge@site1-a:8082", ack.TargetAddress);
|
||||||
|
Assert.Null(ack.ErrorMessage);
|
||||||
|
// Site singletons are scoped to the site-specific role, not the base "Site" role;
|
||||||
|
// failing over the wrong scope would move the wrong node.
|
||||||
|
Assert.Equal("site-site1", roleAskedFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TriggerSiteFailover_RefusesWhenThereIsNoPeer()
|
||||||
|
{
|
||||||
|
var dmProbe = CreateTestProbe();
|
||||||
|
Func<string, string?> failOver = _ => null;
|
||||||
|
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref, null, failOver)));
|
||||||
|
|
||||||
|
siteActor.Tell(new TriggerSiteFailover("corr-2", "site1"));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<SiteFailoverAck>();
|
||||||
|
Assert.False(ack.Accepted);
|
||||||
|
Assert.Null(ack.TargetAddress);
|
||||||
|
Assert.NotNull(ack.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TriggerSiteFailover_RefusesACommandAddressedToAnotherSite()
|
||||||
|
{
|
||||||
|
// A misrouted command must be refused, not silently acted on — acting would fail
|
||||||
|
// over a site the operator never selected.
|
||||||
|
var dmProbe = CreateTestProbe();
|
||||||
|
var invoked = false;
|
||||||
|
Func<string, string?> failOver = _ =>
|
||||||
|
{
|
||||||
|
invoked = true;
|
||||||
|
return "addr";
|
||||||
|
};
|
||||||
|
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref, null, failOver)));
|
||||||
|
|
||||||
|
siteActor.Tell(new TriggerSiteFailover("corr-3", "site2"));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<SiteFailoverAck>();
|
||||||
|
Assert.False(ack.Accepted);
|
||||||
|
Assert.Contains("site2", ack.ErrorMessage);
|
||||||
|
Assert.False(invoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TriggerSiteFailover_FaultInTheLeave_IsReportedNotThrown()
|
||||||
|
{
|
||||||
|
var dmProbe = CreateTestProbe();
|
||||||
|
Func<string, string?> failOver = _ => throw new InvalidOperationException("cluster unavailable");
|
||||||
|
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref, null, failOver)));
|
||||||
|
|
||||||
|
siteActor.Tell(new TriggerSiteFailover("corr-4", "site1"));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<SiteFailoverAck>();
|
||||||
|
Assert.False(ack.Accepted);
|
||||||
|
Assert.Contains("cluster unavailable", ack.ErrorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,4 +195,49 @@ public class HoconBuilderTests
|
|||||||
"Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster",
|
"Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster",
|
||||||
config.GetString("akka.cluster.downing-provider-class"));
|
config.GetString("akka.cluster.downing-provider-class"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildHocon_AutoDownStrategy_EmitsAutoDowningProvider()
|
||||||
|
{
|
||||||
|
// Decision 2026-07-21 (availability over partition-safety): 'auto-down' must
|
||||||
|
// swap the downing provider to Akka's AutoDowning so the survivor downs a
|
||||||
|
// crashed peer — including a crashed OLDEST, which two-node keep-oldest
|
||||||
|
// cannot survive — after StableAfter.
|
||||||
|
var cluster = DefaultCluster();
|
||||||
|
cluster.SplitBrainResolverStrategy = "auto-down";
|
||||||
|
cluster.StableAfter = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
var hocon = AkkaHostedService.BuildHocon(
|
||||||
|
DefaultNode(), cluster, new[] { "Central" },
|
||||||
|
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
var config = ConfigurationFactory.ParseString(hocon);
|
||||||
|
Assert.Equal(
|
||||||
|
"Akka.Cluster.AutoDowning, Akka.Cluster",
|
||||||
|
config.GetString("akka.cluster.downing-provider-class"));
|
||||||
|
Assert.Equal(
|
||||||
|
TimeSpan.FromSeconds(15),
|
||||||
|
config.GetTimeSpan("akka.cluster.auto-down-unreachable-after"));
|
||||||
|
// The SBR section must NOT be active alongside AutoDowning.
|
||||||
|
Assert.False(config.HasPath("akka.cluster.split-brain-resolver.active-strategy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildHocon_AutoDownStrategy_IsCaseInsensitive_AndDocumentStaysIntact()
|
||||||
|
{
|
||||||
|
var cluster = DefaultCluster();
|
||||||
|
cluster.SplitBrainResolverStrategy = "Auto-Down";
|
||||||
|
|
||||||
|
var hocon = AkkaHostedService.BuildHocon(
|
||||||
|
DefaultNode(), cluster, new[] { "Central" },
|
||||||
|
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
var config = ConfigurationFactory.ParseString(hocon);
|
||||||
|
Assert.Equal(
|
||||||
|
"Akka.Cluster.AutoDowning, Akka.Cluster",
|
||||||
|
config.GetString("akka.cluster.downing-provider-class"));
|
||||||
|
// Keys after the downing block must remain intact (document not corrupted).
|
||||||
|
Assert.Equal(1, config.GetInt("akka.cluster.min-nr-of-members"));
|
||||||
|
Assert.True(config.GetBoolean("akka.cluster.run-coordinated-shutdown-when-down"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ public class StartupValidatorTests
|
|||||||
{
|
{
|
||||||
var values = ValidCentralConfig();
|
var values = ValidCentralConfig();
|
||||||
values["ScadaBridge:Node:RemotingPort"] = port;
|
values["ScadaBridge:Node:RemotingPort"] = port;
|
||||||
|
// The self-first seed rule (2026-07-22) compares host AND port, so this node's own
|
||||||
|
// seed entry moves with its remoting port — otherwise this port-range test would be
|
||||||
|
// asserting against a config that is inconsistent for an unrelated reason.
|
||||||
|
values["ScadaBridge:Cluster:SeedNodes:0"] = $"akka.tcp://scadabridge@central-node1:{port}";
|
||||||
var config = BuildConfig(values);
|
var config = BuildConfig(values);
|
||||||
|
|
||||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||||
@@ -273,6 +277,52 @@ public class StartupValidatorTests
|
|||||||
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PeerFirstSeedOrder_FailsValidation()
|
||||||
|
{
|
||||||
|
// Decision 2026-07-22: every node must list ITSELF as seed-nodes[0]. Akka only runs
|
||||||
|
// FirstSeedNodeProcess (the process that can form a new cluster when no peer answers)
|
||||||
|
// when seed-nodes[0] is this node's own address; with the peer first the node can
|
||||||
|
// never cold-start alone — the "registered outage gap".
|
||||||
|
var values = ValidCentralConfig();
|
||||||
|
values["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@central-node2:8081";
|
||||||
|
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@central-node1:8081";
|
||||||
|
var config = BuildConfig(values);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||||
|
Assert.Contains("SeedNodes", ex.Message);
|
||||||
|
Assert.Contains("must list this node itself first", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelfFirstSeedOrder_OnASiteNode_PassesValidation()
|
||||||
|
{
|
||||||
|
// Positive control for the rule above: the shipped ordering must validate on a Site
|
||||||
|
// node too (the rule is unconditional, not Central-only).
|
||||||
|
var config = BuildConfig(ValidSiteConfig());
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelfFirstSeed_MatchedOnHostAndPort_NotJustHost()
|
||||||
|
{
|
||||||
|
// Both nodes of a pair can share a hostname when they differ by port (a two-node
|
||||||
|
// dev/loopback install). The rule must compare host AND port, or such a node passes
|
||||||
|
// while actually being the non-first seed.
|
||||||
|
var values = ValidCentralConfig();
|
||||||
|
values["ScadaBridge:Node:NodeHostname"] = "localhost";
|
||||||
|
values["ScadaBridge:Node:RemotingPort"] = "8082";
|
||||||
|
values["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:8081";
|
||||||
|
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:8082";
|
||||||
|
var config = BuildConfig(values);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||||
|
Assert.Contains("must list this node itself first", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("0")]
|
[InlineData("0")]
|
||||||
[InlineData("-1")]
|
[InlineData("-1")]
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using Akka.Configuration;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cluster-level proof of the admin "Trigger failover" control: the current active node
|
||||||
|
/// (the OLDEST Up member — <see cref="ActiveNodeEvaluator"/>'s rule, which is where
|
||||||
|
/// ClusterSingletonManager places singletons) leaves GRACEFULLY, so its singletons hand over
|
||||||
|
/// rather than being killed, and the survivor becomes the active node.
|
||||||
|
///
|
||||||
|
/// <para>Graceful <c>Leave</c>, never <c>Down</c>: a Down would skip singleton hand-off and
|
||||||
|
/// leave the pair to the downing strategy. The peer guard matters just as much — failing over
|
||||||
|
/// a single-node cluster is not a failover, it is an outage, so the service refuses.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ManualFailoverTests : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly List<ActorSystem> _systems = new();
|
||||||
|
|
||||||
|
/// <summary>Starts a one-node cluster (self-first seed list, so it forms alone).</summary>
|
||||||
|
private ActorSystem StartSoloNode()
|
||||||
|
{
|
||||||
|
var port = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var nodeOptions = new NodeOptions { Role = "Central", NodeHostname = "127.0.0.1", RemotingPort = port };
|
||||||
|
var clusterOptions = new ClusterOptions
|
||||||
|
{
|
||||||
|
SeedNodes = new List<string> { $"akka.tcp://scadabridge@127.0.0.1:{port}" },
|
||||||
|
AllowSingleNodeCluster = true,
|
||||||
|
StableAfter = TimeSpan.FromSeconds(3),
|
||||||
|
HeartbeatInterval = TimeSpan.FromMilliseconds(500),
|
||||||
|
FailureDetectionThreshold = TimeSpan.FromSeconds(2),
|
||||||
|
MinNrOfMembers = 1,
|
||||||
|
};
|
||||||
|
var hocon = AkkaHostedService.BuildHocon(
|
||||||
|
nodeOptions, clusterOptions, new[] { "Central" },
|
||||||
|
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3));
|
||||||
|
var system = ActorSystem.Create("scadabridge", ConfigurationFactory.ParseString(hocon));
|
||||||
|
_systems.Add(system);
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Failover_makes_the_oldest_leave_and_the_survivor_take_over()
|
||||||
|
{
|
||||||
|
await using var f = await TwoNodeClusterFixture.StartAsync();
|
||||||
|
var oldest = Akka.Cluster.Cluster.Get(f.NodeA); // NodeA started first = oldest
|
||||||
|
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(oldest, "Central"),
|
||||||
|
"precondition: NodeA must be the active (oldest Up) node before the failover");
|
||||||
|
|
||||||
|
// Issued from the OTHER node, exactly as the UI does it (the button is served by
|
||||||
|
// whichever node Traefik routed the admin to).
|
||||||
|
var target = AkkaManualFailoverService.FailOverCore(f.NodeB, "Central");
|
||||||
|
|
||||||
|
Assert.Equal(oldest.SelfAddress, target);
|
||||||
|
|
||||||
|
// Graceful exit path: the left node's own ActorSystem terminates…
|
||||||
|
await f.NodeA.WhenTerminated.WaitAsync(TimeSpan.FromSeconds(30));
|
||||||
|
// …and the survivor becomes a 1-member cluster and the oldest-Up active node.
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(f.NodeB, oldest.SelfAddress, TimeSpan.FromSeconds(30));
|
||||||
|
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(f.NodeB), "Central"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Failover_refuses_when_no_peer_exists()
|
||||||
|
{
|
||||||
|
var solo = StartSoloNode();
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(solo, 1, TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
var target = AkkaManualFailoverService.FailOverCore(solo, "Central");
|
||||||
|
|
||||||
|
Assert.Null(target);
|
||||||
|
// Positive assert: the refusal left the node running and still active — the guard
|
||||||
|
// must not have half-issued a Leave.
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
|
Assert.Equal(MemberStatus.Up, Akka.Cluster.Cluster.Get(solo).SelfMember.Status);
|
||||||
|
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(solo), "Central"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Failover_dry_run_names_the_target_without_moving_the_cluster()
|
||||||
|
{
|
||||||
|
// The service resolves the target first (to audit it BEFORE acting); that probe must
|
||||||
|
// not itself perturb the cluster.
|
||||||
|
await using var f = await TwoNodeClusterFixture.StartAsync();
|
||||||
|
var oldest = Akka.Cluster.Cluster.Get(f.NodeA);
|
||||||
|
|
||||||
|
var probed = AkkaManualFailoverService.FailOverCore(f.NodeB, "Central", dryRun: true);
|
||||||
|
|
||||||
|
Assert.Equal(oldest.SelfAddress, probed);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3));
|
||||||
|
Assert.Equal(2, Akka.Cluster.Cluster.Get(f.NodeB).State.Members.Count(m => m.Status == MemberStatus.Up));
|
||||||
|
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(oldest, "Central"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var s in _systems)
|
||||||
|
{
|
||||||
|
if (s is { WhenTerminated.IsCompleted: false })
|
||||||
|
{
|
||||||
|
try { await s.Terminate().WaitAsync(TimeSpan.FromSeconds(10)); } catch { /* teardown */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,26 @@ using Xunit;
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Behavioral proof that the SBR downing provider enabled in
|
/// Behavioral proof that the downing provider enabled in
|
||||||
/// <c>AkkaHostedService.BuildHocon</c> (arch-review 01 Critical) is actually active:
|
/// <c>AkkaHostedService.BuildHocon</c> (arch-review 01 Critical) is actually active:
|
||||||
/// after a hard crash, the surviving node DOWNS and REMOVES the crashed member. Under
|
/// after a hard crash, the surviving node DOWNS and REMOVES the crashed member. Under
|
||||||
/// the pre-fix Akka default (NoDowning) the crashed member lingers <c>Unreachable</c>
|
/// the pre-fix Akka default (NoDowning) the crashed member lingers <c>Unreachable</c>
|
||||||
/// forever, so the member-removal assertions here are impossible to satisfy without the
|
/// forever, so the member-removal assertions here are impossible to satisfy without the
|
||||||
/// fix — that is what gives the test teeth.
|
/// fix — that is what gives the tests teeth.
|
||||||
///
|
///
|
||||||
/// IMPORTANT — <c>keep-oldest</c> two-node semantics (verified empirically, Akka 1.5.62):
|
/// TWO-NODE SEMANTICS (verified against Akka.NET 1.5.62 source + live on the docker
|
||||||
/// SBR downs the partition that does NOT contain the oldest member. So the crash that
|
/// rig, 2026-07-21):
|
||||||
/// SBR can recover from in a two-node cluster is the crash of the YOUNGER node — the
|
/// <list type="bullet">
|
||||||
/// oldest survives and keeps its singletons. Crashing the OLDEST node instead makes the
|
/// <item><c>keep-oldest</c> — downs the side that does NOT contain the oldest member,
|
||||||
/// younger survivor down ITSELF (total cluster loss); <c>down-if-alone=on</c> does not
|
/// and its <c>down-if-alone</c> escape only fires when the surviving side has ≥2
|
||||||
/// change this on a hard crash because the alone-oldest is no longer running to down
|
/// members (<c>KeepOldest.OldestDecision</c>: <c>otherSide == 1 && thisSide >= 2</c>).
|
||||||
/// itself. That asymmetry (active/oldest-node crash is NOT covered by two-node
|
/// With 1-vs-1 the younger survivor therefore takes <c>DownReachable</c> — it downs
|
||||||
/// keep-oldest) is a design-level gap tracked separately, not something this test can
|
/// ITSELF — so only a YOUNGER-node crash is survivable.</item>
|
||||||
/// assert as a success path.
|
/// <item><c>auto-down</c> (production default, decision 2026-07-21) — the leader among
|
||||||
|
/// the reachable members downs the unreachable peer after the stability window, so a
|
||||||
|
/// crash of EITHER node fails over to the survivor; the accepted trade is dual-active
|
||||||
|
/// during a real network partition.</item>
|
||||||
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SbrFailoverTests
|
public class SbrFailoverTests
|
||||||
{
|
{
|
||||||
@@ -46,7 +50,8 @@ public class SbrFailoverTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task HardCrashOfYoungerNode_SbrDownsIt_AndOldestKeepsSingleton()
|
public async Task HardCrashOfYoungerNode_SbrDownsIt_AndOldestKeepsSingleton()
|
||||||
{
|
{
|
||||||
await using var cluster = await TwoNodeClusterFixture.StartAsync();
|
// Pinned to keep-oldest: this is the SBR path's (only) survivable direction.
|
||||||
|
await using var cluster = await TwoNodeClusterFixture.StartAsync(strategy: "keep-oldest");
|
||||||
var (_, proxyA) = StartSingleton(cluster.NodeA); // oldest hosts the singleton
|
var (_, proxyA) = StartSingleton(cluster.NodeA); // oldest hosts the singleton
|
||||||
StartSingleton(cluster.NodeB);
|
StartSingleton(cluster.NodeB);
|
||||||
|
|
||||||
@@ -78,4 +83,80 @@ public class SbrFailoverTests
|
|||||||
}
|
}
|
||||||
throw new Xunit.Sdk.XunitException($"Singleton stopped answering on the surviving oldest node after SBR downing: {last}");
|
throw new Xunit.Sdk.XunitException($"Singleton stopped answering on the surviving oldest node after SBR downing: {last}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoDown_HardCrashOfOldestNode_YoungerSurvivorTakesOverSingleton()
|
||||||
|
{
|
||||||
|
// Decision 2026-07-21: the direction two-node keep-oldest can NEVER survive
|
||||||
|
// (proven live on the docker rig — the younger survivor took DownReachable and
|
||||||
|
// self-downed). Under auto-down the survivor must instead down the crashed
|
||||||
|
// oldest and TAKE OVER its singleton.
|
||||||
|
await using var cluster = await TwoNodeClusterFixture.StartAsync(strategy: "auto-down");
|
||||||
|
StartSingleton(cluster.NodeA); // oldest hosts the singleton initially
|
||||||
|
var (_, proxyB) = StartSingleton(cluster.NodeB);
|
||||||
|
|
||||||
|
// Singleton reachable from B while A is alive (proxy routes to the oldest).
|
||||||
|
var echo = await proxyB.Ask<string>("ping", TimeSpan.FromSeconds(20));
|
||||||
|
Assert.Equal("ping", echo);
|
||||||
|
|
||||||
|
var victimAddress = Akka.Cluster.Cluster.Get(cluster.NodeA).SelfAddress;
|
||||||
|
await TwoNodeClusterFixture.CrashNode(cluster.NodeA);
|
||||||
|
|
||||||
|
// 1) The younger survivor must DOWN and REMOVE the crashed OLDEST member —
|
||||||
|
// the exact step keep-oldest refuses (it downs itself instead).
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(
|
||||||
|
cluster.NodeB, victimAddress, TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
// 2) B must still be a functioning cluster member (not self-downed) …
|
||||||
|
var clusterB = Akka.Cluster.Cluster.Get(cluster.NodeB);
|
||||||
|
Assert.False(clusterB.IsTerminated, "survivor's Cluster extension terminated — it downed itself");
|
||||||
|
|
||||||
|
// 3) … and the singleton must migrate to B and answer again.
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
||||||
|
Exception? last = null;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var echo2 = await proxyB.Ask<string>("ping-after-oldest-crash", TimeSpan.FromSeconds(3));
|
||||||
|
Assert.Equal("ping-after-oldest-crash", echo2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { last = ex; }
|
||||||
|
}
|
||||||
|
throw new Xunit.Sdk.XunitException(
|
||||||
|
$"Singleton never migrated to the younger survivor after the oldest crashed under auto-down: {last}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoDown_HardCrashOfYoungerNode_OldestKeepsSingleton()
|
||||||
|
{
|
||||||
|
// The previously-survivable direction must STAY survivable under auto-down.
|
||||||
|
await using var cluster = await TwoNodeClusterFixture.StartAsync(strategy: "auto-down");
|
||||||
|
var (_, proxyA) = StartSingleton(cluster.NodeA);
|
||||||
|
StartSingleton(cluster.NodeB);
|
||||||
|
|
||||||
|
Assert.Equal("ping", await proxyA.Ask<string>("ping", TimeSpan.FromSeconds(20)));
|
||||||
|
|
||||||
|
var victimAddress = Akka.Cluster.Cluster.Get(cluster.NodeB).SelfAddress;
|
||||||
|
await TwoNodeClusterFixture.CrashNode(cluster.NodeB);
|
||||||
|
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(
|
||||||
|
cluster.NodeA, victimAddress, TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
||||||
|
Exception? last = null;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var echo2 = await proxyA.Ask<string>("ping-after-crash", TimeSpan.FromSeconds(3));
|
||||||
|
Assert.Equal("ping-after-crash", echo2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { last = ex; }
|
||||||
|
}
|
||||||
|
throw new Xunit.Sdk.XunitException(
|
||||||
|
$"Singleton stopped answering on the surviving oldest node under auto-down: {last}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using Akka.Configuration;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the self-first seed-ordering invariant (decision 2026-07-22): every node lists
|
||||||
|
/// ITSELF as <c>seed-nodes[0]</c> and its partner second.
|
||||||
|
///
|
||||||
|
/// <para><b>Why the ordering is the mechanism.</b> Akka runs a different bootstrap process
|
||||||
|
/// depending on whether <c>seed-nodes[0]</c> is this node's own address. When it is,
|
||||||
|
/// <c>FirstSeedNodeProcess</c> runs: it InitJoins the OTHER seeds and self-joins only after
|
||||||
|
/// <c>seed-node-timeout</c> passes with nobody answering. When it is not,
|
||||||
|
/// <c>JoinSeedNodeProcess</c> runs, which can never form a new cluster — it retries InitJoin
|
||||||
|
/// forever. That is the "registered outage gap" (docker/README.md): a lone cold-starting
|
||||||
|
/// non-first seed never came Up. Listing self first closes it using Akka's own protocol.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Why not an external self-form watchdog.</b> A timer that waits N seconds for
|
||||||
|
/// membership and then calls <c>Cluster.Join(SelfAddress)</c> cannot see Akka's join
|
||||||
|
/// handshake, so it cannot distinguish "no seed answered" from "a seed answered and the join
|
||||||
|
/// is in flight". <see cref="Restarting_node_rejoins_its_live_peer_instead_of_self_forming"/>
|
||||||
|
/// is the case that killed that design: on a routine standby restart the peer is alive but
|
||||||
|
/// the join is stalled behind removal of this node's own stale incarnation, and a Join(self)
|
||||||
|
/// issued during <c>TryingToJoin</c> abandons the in-flight join and forms a SECOND cluster at
|
||||||
|
/// the same address — a permanent split (measured: still split after 90s). Akka's own
|
||||||
|
/// first-seed process has no such race because it is part of the handshake.</para>
|
||||||
|
///
|
||||||
|
/// These build REAL clusters from the production <c>BuildHocon</c> output, like
|
||||||
|
/// <see cref="TwoNodeClusterFixture"/>, at production failure-detection timings.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly List<ActorSystem> _systems = new();
|
||||||
|
|
||||||
|
/// <summary>Starts a node configured the way every shipped node appsettings is: its own
|
||||||
|
/// address first, the partner second. <paramref name="selfFirst"/> = false reproduces the
|
||||||
|
/// pre-fix ordering, which is what makes the outage-gap assertions falsifiable.</summary>
|
||||||
|
private ActorSystem StartNode(int selfPort, int peerPort, bool selfFirst = true)
|
||||||
|
{
|
||||||
|
var nodeOptions = new NodeOptions { Role = "Central", NodeHostname = "127.0.0.1", RemotingPort = selfPort };
|
||||||
|
var self = $"akka.tcp://scadabridge@127.0.0.1:{selfPort}";
|
||||||
|
var peer = $"akka.tcp://scadabridge@127.0.0.1:{peerPort}";
|
||||||
|
var clusterOptions = new ClusterOptions
|
||||||
|
{
|
||||||
|
SeedNodes = selfFirst ? new List<string> { self, peer } : new List<string> { peer, self },
|
||||||
|
// Production failure-detection envelope: this is what sets how long a restarting
|
||||||
|
// node's join stays stalled behind its own stale incarnation.
|
||||||
|
StableAfter = TimeSpan.FromSeconds(15),
|
||||||
|
HeartbeatInterval = TimeSpan.FromSeconds(2),
|
||||||
|
FailureDetectionThreshold = TimeSpan.FromSeconds(10),
|
||||||
|
MinNrOfMembers = 1,
|
||||||
|
};
|
||||||
|
var hocon = AkkaHostedService.BuildHocon(
|
||||||
|
nodeOptions, clusterOptions, new[] { "Central" },
|
||||||
|
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3));
|
||||||
|
// Crash simulation: Terminate() must NOT run CoordinatedShutdown (no Leave gossip),
|
||||||
|
// matching TwoNodeClusterFixture — otherwise a "restart after crash" degenerates to
|
||||||
|
// the graceful path where the peer has already removed the old member.
|
||||||
|
var config = ConfigurationFactory
|
||||||
|
.ParseString("akka.coordinated-shutdown.run-by-actor-system-terminate = off")
|
||||||
|
.WithFallback(ConfigurationFactory.ParseString(hocon));
|
||||||
|
var system = ActorSystem.Create("scadabridge", config);
|
||||||
|
_systems.Add(system);
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Lone_cold_start_forms_a_cluster_when_the_peer_is_dead()
|
||||||
|
{
|
||||||
|
var selfPort = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var deadPeerPort = TwoNodeClusterFixture.GetFreeTcpPort(); // nothing listening
|
||||||
|
var node = StartNode(selfPort, deadPeerPort);
|
||||||
|
|
||||||
|
// Akka's FirstSeedNodeProcess self-joins after seed-node-timeout (~5s) once the dead
|
||||||
|
// peer fails to answer InitJoin. This is the unattended cold-start-alone guarantee.
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(node, 1, TimeSpan.FromSeconds(30));
|
||||||
|
Assert.Equal(MemberStatus.Up, Akka.Cluster.Cluster.Get(node).SelfMember.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Peer_first_ordering_is_the_outage_gap_and_never_forms()
|
||||||
|
{
|
||||||
|
// FALSIFIABILITY CONTROL for the test above: with the OLD ordering (peer first) the
|
||||||
|
// identical scenario never comes Up. If this test ever starts passing quickly, the
|
||||||
|
// self-first ordering has stopped being the thing doing the work.
|
||||||
|
var selfPort = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var deadPeerPort = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var node = StartNode(selfPort, deadPeerPort, selfFirst: false);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(15)); // 3x the seed-node-timeout
|
||||||
|
var cluster = Akka.Cluster.Cluster.Get(node);
|
||||||
|
Assert.Empty(cluster.State.Members); // still InitJoin-looping — the registered outage gap
|
||||||
|
|
||||||
|
// Positive control: the node was formable all along; only the ordering blocked it.
|
||||||
|
cluster.Join(cluster.SelfAddress);
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(node, 1, TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Restarting_node_rejoins_its_live_peer_instead_of_self_forming()
|
||||||
|
{
|
||||||
|
// The case that rejected the self-form-watchdog design (code review 2026-07-22, C1):
|
||||||
|
// a routine standby restart while the peer is alive must rejoin, never island.
|
||||||
|
var portA = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var portB = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
|
||||||
|
var nodeA = StartNode(portA, portB);
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 1, TimeSpan.FromSeconds(30));
|
||||||
|
var nodeB = StartNode(portB, portA);
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
// Hard-crash B and immediately restart it at the SAME address, as a container
|
||||||
|
// recreate / service restart does.
|
||||||
|
await nodeB.Terminate().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
var nodeB2 = StartNode(portB, portA);
|
||||||
|
|
||||||
|
// Both must converge on ONE 2-member cluster — never two 1-member clusters.
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeB2, 2, TimeSpan.FromSeconds(90));
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(90));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Both_nodes_cold_starting_together_converge_on_one_cluster()
|
||||||
|
{
|
||||||
|
// Self-first on BOTH nodes raises the obvious question: does a simultaneous cold
|
||||||
|
// start produce two clusters? While they are mutually reachable it does not — the
|
||||||
|
// InitJoin handshake resolves it before either self-joins. (A genuine boot-time
|
||||||
|
// PARTITION would still split, which is the same class auto-down already accepts.)
|
||||||
|
var portA = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var portB = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
|
||||||
|
var nodeA = StartNode(portA, portB);
|
||||||
|
var nodeB = StartNode(portB, portA);
|
||||||
|
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(60));
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InitializeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var s in _systems)
|
||||||
|
{
|
||||||
|
try { await s.Terminate().WaitAsync(TimeSpan.FromSeconds(10)); } catch { /* teardown */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real-cluster proof for the central→site failover relay (Task 10). The unit tests in
|
||||||
|
/// <c>SiteCommunicationActorTests</c> pin the routing and guard logic with the failover action
|
||||||
|
/// stubbed out; this pins the part they cannot — that the shared
|
||||||
|
/// <see cref="ClusterFailoverCoordinator"/> actually moves a SITE pair when scoped to the
|
||||||
|
/// site-specific role.
|
||||||
|
///
|
||||||
|
/// <para>The site-specific role scope is the load-bearing detail: site singletons (the
|
||||||
|
/// Deployment Manager) are placed on <c>site-{SiteId}</c>, not on the base <c>Site</c> role, so
|
||||||
|
/// failing over the wrong scope would move the wrong node.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SiteFailoverRelayTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Failing_over_a_site_pair_moves_the_oldest_and_the_survivor_takes_over()
|
||||||
|
{
|
||||||
|
// A site pair, both nodes carrying the site-specific role.
|
||||||
|
await using var f = await TwoNodeClusterFixture.StartAsync(role: "site-SiteA");
|
||||||
|
var oldest = Akka.Cluster.Cluster.Get(f.NodeA);
|
||||||
|
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(oldest, "site-SiteA"),
|
||||||
|
"precondition: NodeA must be the active (oldest Up) site node");
|
||||||
|
|
||||||
|
// Issued from the other node, as the relay does when contact rotation lands there.
|
||||||
|
var target = ClusterFailoverCoordinator.FailOverOldest(f.NodeB, "site-SiteA");
|
||||||
|
|
||||||
|
Assert.Equal(oldest.SelfAddress, target);
|
||||||
|
await f.NodeA.WhenTerminated.WaitAsync(TimeSpan.FromSeconds(30));
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(f.NodeB, oldest.SelfAddress, TimeSpan.FromSeconds(30));
|
||||||
|
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(f.NodeB), "site-SiteA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_site_role_scope_that_matches_no_member_is_refused()
|
||||||
|
{
|
||||||
|
// Guards the role-scoping mistake directly: asking for a site that isn't this pair
|
||||||
|
// must find no members and refuse, rather than falling back to some other node.
|
||||||
|
await using var f = await TwoNodeClusterFixture.StartAsync(role: "site-SiteA");
|
||||||
|
|
||||||
|
var target = ClusterFailoverCoordinator.FailOverOldest(f.NodeB, "site-SiteB");
|
||||||
|
|
||||||
|
Assert.Null(target);
|
||||||
|
// Positive control: the pair is untouched and still fully formed.
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(f.NodeB, 2, TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,22 +28,26 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable
|
|||||||
public static async Task<TwoNodeClusterFixture> StartAsync(
|
public static async Task<TwoNodeClusterFixture> StartAsync(
|
||||||
string role = "Central", TimeSpan? stableAfter = null,
|
string role = "Central", TimeSpan? stableAfter = null,
|
||||||
int? portA = null, int? portB = null,
|
int? portA = null, int? portB = null,
|
||||||
TimeSpan? heartbeatInterval = null, TimeSpan? failureDetectionThreshold = null)
|
TimeSpan? heartbeatInterval = null, TimeSpan? failureDetectionThreshold = null,
|
||||||
|
string strategy = "auto-down")
|
||||||
{
|
{
|
||||||
var f = new TwoNodeClusterFixture();
|
var f = new TwoNodeClusterFixture();
|
||||||
f.PortA = portA ?? GetFreeTcpPort();
|
f.PortA = portA ?? GetFreeTcpPort();
|
||||||
f.PortB = portB ?? GetFreeTcpPort();
|
f.PortB = portB ?? GetFreeTcpPort();
|
||||||
f.NodeA = f.StartNode(f.PortA, role, stableAfter, heartbeatInterval, failureDetectionThreshold);
|
f.NodeA = f.StartNode(f.PortA, role, stableAfter, heartbeatInterval, failureDetectionThreshold, strategy);
|
||||||
await WaitForMembersUp(f.NodeA, 1, TimeSpan.FromSeconds(20));
|
await WaitForMembersUp(f.NodeA, 1, TimeSpan.FromSeconds(20));
|
||||||
f.NodeB = f.StartNode(f.PortB, role, stableAfter, heartbeatInterval, failureDetectionThreshold);
|
f.NodeB = f.StartNode(f.PortB, role, stableAfter, heartbeatInterval, failureDetectionThreshold, strategy);
|
||||||
await WaitForMembersUp(f.NodeA, 2, TimeSpan.FromSeconds(20));
|
await WaitForMembersUp(f.NodeA, 2, TimeSpan.FromSeconds(20));
|
||||||
await WaitForMembersUp(f.NodeB, 2, TimeSpan.FromSeconds(20));
|
await WaitForMembersUp(f.NodeB, 2, TimeSpan.FromSeconds(20));
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Starts a node from production HOCON; used by StartAsync and by restart-scenarios.</summary>
|
/// <summary>Starts a node from production HOCON; used by StartAsync and by restart-scenarios.
|
||||||
|
/// <paramref name="strategy"/> defaults to the production posture (auto-down, decision
|
||||||
|
/// 2026-07-21); pass "keep-oldest" to exercise the legacy SBR path.</summary>
|
||||||
public ActorSystem StartNode(int port, string role, TimeSpan? stableAfter = null,
|
public ActorSystem StartNode(int port, string role, TimeSpan? stableAfter = null,
|
||||||
TimeSpan? heartbeatInterval = null, TimeSpan? failureDetectionThreshold = null)
|
TimeSpan? heartbeatInterval = null, TimeSpan? failureDetectionThreshold = null,
|
||||||
|
string strategy = "auto-down")
|
||||||
{
|
{
|
||||||
var nodeOptions = new NodeOptions { Role = role, NodeHostname = "127.0.0.1", RemotingPort = port };
|
var nodeOptions = new NodeOptions { Role = role, NodeHostname = "127.0.0.1", RemotingPort = port };
|
||||||
var clusterOptions = new ClusterOptions
|
var clusterOptions = new ClusterOptions
|
||||||
@@ -53,7 +57,7 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable
|
|||||||
$"akka.tcp://scadabridge@127.0.0.1:{PortA}",
|
$"akka.tcp://scadabridge@127.0.0.1:{PortA}",
|
||||||
$"akka.tcp://scadabridge@127.0.0.1:{PortB}",
|
$"akka.tcp://scadabridge@127.0.0.1:{PortB}",
|
||||||
},
|
},
|
||||||
SplitBrainResolverStrategy = "keep-oldest",
|
SplitBrainResolverStrategy = strategy,
|
||||||
StableAfter = stableAfter ?? TimeSpan.FromSeconds(3),
|
StableAfter = stableAfter ?? TimeSpan.FromSeconds(3),
|
||||||
HeartbeatInterval = heartbeatInterval ?? TimeSpan.FromMilliseconds(500),
|
HeartbeatInterval = heartbeatInterval ?? TimeSpan.FromMilliseconds(500),
|
||||||
FailureDetectionThreshold = failureDetectionThreshold ?? TimeSpan.FromSeconds(2),
|
FailureDetectionThreshold = failureDetectionThreshold ?? TimeSpan.FromSeconds(2),
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.Failover;
|
|||||||
/// 2s heartbeat / 10s failure-detection threshold / 15s SBR stable-after —
|
/// 2s heartbeat / 10s failure-detection threshold / 15s SBR stable-after —
|
||||||
/// the CLAUDE.md "total failover ~25s" design envelope.
|
/// the CLAUDE.md "total failover ~25s" design envelope.
|
||||||
///
|
///
|
||||||
/// Measures the SURVIVABLE direction only: hard-crash of the YOUNGER node,
|
/// Runs under the production default downing strategy (auto-down, decision
|
||||||
/// timed to the survivor's member REMOVAL (detection + stable-after + gossip)
|
/// 2026-07-21 — either-direction crash fails over; see SbrFailoverTests XML
|
||||||
/// with singleton continuity asserted on the oldest. The oldest/active-node
|
/// doc). Measures a hard-crash of the YOUNGER node, timed to the survivor's
|
||||||
/// crash is NOT a recovery to time — two-node keep-oldest makes the younger
|
/// member REMOVAL (detection + stability window + gossip) with singleton
|
||||||
/// survivor down itself (total outage; registered deferred user decision,
|
/// continuity asserted on the oldest; the oldest-crash direction is covered
|
||||||
/// see SbrFailoverTests XML doc + master tracker 2026-07-08). Covers overall
|
/// behaviorally by SbrFailoverTests.AutoDown_HardCrashOfOldestNode_* and by
|
||||||
/// review P2-10 / report-08 NF2 and report-01 round-2 N1's measurement ask.
|
/// the docker failover drill. Covers overall review P2-10 / report-08 NF2
|
||||||
|
/// and report-01 round-2 N1's measurement ask.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class FailoverTimingTests(ITestOutputHelper output)
|
public class FailoverTimingTests(ITestOutputHelper output)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user