20 Commits

Author SHA1 Message Date
Joseph Doherty c1216044a4 docs(cluster): document the #33 bootstrap guard; disabled BootstrapGuard block in Host appsettings
Component-ClusterInfrastructure.md gains a 'Simultaneous cold start — the
bootstrap guard' subsection (dark switch, founder/probe semantics, config table,
accepted trade) plus a forward reference from Dual-Node Recovery. Host
appsettings.Central/Site.json carry a disabled BootstrapGuard block with a
_bootstrapGuard note for operator discoverability. CLAUDE.md Cluster & Failover
note added.
2026-07-24 08:57:44 -04:00
Joseph Doherty 248676ed16 feat(cluster): simultaneous-cold-start split-brain guard (#33)
Port OtOpcUa's bootstrap guard (lmxopcua d1dac87f) to ScadaBridge's
BuildHocon bootstrap. Both pair nodes are self-first seeds, so a true
simultaneous cold start (shared site power event) races FirstSeedNodeProcess
on both and forms two 1-node clusters that never merge.

Opt-in dark switch ScadaBridge:Cluster:BootstrapGuard:Enabled (default off,
guard-off behavior byte-identical). When on: BuildHocon emits an empty seed
list so Akka does not auto-join, and ClusterBootstrapCoordinator (IHostedService,
registered in both the Central and Site composition roots) picks the join order
from ClusterBootstrapGuard's pure decision core — the lower canonical host:port
is the founder (self-first, forms immediately); the higher node TCP-probes the
founder up to PartnerProbeSeconds and joins peer-first if reachable, else
self-first (cold-start-alone preserved). Decides before a single JoinSeedNodes,
never re-forms mid-handshake.

Review notes carried over: case-insensitive founder tie-break; fail-fast
validation of probe timings when enabled; higher-node-cold-start-alone covered
by a real-ActorSystem test. 21 unit + 5 real-cluster tests (incl. the headline
both-cold-start-together-form-one-cluster).
2026-07-24 08:55:48 -04:00
Joseph Doherty bb138d5254 docs: add env_variables.md — full environment-variable inventory
Deep-scan catalog of every environment variable ScadaBridge reads (Host, CLI,
DelmiaNotifier, EF design-time tooling, test/CI harness) plus supporting infra
containers. Each entry carries scope (Runtime/Design-time/Test/Infra), whether a
shipped appsettings key already exists for it, consumer, purpose, potential
values, and required-ness. Compiled from GetEnvironmentVariable call sites, the
Host AddEnvironmentVariables() source, docker/docker-env2 compose, install.ps1,
and all appsettings*.json.
2026-07-24 05:02:24 -04:00
Joseph Doherty 9a12826172 docs(plans): OtOpcUa v3 native-alarm B/C live-gate PASS (#14) — no code needed; item C premise obsolete (Gitea #17) 2026-07-24 04:33:20 -04:00
Joseph Doherty 86d129de6c fix(dcl): harden endpoint host-rewrite — IPv6 brackets + portless URLs
Code review of a1abbff7 found two edge cases: a discovery/advertised URL with no
explicit port emitted a malformed ':-1' (opc.tcp has no Uri default port), and an
IPv6 literal host could lose/double its brackets. Omit the port when neither URL
carries one; bracket an IPv6 host only when missing. +4 tests (12 total).
2026-07-23 16:58:44 -04:00
Joseph Doherty dbec3ee263 docs(plans): OtOpcUa v3 raw-path live-gate PASS (#14) — fix validated live, B/C deferred 2026-07-23 16:54:48 -04:00
Joseph Doherty a1abbff760 fix(dcl): rewrite advertised OPC UA endpoint host to the reachable one
An OPC UA server advertises its base address from its own config, not the route
the client took — commonly a 0.0.0.0 wildcard bind or an internal container/NAT
hostname. The OPC Foundation session dials EndpointDescription.EndpointUrl
verbatim, so a 0.0.0.0 advertisement resolved to the client's own loopback and
the connect failed. RealOpcUaClient now swaps the advertised authority for the
reachable host/port (ConnectAsync + VerifyEndpointAsync), preserving scheme+path;
no-op when already reachable. Surfaced live-gating OtOpcUa v3 (#14).
2026-07-23 16:43:45 -04:00
Joseph Doherty 266f001a2e docs(plans): OtOpcUa v3 dual-namespace cutover scope + phase-2 plan (#14) 2026-07-23 15:22:22 -04:00
Joseph Doherty e04c2617cc feat(ui): warn on bare ns= node bindings in the browse picker (v3 cutover #14) 2026-07-23 15:18:36 -04:00
Joseph Doherty 97afa84fcd docs(dcl): sweep ns= examples to durable nsu= form (v3 cutover #14) 2026-07-23 15:17:13 -04:00
Joseph Doherty 0eb44314cb feat(dcl): add OpcUaReferenceForm.IsDurable — flags bare ns= bindings (v3 cutover #14) 2026-07-23 15:16:47 -04:00
Joseph Doherty a5256e9b12 chore(comm): delete dead IntegrationCallRequest routing (Gitea #32)
'Pattern 4: Integration Routing' — RouteIntegrationCallAsync →
SiteEnvelope(IntegrationCallRequest) → SiteCommunicationActor integration
handler — was plumbed end to end but connected at neither end: no
producer (zero callers) and no handler (AkkaHostedService never
registered LocalHandlerType.Integration). It was an early scaffold the
architecture routed around — the brokered External→Central→Site→Central
round-trip is served by the Inbound API's routed-site-script path (the
RouteTo* verbs, driven from CommunicationServiceInstanceRouter), which is
live, tested, and shares IntegrationTimeout.

Decision (#32): delete. Removed the IntegrationCall{Request,Response}
messages, RouteIntegrationCallAsync, the SiteCommunicationActor receive
block + _integrationHandler field + LocalHandlerType.Integration, and the
four tests that covered them (2 actor, 2 message-contract, 1 dispatcher-
reject, 1 mapper-reject). KEPT IntegrationTimeout — it is the live
timeout for the RouteTo* verbs. Updated the exclusion-narrative comments
(proto/mapper/dispatcher), design §4, the components doc timeout table,
and marked the known-issue RESOLVED.

Full solution build clean (0/0); Communication 634 + Host 421 green.
Net -172/+20 across 14 files. Not in the gRPC proto (was deliberately
excluded there), so no wire-format change.
2026-07-23 14:27:32 -04:00
Joseph Doherty e0f105c3b3 docs(env2): live-gate docker-env2 on the gRPC PSK build — PASS 3/3 (Gitea #31)
Rebuilt scadabridge:latest from main @ 8524a7f7 and recreated only the
env2 containers. All three gate checks pass on site-x:
1. both site nodes boot with the key (StartupValidator fail-closed →
   reaching 'Application started' proves the key present);
2. control-plane PSK auth: no-header / wrong-key ⇒ PermissionDenied,
   correct key ⇒ success, on both nodes (:9123, :9124);
3. LocalDb unaffected (local-only; 0 errors, healthy boot).
Bonus: central registers site-x online via gRPC heartbeat; no real
ClusterClient/receptionist (only the benign ClusterClientSiteAuditClient
label, same as the primary rig). Noted a seed-data gap (ScadaBridgeConfig2
dbo.Sites is empty) — orthogonal to the transport.
2026-07-23 14:10:22 -04:00
Joseph Doherty 8524a7f746 test(sms-e2e): valid Twilio SID fixture so the create path passes (Gitea #29)
TestAccountSid was 'ACtest123' (AC + 6 chars). The management create
path validates the Account SID against ^AC[0-9a-fA-F]{32}$
(ManagementActor.cs:2205, added 2026-07-10, commit 40088a21) while the
fixture predates the guard (2026-06-19) — so the create was rejected and
the config-card + secret-non-leak assertions have been red/inert since.

Use AC + 32 hex (AC00000000000000000000000000000001) so the create
succeeds and the downstream Auth-Token-non-leak assertion actually runs
again. Unit-level RepositoryCoverageTests keep their short SIDs — they
construct SmsConfiguration directly and never hit the validated path.
2026-07-23 14:03:49 -04:00
Joseph Doherty c4dcd9bc02 fix(transport): run bundle import inside the execution strategy (Gitea #28)
BundleImporter.ApplyAsync opened a user-initiated transaction directly,
but the central ConfigurationDb context is configured with
EnableRetryOnFailure. SqlServerRetryingExecutionStrategy rejects
user-initiated transactions, so every bundle import threw on any real
SQL Server ('...does not support user-initiated transactions.'). The
whole Transport suite ran on the in-memory provider (no retrying
strategy, BeginTransaction is a no-op) so it never surfaced.

Extract the transactional apply into ApplyMergeAsync and drive it via
_dbContext.Database.CreateExecutionStrategy().ExecuteAsync, so the
strategy owns the BeginTransaction -> apply -> Commit unit as one
retriable block. The delegate resets per-attempt state (change tracker
+ ApplyMergeAsync rebuilds its own summary/resolution accumulators) so a
retried attempt cannot double-apply; a rollback failure is captured and
surfaced on the BundleImportFailed audit row exactly as before. Post-
commit side effects (ScriptArtifactsChanged publish, session zero/
remove) moved outside the retriable delegate so they run once.

Regression test: BundleImporterRetryingStrategyTests runs the import on
SQLite with a retrying execution strategy (RetriesOnFailure == true,
arming the same guard as production). It fails with the exact production
error on the pre-fix code and passes after. Existing rollback/apply
contracts unchanged (104 integration + 154 unit tests green).
2026-07-23 13:55:09 -04:00
Joseph Doherty 9f91e84d83 docs(grpc): Phase 5 live gate — PASS (8/8), migration complete
Full eight-check gate on the Phase 4 deletion build (main @ 7fd5cb2b),
rig rebuilt from main. All 8 checks PASS: PSK negatives; site->central
matrix (notif no-loss/dupe, both audit paths, reconcile self-heal);
central->site matrix (Query/Parked/Lifecycle); active-central kill ->
sticky flip 1s + central-b active 26s; mid-drain total==distinct;
297,574-byte gRPC reply (128KB frame-class retired); cluster membership
pair-only (no cross-boundary Akka association); full-rig restart 0
receptionist/ClusterClient lines on any of 8 nodes.

Check-1 clarification recorded: site-side interceptor uses the node's
single GrpcPsk and ignores x-scadabridge-site (a central-side routing
hint) -- per-site isolation proven by wrong-key reject. Instance-
dependent central->site RPCs (OpcUa/Route/standby-parked/TriggerFailover)
carried forward, unit-proven.
2026-07-23 13:35:16 -04:00
Joseph Doherty 7fd5cb2b56 feat(comm): Phase 4 — delete Akka ClusterClient site↔central transport, gRPC-only
ClusterClient→gRPC migration Phase 4 (docs/plans/2026-07-22-clusterclient-to-grpc-plan.md).
Phases 2/3 proved both directions on gRPC; this removes the Akka transport underneath.

Deleted:
- AkkaCentralTransport, AkkaSiteTransport (+ their dedicated tests)
- ISiteClientFactory + DefaultSiteClientFactory; CentralCommunicationActor legacy
  ctor + SelectTransport (Host now builds GrpcSiteTransport and injects it)
- ClusterClient creation + both ClusterClientReceptionist.RegisterService calls in
  AkkaHostedService; the RegisterCentralClient message + receive block
- CommunicationOptions.CentralContactPoints; the CentralTransport/SiteTransport
  coexistence flags; the CentralTransportMode/SiteTransportKind enums

gRPC is now the only site↔central transport (site→central CentralControlService via
GrpcCentralTransport; central→site SiteCommandService via GrpcSiteTransport), both
built unconditionally by the Host. NoOpCentralTransport is the fail-loud null-default
so TestKit command-dispatch suites still construct the site actor without a wired
transport; production always injects GrpcCentralTransport.

Config: CentralGrpcEndpoints is now unconditional — CommunicationOptionsValidator
rejects blank entries (role-agnostic), and StartupValidator requires a Site node to
list >=1 endpoint (fail-fast, mirrors GrpcPsk). Rig configs moved
CentralContactPoints -> CentralGrpcEndpoints (docker x6, docker-env2 x2, Host default,
deploy/wonder-app-vd03). Kept Akka.Cluster.Tools (ClusterSingleton still used).

Tests: build 0/0; Communication.Tests 640, Host.Tests 421 green. Removed the
ClusterClient.Send per-site-routing tests (covered by the transport suites), swapped
the ISiteClientFactory-based ctors to a substitute ISiteCommandTransport, converted
the audit-push integration relay to an in-process bridge transport.

Docs: Component-Communication/Host/StoreAndForward, components/Communication,
topology-guide, grpc_streams (SUPERSEDED note), the frame-size known-issue (retired
amendment), and CLAUDE.md transport decisions.

Not included: the dead IntegrationCallRequest path (#32) is a separate user-owned
behavioral decision — SiteEnvelope routing is transport-agnostic so it still compiles.
2026-07-23 12:54:32 -04:00
Joseph Doherty 3a8ddb7087 docs(known-issues): link IntegrationCallRequest dead-code note to Gitea #32 2026-07-23 11:52:52 -04:00
Joseph Doherty 54da10dc00 docs(grpc): Phase 3 live gate — PASS; central→site command cutover
Central flipped to SiteTransport=Grpc (both nodes, central-wide flag), sites
kept on gRPC from Phase 2 → both directions on gRPC at once. Central logs
'central→site command transport: gRPC (SiteCommandService)'; 0 ClusterClient-to-
site on either central.

Command matrix over SiteCommandService (all 200):
- ExecuteQuery (event-log) + ExecuteParked (parked) — all 3 sites
- ExecuteLifecycle disable/enable #95 on site-a — NEW live coverage vs 1B/P2
  (SoakNotify instances survived the recreate as Enabled)

Resilience:
- hard-kill the ACTIVE site-a node mid-query-loop → in-flight call returned
  TIMEOUT at exactly the 30s QueryTimeout deadline (bounded, no hang; deadline≠
  retry — an in-flight call can't be safely re-sent)
- next + all subsequent queries recovered automatically via site-a-b
  (SitePairChannelProvider NodeA→NodeB); site→central S&F never stopped
  (notif 619→715, still no dupes)
- 0 PermissionDenied across all 8 nodes

ExecuteOpcUa/ExecuteRoute/TriggerFailover deferred (no OPC-bound instance / no
CLI verb; unit-proven). Rig left both-gRPC; git reverted to Akka default.
2026-07-23 11:45:12 -04:00
Joseph Doherty a54602c14a docs(grpc): Phase 2 live gate — PASS; site→central S&F cutover soak
All 6 site nodes flipped to CentralTransport=Grpc; whole control plane
(heartbeat/health/notification S&F/audit) rides gRPC CentralControlService,
196 RPCs/90s 0 non-200, 0 PermissionDenied, 0 health-sequence regressions.

Soak driven by a live SoakNotify workload (3 instances, 3 notifs/5s):
- single-node active-kill (central-a): sticky failover central-a:8083→b:8083
  logged instantly, central-b active in 29s, buffer drained 72→101, every 5s
  bucket = exactly 3 through the gap, 101==101 distinct
- failback: central-a rejoined ready ~5s as standby, central-b kept active
  (oldest-Up, no flap), traffic uninterrupted
- full outage (both central down ~59s): count frozen, cold re-form central-b
  active ~14s, ~42 buffered drained, every 5s bucket = exactly 3 across the
  whole dead window, 216==216 distinct — zero loss, zero dupes

Also previews Phase 5 checks 4 (failover/failback) and 5 (mid-drain kill).
Rig config reverted to Akka default (defaults stay Akka until Phase 4).
2026-07-23 11:31:09 -04:00
81 changed files with 3257 additions and 1731 deletions
+3 -2
View File
@@ -134,7 +134,7 @@ 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 **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. - Inter-cluster communication uses **three** transports, not two **all cross-cluster command/control and data now rides gRPC** after the ClusterClient→gRPC migration's Phase 4 (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`) deleted Akka `ClusterClient`/`ClusterClientReceptionist`: (1) **gRPC command/control** — site→central over the central-hosted `CentralControlService` (`GrpcCentralTransport`, sticky central-a→central-b channel pair; deployments/notifications/health/heartbeat/audit-ingest/reconcile), and central→site over the site-hosted `SiteCommandService` (`GrpcSiteTransport`, per-site NodeA→NodeB channel pair; the 28 lifecycle/OPC-UA/query/parked/route/failover commands); (2) **gRPC** server-streaming for real-time data (attribute values, alarm states, `SiteStreamService`); and (3) **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. The gRPC boundary is per-site PSK-authenticated (`ControlPlaneAuthInterceptor`, unchanged). There is **no receptionist registration**discovery is by dialling configured endpoints; central builds one `SitePairChannelProvider` per site (addresses from `Site.GrpcNodeAAddress`/`GrpcNodeBAddress`, refreshed from the DB every 60s and on admin changes), sites dial `ScadaBridge:Communication:CentralGrpcEndpoints` (both central nodes, h2c on `CentralGrpcPort` 8083, **NOT** via Traefik). **Discovery is asymmetric by design:** central discovers site gRPC addresses from the *database* (refreshable at runtime), sites discover central from *appsettings* (`CentralGrpcEndpoints`, static — restart required; `StartupValidator` requires a Site node to list at least one). `Akka.Cluster.Tools` stays for ClusterSingleton; only the ClusterClient part is gone. **Central never buffers for an unreachable site** — the send fails with the caller's Ask/deadline timing out; a `ConnectionStateChanged` mechanism built for this was deleted as dead code.
- **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. - **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. - **`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. - 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.
@@ -220,6 +220,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
- 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. - **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. - **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.
- **Simultaneous-cold-start split-brain guard (opt-in, Gitea #33) — the residual self-first cost, closed.** Self-first-on-both means a *truly simultaneous* cold start (shared power/hypervisor event) races `FirstSeedNodeProcess` on BOTH nodes → two 1-node clusters that never merge (in-process loopback tests converge and hide it; real parallel VM starts don't — OtOpcUa reproduced it live). Ported from OtOpcUa (`lmxopcua` `d1dac87f`): a **dark switch `ScadaBridge:Cluster:BootstrapGuard:Enabled` (default OFF, guard-off byte-identical)**. On: `BuildHocon` emits an EMPTY seed list (Akka does not auto-join) and `ClusterBootstrapCoordinator` (`IHostedService`, registered in BOTH the Central and Site composition roots) issues ONE reachability-gated `JoinSeedNodes` — the pure `ClusterBootstrapGuard` core makes the lower canonical `host:port` the **founder** (self-first, forms immediately), the higher node TCP-probes the founder up to `PartnerProbeSeconds` (25s) and joins **peer-first** if reachable else **self-first** (cold-start-alone preserved). Decided BEFORE a single join, **never re-forms mid-handshake** (that was the rejected self-form-timer's flaw); case-insensitive founder tie-break; probe timings validated `>0` when enabled. Residual accepted trade: founder dies in the probe→join window → higher node hangs unjoined, coordinator WARNs, a restart recovers it. Tests: `ClusterBootstrapGuardTests` (pure) + `ClusterBootstrapCoordinatorTests` (real-ActorSystem, incl. both-cold-start-together-form-one-cluster). See `docs/requirements/Component-ClusterInfrastructure.md` → Simultaneous cold start.
### 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.
@@ -247,7 +248,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
### Akka.NET Conventions ### Akka.NET Conventions
- Tell for hot-path internal communication; Ask reserved for system boundaries. - Tell for hot-path internal communication; Ask reserved for system boundaries.
- ClusterClient for cross-cluster communication; ClusterClientReceptionist for service discovery across cluster boundaries. - Cross-cluster communication is gRPC (per-site PSK-authenticated): site→central `CentralControlService`, central→site `SiteCommandService`, plus the `SiteStreamService` data stream. ClusterClient/ClusterClientReceptionist were removed in the migration's Phase 4 — service discovery is by dialling configured endpoints, not the receptionist. (Akka.Cluster.Tools remains for ClusterSingleton.)
- Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory. - Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory.
- Application-level correlation IDs on all request/response messages. - Application-level correlation IDs on all request/response messages.
@@ -47,9 +47,9 @@
// node fails StartupValidator: the gate is fail-closed, so an unset key would // node fails StartupValidator: the gate is fail-closed, so an unset key would
// refuse every SiteStream call while the node still looked healthy. // refuse every SiteStream call while the node still looked healthy.
"GrpcPsk": "dev-grpc-psk-docker-env2-site-x", "GrpcPsk": "dev-grpc-psk-docker-env2-site-x",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081", "http://scadabridge-env2-central-a:8083",
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081" "http://scadabridge-env2-central-b:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
@@ -47,9 +47,9 @@
// node fails StartupValidator: the gate is fail-closed, so an unset key would // node fails StartupValidator: the gate is fail-closed, so an unset key would
// refuse every SiteStream call while the node still looked healthy. // refuse every SiteStream call while the node still looked healthy.
"GrpcPsk": "dev-grpc-psk-docker-env2-site-x", "GrpcPsk": "dev-grpc-psk-docker-env2-site-x",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081", "http://scadabridge-env2-central-a:8083",
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081" "http://scadabridge-env2-central-b:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
+3 -3
View File
@@ -48,9 +48,9 @@
// node fails StartupValidator: the gate is fail-closed, so an unset key would // node fails StartupValidator: the gate is fail-closed, so an unset key would
// refuse every SiteStream call while the node still looked healthy. // refuse every SiteStream call while the node still looked healthy.
"GrpcPsk": "dev-grpc-psk-docker-site-a", "GrpcPsk": "dev-grpc-psk-docker-site-a",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@scadabridge-central-a:8081", "http://scadabridge-central-a:8083",
"akka.tcp://scadabridge@scadabridge-central-b:8081" "http://scadabridge-central-b:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
+3 -3
View File
@@ -48,9 +48,9 @@
// node fails StartupValidator: the gate is fail-closed, so an unset key would // node fails StartupValidator: the gate is fail-closed, so an unset key would
// refuse every SiteStream call while the node still looked healthy. // refuse every SiteStream call while the node still looked healthy.
"GrpcPsk": "dev-grpc-psk-docker-site-a", "GrpcPsk": "dev-grpc-psk-docker-site-a",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@scadabridge-central-a:8081", "http://scadabridge-central-a:8083",
"akka.tcp://scadabridge@scadabridge-central-b:8081" "http://scadabridge-central-b:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
+3 -3
View File
@@ -48,9 +48,9 @@
// node fails StartupValidator: the gate is fail-closed, so an unset key would // node fails StartupValidator: the gate is fail-closed, so an unset key would
// refuse every SiteStream call while the node still looked healthy. // refuse every SiteStream call while the node still looked healthy.
"GrpcPsk": "dev-grpc-psk-docker-site-b", "GrpcPsk": "dev-grpc-psk-docker-site-b",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@scadabridge-central-a:8081", "http://scadabridge-central-a:8083",
"akka.tcp://scadabridge@scadabridge-central-b:8081" "http://scadabridge-central-b:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
+3 -3
View File
@@ -48,9 +48,9 @@
// node fails StartupValidator: the gate is fail-closed, so an unset key would // node fails StartupValidator: the gate is fail-closed, so an unset key would
// refuse every SiteStream call while the node still looked healthy. // refuse every SiteStream call while the node still looked healthy.
"GrpcPsk": "dev-grpc-psk-docker-site-b", "GrpcPsk": "dev-grpc-psk-docker-site-b",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@scadabridge-central-a:8081", "http://scadabridge-central-a:8083",
"akka.tcp://scadabridge@scadabridge-central-b:8081" "http://scadabridge-central-b:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
+3 -3
View File
@@ -48,9 +48,9 @@
// node fails StartupValidator: the gate is fail-closed, so an unset key would // node fails StartupValidator: the gate is fail-closed, so an unset key would
// refuse every SiteStream call while the node still looked healthy. // refuse every SiteStream call while the node still looked healthy.
"GrpcPsk": "dev-grpc-psk-docker-site-c", "GrpcPsk": "dev-grpc-psk-docker-site-c",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@scadabridge-central-a:8081", "http://scadabridge-central-a:8083",
"akka.tcp://scadabridge@scadabridge-central-b:8081" "http://scadabridge-central-b:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
+3 -3
View File
@@ -48,9 +48,9 @@
// node fails StartupValidator: the gate is fail-closed, so an unset key would // node fails StartupValidator: the gate is fail-closed, so an unset key would
// refuse every SiteStream call while the node still looked healthy. // refuse every SiteStream call while the node still looked healthy.
"GrpcPsk": "dev-grpc-psk-docker-site-c", "GrpcPsk": "dev-grpc-psk-docker-site-c",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@scadabridge-central-a:8081", "http://scadabridge-central-a:8083",
"akka.tcp://scadabridge@scadabridge-central-b:8081" "http://scadabridge-central-b:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
+21 -18
View File
@@ -1,6 +1,6 @@
# CentralSite Communication # CentralSite Communication
The CentralSite 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`. The CentralSite Communication component is the transport layer that connects the central cluster to every site cluster. It provides three independent transports, **all now gRPC-based across the boundary** (Akka `ClusterClient` was removed in Phase 4 of the ClusterClient→gRPC migration, 2026-07-23): gRPC command/control in both directions (`CentralControlService` on central via `GrpcCentralTransport`; `SiteCommandService` on the site via `GrpcSiteTransport`), gRPC server-streaming for real-time data (`SiteStreamService`), and plain token-gated HTTP for the deployment-config fetch. Cross-cluster Akka remoting and `ClusterClientReceptionist` are gone; Akka remoting is now intra-cluster only.
## Overview ## Overview
@@ -22,25 +22,26 @@ DI registration is called from the Host composition root via `AddCommunication`.
| Transport | Who dials | Data direction | Purpose | | Transport | Who dials | Data direction | Purpose |
|-----------|-----------|----------------|---------| |-----------|-----------|----------------|---------|
| Akka.NET `ClusterClient` | both (central → site per site; site → central) | bidirectional | Deploy notifies, lifecycle, subscribe/unsubscribe handshake, snapshots, heartbeats, health reports, telemetry, notifications | | gRPC command/control (`CentralControlService`) | **site dials central** | site → central | Heartbeats, health reports, notification submit/status, audit + cached-telemetry ingest, reconcile |
| gRPC command/control (`SiteCommandService`) | **central dials the site** | central → site | Deploy notifies, lifecycle, OPC UA, remote queries, subscribe/unsubscribe handshake, snapshots, parked, route, failover |
| 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 | | 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` | | 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 command/control calls, 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. **`SiteStreamService`'s gRPC dial direction is inverted from its data direction.** Values flow site → central, but each **site node hosts the `SiteStreamService` server** and **central is the client**. Command/control is different: it runs on its own two services — `CentralControlService` (central-hosted, site dials in) and `SiteCommandService` (site-hosted, central dials in). So a central node now DOES host a gRPC server (`CentralControlService`), unlike before Phase 4. The two legacy `Ingest*` unary RPCs on the site-hosted `SiteStreamService` remain dead in the shipped topology (no site dials a site for ingest); audit telemetry is pushed site → central over `CentralControlService`, and central pulls with `PullAuditEvents` / `PullSiteCalls` by dialling the site's `SiteStreamService`.
**No transport carries transport encryption; two of the three now carry authentication.** **No transport carries transport encryption; two of the three now carry authentication.**
- **Akka remoting / `ClusterClient` — unauthenticated.** `BuildHocon` emits no `enable-ssl`, no secure cookie and no `trusted-selection-paths`, so the command/control path is plaintext and open to anything that can reach the remoting port. - **Akka remoting — unauthenticated, but intra-cluster only.** `BuildHocon` emits no `enable-ssl`, no secure cookie and no `trusted-selection-paths`, so remoting is plaintext and open to anything that can reach the remoting port — but as of Phase 4 remoting no longer crosses the site↔central boundary, so this exposure is pair-internal.
- **gRPC — authenticated by preshared key since 2026-07-22.** The listener is still **h2c**`ListenAnyIP(grpcPort, o => o.Protocols = HttpProtocols.Http2)` with no `UseHttps` — but `ControlPlaneAuthInterceptor` gates every method under `/sitestream.SiteStreamService/`, including the `PullAuditEvents` / `PullSiteCalls` RPCs that return audit rows. It is fail-closed (no key ⇒ everything refused, and `StartupValidator` will not boot a site node in that state), compares with `CryptographicOperations.FixedTimeEquals`, and rejects with `PermissionDenied`. Central attaches the key via `ControlPlaneCredentials`, which binds `CallCredentials` to each channel so unary and streaming calls are covered uniformly`SiteStreamGrpcClient`, `GrpcPullAuditEventsInvoker` and `GrpcPullSiteCallsInvoker` all build their channels through it. Keys are **per site** (`SB-GRPC-PSK-<siteId>`), so a compromised site yields only its own. `LocalDbSyncAuthInterceptor` shares the listener and keeps its own separate key on `/localdb_sync.v1.LocalDbSync/` — the two authenticate different peers (central vs. the pair partner) and are never shared. - **gRPC — authenticated by preshared key since 2026-07-22.** The listeners are still **h2c**`ListenAnyIP(grpcPort, o => o.Protocols = HttpProtocols.Http2)` with no `UseHttps` — but `ControlPlaneAuthInterceptor` gates the command/control and streaming services alike: every method under `/sitestream.SiteStreamService/` (including the `PullAuditEvents` / `PullSiteCalls` RPCs that return audit rows) **and** the Phase-4 `CentralControlService` (site→central) / `SiteCommandService` (central→site). It is fail-closed (no key ⇒ everything refused, and `StartupValidator` will not boot a site node in that state), compares with `CryptographicOperations.FixedTimeEquals`, and rejects with `PermissionDenied`. The caller attaches the key via `ControlPlaneCredentials`, which binds `CallCredentials` to each channel so unary and streaming calls are covered uniformly. Keys are **per site** (`SB-GRPC-PSK-<siteId>`), so a compromised site yields only its own. `LocalDbSyncAuthInterceptor` shares the listener and keeps its own separate key on `/localdb_sync.v1.LocalDbSync/` — the two authenticate different peers (central vs. the pair partner) and are never shared.
- **HTTP config fetch — token-authenticated.** Its per-deployment token is the *entire* security boundary (the endpoint is `AllowAnonymous`). - **HTTP config fetch — token-authenticated.** Its per-deployment token is the *entire* security boundary (the endpoint is `AllowAnonymous`).
A bearer PSK over plaintext h2c is readable and replayable by anyone on the path, so the design still assumes a trusted network between central and sites — but the bar is now "read the traffic" rather than "reach the port". TLS on these listeners is the follow-on hardening and would not change the key design. Operational detail: [`docs/deployment/topology-guide.md`](../deployment/topology-guide.md). A bearer PSK over plaintext h2c is readable and replayable by anyone on the path, so the design still assumes a trusted network between central and sites — but the bar is now "read the traffic" rather than "reach the port". TLS on these listeners is the follow-on hardening and would not change the key design. Operational detail: [`docs/deployment/topology-guide.md`](../deployment/topology-guide.md).
### Notify-and-fetch: the deployment-config HTTP path ### 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: An instance deployment does not carry its flattened configuration inside the command message. Central stages a `PendingDeployment` row (config JSON + a freshly generated `DeploymentFetchToken` + a TTL) and sends only a small `RefreshDeploymentCommand` over gRPC command/control (`SiteCommandService`), 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 ```csharp
// SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs // SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs
@@ -49,11 +50,11 @@ using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Add("X-Deployment-Token", token); 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. `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 path was originally introduced because a flattened config could exceed the default 128 KB Akka frame size when command/control rode ClusterClient — the oversized message was silently dropped and the deploy hung to its timeout (see `docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`). That Akka frame limit no longer applies now that command/control rides gRPC (`SiteCommandService`, 4 MB cap), but notify-and-fetch remains the deploy path. `DeployArtifactsCommand` was **not** moved to this path and still carries its payload inline — now over gRPC, so it is no longer at risk of the 128 KB drop.
### 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 `GrpcSiteTransport` channel pair per site (dialling `SiteCommandService`); each site maintains a single `GrpcCentralTransport` channel pair pointed at both central nodes (dialling `CentralControlService`).
### `SiteEnvelope` routing ### `SiteEnvelope` routing
@@ -94,12 +95,14 @@ If a site is unreachable when a command arrives, the caller's Ask times out. Cen
## Architecture ## Architecture
> **Phase 4 note (2026-07-23).** The `ClusterClient` mechanics described in this section — `SiteEnvelope` routing via `ClusterClient.Send("/user/site-communication", …)`, one `ClusterClient` per site on `CentralCommunicationActor`, the site's outbound `_centralClient`, and `ClusterClientReceptionist` registration — were **replaced by gRPC command/control** in Phase 4 of the ClusterClient→gRPC migration. Central→site commands now go over `GrpcSiteTransport` → the site-hosted `SiteCommandService` (per-site NodeA→NodeB failover channel pair), and site→central messages over `GrpcCentralTransport` → the central-hosted `CentralControlService` (sticky central-a→central-b pair). Endpoints are dialled directly (no receptionist, no cross-cluster actor discovery). The per-site address load below still runs, but it now feeds a **per-site gRPC-endpoint cache** (from `GrpcNodeAAddress`/`GrpcNodeBAddress`) consumed by a `SitePairChannelProvider`, not a `ClusterClient` contact set. See [Component-Communication.md](../requirements/Component-Communication.md) for the current design.
### Central-side: `CentralCommunicationActor` ### Central-side: `CentralCommunicationActor`
`CentralCommunicationActor` is a `ReceiveActor` created at `/user/central-communication` and registered with `ClusterClientReceptionist` so the site's `ClusterClient` can locate it. It owns: `CentralCommunicationActor` is a `ReceiveActor` created at `/user/central-communication` and registered with `ClusterClientReceptionist` so the site's `ClusterClient` can locate it. It owns:
- A `Dictionary<string, (IActorRef Client, ImmutableHashSet<string> ContactAddresses)>` keyed by site identifier — one `ClusterClient` per site. - A `Dictionary<string, (IActorRef Client, ImmutableHashSet<string> ContactAddresses)>` keyed by site identifier — one `ClusterClient` per site.
- A `RefreshSiteAddresses` periodic timer (60-second cadence, starting immediately). Each tick fires `LoadSiteAddressesFromDb`, which reads every `Site` row from the database, parses `NodeAAddress` and `NodeBAddress` into Akka receptionist paths (`{addr}/system/receptionist`), and pipes a `SiteAddressCacheLoaded` message back to Self. `HandleSiteAddressCacheLoaded` creates, updates, or stops `ClusterClient` actors based on the diff. - A `RefreshSiteAddresses` periodic timer (60-second cadence, starting immediately). Each tick fires `LoadSiteAddressesFromDb`, which reads every `Site` row from the database and (as of Phase 4) builds a **per-site gRPC-endpoint cache** from `GrpcNodeAAddress`/`GrpcNodeBAddress`, piping a `SiteAddressCacheLoaded` message back to Self. `HandleSiteAddressCacheLoaded` reconciles the diff, updating the `SitePairChannelProvider` that `GrpcSiteTransport` uses to dial each site's `SiteCommandService`. (Before Phase 4 this parsed the Akka `NodeAAddress`/`NodeBAddress` into receptionist paths and created/updated/stopped a `ClusterClient` per site.)
- Proxy references to `NotificationOutboxActor` and `AuditLogIngestActor` cluster singletons, injected post-construction via `RegisterNotificationOutbox` / `RegisterAuditIngest` messages from the Host. Messages that arrive before the proxy is registered are answered with a non-accepted ack (notifications) or an empty reply (audit), so the site retries without data loss. - Proxy references to `NotificationOutboxActor` and `AuditLogIngestActor` cluster singletons, injected post-construction via `RegisterNotificationOutbox` / `RegisterAuditIngest` messages from the Host. Messages that arrive before the proxy is registered are answered with a non-accepted ack (notifications) or an empty reply (audit), so the site retries without data loss.
- Fanout of `SiteHealthReport` to the peer central node via `DistributedPubSub`, keyed on the `site-health-replica` topic, so both central nodes' aggregators stay in sync regardless of which central node the site's `ClusterClient` load-balanced the report to. - Fanout of `SiteHealthReport` to the peer central node via `DistributedPubSub`, keyed on the `site-health-replica` topic, so both central nodes' aggregators stay in sync regardless of which central node the site's `ClusterClient` load-balanced the report to.
@@ -212,7 +215,7 @@ Central callers interact through `CommunicationService`, which wraps each comman
| Instance deployment (notify-and-fetch) | `RefreshDeploymentAsync` | 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 (Inbound API routed-site-script) | `RouteToCallAsync`, `RouteToGetAttributesAsync`, `RouteToSetAttributesAsync`, `RouteToWaitForAttributeAsync` | 30 s (`IntegrationTimeout`) |
| Debug snapshot | `RequestDebugSnapshotAsync` | 30 s | | Debug snapshot | `RequestDebugSnapshotAsync` | 30 s |
| Remote queries | `QueryEventLogsAsync`, `QueryParkedMessagesAsync`, etc. | 30 s | | Remote queries | `QueryEventLogsAsync`, `QueryParkedMessagesAsync`, etc. | 30 s |
| OPC UA tag browse | `BrowseNodeAsync` | 30 s | | OPC UA tag browse | `BrowseNodeAsync` | 30 s |
@@ -236,7 +239,7 @@ All options are bound from the `ScadaBridge:Communication` section via `Communic
| `IntegrationTimeout` | `00:00:30` | Ask timeout for integration routing and Inbound API routing. | | `IntegrationTimeout` | `00:00:30` | Ask timeout for integration routing and Inbound API routing. |
| `DebugViewTimeout` | `00:00:10` | Ask timeout for debug subscribe/unsubscribe handshake. | | `DebugViewTimeout` | `00:00:10` | Ask timeout for debug subscribe/unsubscribe handshake. |
| `NotificationForwardTimeout` | `00:00:30` | Ask timeout for notification submission forwarding. | | `NotificationForwardTimeout` | `00:00:30` | Ask timeout for notification submission forwarding. |
| `CentralContactPoints` | `[]` | Site-side: Akka addresses of central nodes, e.g. `akka.tcp://scadabridge@central-a:8081`. | | `CentralGrpcEndpoints` | `[]` | Site-side: gRPC h2c endpoints of the central nodes' `CentralControlService`, e.g. `http://scadabridge-central-a:8083` (central's `CentralGrpcPort`, default 8083 — direct, not via Traefik, which is HTTP/1 only). **Required on Site nodes** (at least one); empty on Central nodes, which host `CentralControlService` and do not dial it. Replaces the former `CentralContactPoints` Akka-address list. |
| `GrpcKeepAlivePingDelay` | `00:00:15` | HTTP/2 keepalive PING interval on `SiteStreamGrpcClient`. | | `GrpcKeepAlivePingDelay` | `00:00:15` | HTTP/2 keepalive PING interval on `SiteStreamGrpcClient`. |
| `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. |
@@ -259,27 +262,27 @@ 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 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. - [Cluster Infrastructure (#13)](./ClusterInfrastructure.md) — provides ClusterSingleton and the oldest-`Up` active/standby model that `SiteCommunicationActor`'s `IsActive` stamp depends on, plus the single `"scadabridge"` `ActorSystem` name for intra-cluster remoting. (`ClusterClientReceptionist` is no longer used — cross-cluster messaging is gRPC as of Phase 4.) `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 13. `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. - [Deployment Manager (#2)](./DeploymentManager.md) — the primary consumer of command/control patterns 13. `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 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. - [Audit Log (#23)](./AuditLog.md) — `SiteStreamGrpcServer` hosts the `IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents` and `PullSiteCalls` RPCs. The `Ingest*` pair on the site-hosted service stays unused in the shipped topology; sites push audit telemetry site→central over gRPC to `CentralControlService`, which 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's `SiteStreamService`.
- [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` runs at `/user/management` on central and is reached **in-process** through `ManagementActorHolder`; the CLI connects over HTTP, not `ClusterClient`. (It was `ClusterClientReceptionist`-registered until 2026-07-22, for a CLI that was never built that way.) So this component's `ClusterClient` usage is exclusively the inter-cluster hub-and-spoke connections. 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`. - [Management Service (#18)](./ManagementService.md) — `ManagementActor` runs at `/user/management` on central and is reached **in-process** through `ManagementActorHolder`; the CLI connects over HTTP, not any cluster transport. (It was `ClusterClientReceptionist`-registered until 2026-07-22, for a CLI that was never built that way.) After Phase 4 this component uses no `ClusterClient` at all — its cross-cluster connections are the gRPC `CentralControlService` / `SiteCommandService` transports. Management Service also hosts `DeploymentConfigEndpoints` — the `GET /api/internal/deployments/{id}/config` route that terminates the HTTP deploy-config 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
### A site's commands fail immediately ### A site's commands fail immediately
Check that `NodeAAddress` and `NodeBAddress` are populated in the site configuration — if both are empty, `CentralCommunicationActor` logs a warning and skips that site on every refresh, so no `ClusterClient` is created and all commands timeout. `CommunicationService.RefreshSiteAddresses()` triggers an on-demand refresh after an address is added. Check that `GrpcNodeAAddress` and `GrpcNodeBAddress` are populated in the site configuration — if both are empty, `CentralCommunicationActor` logs a warning and skips that site on every refresh, so no `SiteCommandService` channel is built and all commands timeout. `CommunicationService.RefreshSiteAddresses()` triggers an on-demand refresh after an address is added.
### Commands are timing out but the site is reachable ### Commands are timing out but the site is reachable
A single malformed address string for one site can silently prevent `ClusterClient` creation for that site while other sites are unaffected. Check the logs for a `Warning` line from `HandleSiteAddressCacheLoaded` naming the offending site. The actor parse-guard catches the `ActorPath.Parse` exception per-site so the rest of the refresh proceeds. A single malformed gRPC endpoint string for one site can silently prevent channel creation for that site while other sites are unaffected. Check the logs for a `Warning` line from `HandleSiteAddressCacheLoaded` naming the offending site. The per-site parse-guard skips the bad entry so the rest of the refresh proceeds.
A `Warning` at the `Status.Failure` handler in `CentralCommunicationActor` means `LoadSiteAddressesFromDb` itself threw (typically a SQL connection error); the cache is left stale until the next successful refresh. A `Warning` at the `Status.Failure` handler in `CentralCommunicationActor` means `LoadSiteAddressesFromDb` itself threw (typically a SQL connection error); the cache is left stale until the next successful refresh.
@@ -291,7 +294,7 @@ After a site node failover, the `DebugStreamBridgeActor` attempts to reconnect t
### Deployments fail immediately with a config-fetch error ### 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". The site received the `RefreshDeploymentCommand` over gRPC command/control (`SiteCommandService`) 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
+15 -7
View File
@@ -178,14 +178,22 @@ database from its peer rather than letting it rejoin.
### Central-Site Communication ### Central-Site Communication
Three transports cross the boundary, not one: Three transports cross the boundary, not one — **all now gRPC or HTTP; Akka ClusterClient was removed
in Phase 4 of the ClusterClient→gRPC migration (2026-07-23), and Akka remoting no longer crosses the
boundary at all:**
- **Akka ClusterClient** — command/control. Sites list every central node in - **gRPC command/control** — both directions, on sticky-failover channel pairs, dialled directly (no
`ScadaBridge:Communication:CentralContactPoints`; contact rotation reaches whichever node receptionist, no "active central" to identify — each side dials both of the peer's node endpoints):
answers, so no "active central" needs to be identified. (There is no `Communication:CentralSeedNode` - *Site → central* to the central-hosted **`CentralControlService`** (`GrpcCentralTransport`): the
setting — earlier revisions of this guide named one that never existed in the code.) site lists the central nodes' gRPC endpoints in `ScadaBridge:Communication:CentralGrpcEndpoints`
- **gRPC** — real-time data and audit pull. Note the direction is inverted from the data flow: (e.g. `http://scadabridge-central-a:8083`, the central's `CentralGrpcPort`, default 8083 — direct
each **site node hosts the gRPC server** on `GrpcPort` (default 8083, h2c) and central dials in. h2c, **not** via Traefik, which is HTTP/1 only). A Site node must list at least one; central nodes
leave it empty.
- *Central → site* to the site-hosted **`SiteCommandService`** (`GrpcSiteTransport`): central dials
the site's `GrpcNodeAAddress` / `GrpcNodeBAddress` (from the Site entity), NodeA→NodeB failover.
- **gRPC streaming + audit pull** — real-time data and audit/telemetry pull on the site-hosted
**`SiteStreamService`**. Note the direction is inverted from the data flow: each **site node hosts
the server** on `GrpcPort` (default 8083, h2c) and central dials in.
- **Plain HTTP** — the deploy config itself, fetched by the site with a per-deployment token. - **Plain HTTP** — the deploy config itself, fetched by the site with a per-deployment token.
#### gRPC control-plane preshared key (required) #### gRPC control-plane preshared key (required)
@@ -31,6 +31,25 @@ Any deployment replicating wide rows must size that key deliberately; see the Ph
Note the failure mode differs from the one documented below: an oversized gRPC message is Note the failure mode differs from the one documented below: an oversized gRPC message is
**rejected**, not silently dropped. **rejected**, not silently dropped.
## Amendment (2026-07-23) — Akka frame-size class RETIRED for site↔central command/control
Phase 4 of the ClusterClient→gRPC migration deleted the Akka `ClusterClient` site↔central transport
entirely. **All site↔central command/control now rides gRPC** — central→site over `SiteCommandService`
(`GrpcSiteTransport`) and site→central over `CentralControlService` (`GrpcCentralTransport`), each with
the gRPC default 4 MB message cap and **no per-message Akka frame limit**. Consequently:
- The 128 KB Akka `maximum-frame-size` constraint **no longer applies to any site↔central command
message**, including `DeployArtifactsCommand`, which still carries its payload inline but now travels
over gRPC.
- The **silent frame-drop failure mode** described below — the transport dropping one oversized message
while heartbeats keep flowing and the deploy hangs to its timeout — **cannot occur on that path any
more.** An over-cap gRPC message is *rejected* with an error, not silently discarded.
- The notify-and-fetch deploy path (the 2026-06-26 resolution) still stands and remains the deploy
mechanism; it is simply no longer the *only* thing keeping large payloads off a frame-limited hop.
- Akka remoting is now intra-cluster only, so its frame size governs only pair-internal traffic.
See `docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`.
The diagnosis below is retained as the historical record of how the bug was found and reasoned about. The diagnosis below is retained as the historical record of how the bug was found and reasoned about.
## Summary ## Summary
@@ -1,7 +1,16 @@
# Integration call routing (`IntegrationCallRequest`) is dead on both ends # Integration call routing (`IntegrationCallRequest`) is dead on both ends
**Date:** 2026-07-22 · **Status:** OPEN (decision needed: wire or delete) · **Severity:** Low (no **Date:** 2026-07-22 · **Status:** RESOLVED (DELETED 2026-07-23) · **Tracked:** Gitea
runtime impact — the path cannot be reached) · **Area:** CentralSite Communication [#32](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/32) (filed 2026-07-23) · **Severity:**
Low (no runtime impact — the path cannot be reached) · **Area:** CentralSite Communication
> **Resolution (2026-07-23):** Decision = **delete** (option 1 below). Removed the
> `IntegrationCallRequest`/`IntegrationCallResponse` messages, `CommunicationService.RouteIntegrationCallAsync`,
> the `SiteCommunicationActor` receive block + `_integrationHandler` field + `LocalHandlerType.Integration`,
> and the four tests that covered them. `IntegrationTimeout` was **kept** — it is the live timeout for the
> Inbound API's `RouteTo*` verbs, which are the actual implementation of this "External → Central → Site →
> Central" pattern (design §4). The narrative comments that documented the exclusion (proto/mapper/dispatcher)
> were updated. Full solution build clean; Communication suite 634 green. This note is retained as the record.
## What ## What
@@ -234,3 +234,309 @@ plan-vs-code finding, recorded rather than worked around.
The instance-dependent matrix (tag ops, lifecycle, standby parked retry) and `TriggerSiteFailover` The instance-dependent matrix (tag ops, lifecycle, standby parked retry) and `TriggerSiteFailover`
get their live exercise in Phase 3's full UI command matrix; the transport itself is proven here. get their live exercise in Phase 3's full UI command matrix; the transport itself is proven here.
---
## Phase 2 — full site→central cutover + S&F soak — **PASS** (2026-07-23)
All three sites (**all 6 nodes**) flipped to `CentralTransport=Grpc` (edit to
`docker/site-*/appsettings.Site.json`, reverted in git after the gate — defaults stay Akka),
rebuilt from `main` + `--force-recreate`. Central left on `SiteTransport=Akka` (Phase 3 owns
that direction). The gate was driven by a **live S&F workload**, closing the notification/audit
path that 1A/1B could only unit-prove.
### S&F driver
A minimal dependency-free template `SoakNotify` (one 5 s `Interval` script:
`Notify.To("Engineering Alerts").Send(...)`), 3 instances deployed+enabled on site-a → a steady
**3 notifications per 5 s bucket** (the pre-existing `Motor Controller` "soak-motor" instances
need 30 OPC UA bindings and were unusable). The central `dbo.Notifications` table (one row per
`NotificationId`, insert-if-not-exists) is the no-loss/no-dupes source of truth.
### Checks
| # | Check | Result |
|---|---|---|
| 1 | All 6 site nodes on `CentralTransport=Grpc` | **PASS** — each logs `Site→central transport: gRPC to 2 central endpoint(s)`; **0** `PermissionDenied` across all six for the whole run |
| 2 | Full control plane rides gRPC `CentralControlService` | **PASS** — central sees `Heartbeat`, `ReportSiteHealth`, `SubmitNotification` (the S&F path), `IngestAuditEvents`**196 RPCs / 90 s, 0 non-200** |
| 3 | Health/heartbeat cadence unchanged, no sequence regressions | **PASS**`CentralHealthAggregator` logged **0** sequence-regression/out-of-order lines; heartbeat steady at ~144/30 s across 3 sites |
| 4 | **Single-node failover** — hard-kill the **active** central (central-a) | **PASS**`CentralChannelProvider` logged sticky failover `central-a:8083 → central-b:8083` at the instant of kill; central-b active in **29 s** (auto-down); notif count froze at 72 during the gap |
| 5 | Buffer drains, no loss/dupes (single-node) | **PASS** — count resumed 72→101; **every 5 s bucket through the outage = exactly 3**, no gap; 101 total == 101 distinct |
| 6 | **Failback** — restart central-a | **PASS** — rejoined **ready in ~5 s as standby** (`active=503`); central-b **retained active** (oldest-Up, no role flap); traffic uninterrupted (uniform 3/bucket across failback); central-a singletons → `Younger` |
| 7 | **Full central outage** — hard-kill **both** central (~59 s) | **PASS** — count frozen at 155 for the entire outage; sites buffered continuously |
| 8 | Cold re-form + drain, no loss/dupes (both-down) | **PASS** — cold cluster re-formed, central-b active in **~14 s**; ~42 buffered notifications drained; **every 5 s bucket across the whole ~59 s both-dead window = exactly 3**, no gap; **216 total == 216 distinct** |
Checks 48 also stand as a live preview of Phase 5 checks 4 (failover/failback) and 5 (mid-drain
kill, zero loss/zero dupes).
### Notes
- **Audit telemetry also rides the new gRPC `CentralControlService`** (`IngestAuditEvents`) — the
`SiteAuditTelemetryActor` "ClusterClientSiteAuditClient" label is legacy naming, not the wire
path. So `CentralTransport=Grpc` moves heartbeat, health, notification S&F **and** audit off
ClusterClient in one flip.
- Sites settled on **central-a** as the gRPC endpoint after the cold both-restart while **central-b**
held the active/singleton role — the gRPC endpoint node and the singleton host can differ; central-a
receives the forward and Akka-routes to the `NotificationOutboxActor` singleton on central-b. Both
are correct and independent.
- Some notifications land `Parked` at central (no SMTP config on the bare rig) — irrelevant to the
transport proof: the `Notifications` **row** is written on forward regardless of downstream SMTP
delivery, so the count is a faithful no-loss/no-dupes measure.
- Rig left running on the gRPC build; git config reverted to Akka default (a redeploy from `main`
resets to all-Akka).
---
## Phase 3 — full central→site cutover + command matrix — **PASS** (2026-07-23)
Central flipped to `SiteTransport=Grpc` (both central nodes; the flag is **central-wide**
`CentralCommunicationActor.SelectTransport`). Sites kept on `CentralTransport=Grpc` from Phase 2,
so the rig ran **both directions on gRPC simultaneously** — the eventual all-gRPC end state.
Config bind-mounted (`:ro`), so a container `--force-recreate` (no image rebuild — code unchanged
since Phase 2) applied it. Central logged the selection at startup:
`central→site command transport: gRPC (SiteCommandService)`.
The Phase 2 `SoakNotify` instances (#9597) **survived the recreate as Enabled** (site volume
persisted `deployed_configurations` this time), so the notification workload kept flowing —
and lifecycle commands became drivable, closing the 1B/Phase-2 gap.
### Checks
| # | Check | Result |
|---|---|---|
| 1 | Central selects the gRPC command transport | **PASS**`central→site command transport: gRPC (SiteCommandService)`; **0** `Created ClusterClient to` site lines on either central |
| 2 | `ExecuteQuery` (event-log) over gRPC, all 3 sites | **PASS** — all 3 returned correlationIds; site logs `SiteCommandService/ExecuteQuery - 200` (site-a-a/b-a/c-a each served the calls; standby nodes 0 — work lands on the active site node via the singleton proxy) |
| 3 | `ExecuteParked` (parked query) over gRPC, all 3 sites | **PASS** — all 3 returned `success` payloads over `SiteCommandService/ExecuteParked` |
| 4 | `ExecuteLifecycle` (disable→enable) over gRPC, site-a | **PASS**`instance disable`/`enable #95``success:true`; site served `SiteCommandService/ExecuteLifecycle` (×4), instance state toggled — **NEW live coverage vs 1B/Phase-2 (needed a deployed instance)** |
| 5 | **Site-node kill mid-command → clean error, no hang** | **PASS** — killed the **active** site-a node (site-a-a) during a 1 s query loop: the in-flight call returned `TIMEOUT` at **dur=30.1 s = the `QueryTimeout` deadline** — bounded, not an indefinite hang (see note) |
| 6 | **Site-pair failover mid-stream** | **PASS** — the very next query (~32 s after kill) and all subsequent ones succeeded automatically via **site-a-b**; `SitePairChannelProvider` failed the gRPC channel NodeA→NodeB and the site singleton migrated; site→central S&F never stopped (notif count climbed 619→715 through the kill, still no dupes) |
| 7 | No PSK drift | **PASS****0** `PermissionDenied`/`Unauthenticated` across all 8 nodes for the whole run |
| 8 | Zero ClusterClient activity on the flipped path | **PASS** — central built no site ClusterClients; command routing is entirely `SiteCommandService` gRPC |
### Note on check 5 (deadline vs fast-fail)
The command in flight when site-a-a was **hard-killed** (SIGKILL) waited the full 30 s
`QueryTimeout` rather than failing fast on connect-refused: an already-dispatched gRPC call on a
dropped connection isn't observed as unsent, so it correctly cannot be auto-retried on the peer
node (it might have executed) and returns the deadline error to the caller — exactly the plan's
"deadline ≠ retry" rule. The deadline is the backstop; "no hang beyond deadline" is satisfied
(30.1 s). Only **provably-unsent** connect failures fail over fast, which is why every *subsequent*
call recovered immediately via site-a-b.
### Not driven on this gate (unchanged from 1B/Phase 2)
- **`ExecuteOpcUa`** (BrowseNode/ReadTagValues/WriteTag) — needs an OPC-bound deployed instance;
the bare rig's only deployable template (`SoakNotify`) has no data connection, and `Motor
Controller` needs 30 OPC UA bindings. Unit-proven (dispatcher routing ×28).
- **`ExecuteRoute`** (inbound-API → routed site script) — needs an inbound method + routing target
the rig lacks.
- **`TriggerFailover`** — no CLI verb (UI/management-only), destructive; unit-proven (ack-before-
`Leave` ordering tests). The hard-kill in check 6 is the live equivalent of a site-pair failover.
Rig left running with **both** transports on gRPC; git config reverted to Akka default (a redeploy
from `main` resets to all-Akka).
---
## Phase 5 — full eight-check gate on the deletion build — **PASS** (2026-07-23)
The migration's terminal gate, run on `main` **after Phase 4** (`7fd5cb2b`) — the build where the
Akka `ClusterClient`/`ClusterClientReceptionist` transport is **physically deleted**, the
`CentralTransport`/`SiteTransport` flags are gone, and gRPC is the *only* site↔central transport
(no flags to flip). Rig rebuilt from `main` via `bash docker/deploy.sh`, all 9 containers recreated
from the new image; central MS SQL and the S&F driver (`SoakNotify` template #2147 / instances
#9597 / list "Engineering Alerts" #28) persisted from prior phases.
**Provenance guard:** the pre-existing rig image was built `11:17`, *before* the Phase 4 commit
(`12:54`); it still carried ClusterClient code and would have invalidated checks 78. Confirmed
by rebuild timestamp, then by **0** ClusterClient/receptionist log lines across all 8 nodes on the
fresh boot. (The lone `ClusterClientReceptionist` symbol still present is inside
`Akka.Cluster.Tools.dll` — the library we deliberately keep for `ClusterSingleton` — not our code.)
Header names for the negative probes: `authorization: Bearer <psk>` + `x-scadabridge-site: <siteId>`
(`ISitePskProvider.cs`). Docker rig PSKs: `dev-grpc-psk-docker-site-{a,b,c}`
(`ScadaBridge__Communication__SitePsks__site-*`, central override; sites read their own
`GrpcPsk` from mounted `appsettings.Site.json`).
### Checks
| # | Check | Result |
|---|---|---|
| 1 | **PSK negatives** | **PASS (with a contract clarification)** — see below |
| 2 | **Site→central matrix** | **PASS** — notifications, both audit paths, health, heartbeat, reconcile all live over gRPC |
| 3 | **Central→site matrix** | **PASS (proportionate)**`ExecuteQuery`/`ExecuteParked`/`ExecuteLifecycle` live; instance-dependent RPCs carried forward (see below) |
| 4 | **Failover / failback** | **PASS** — active-central kill → sticky flip in 1 s, central-b active in 26 s; failback kept central-b active (oldest-Up, no flap) |
| 5 | **Mid-drain kill, zero loss/dupes** | **PASS** — buffered notifications flushed; **total == distinct** across the outage |
| 6 | **Frame-class retirement (>128 KB)** | **PASS** — a single **297,574-byte** gRPC reply succeeded (Akka's 128 KB frame would have dropped it) |
| 7 | **No cross-boundary Akka association** | **PASS** — each Akka cluster's membership is strictly its own pair; **0** cross-boundary association lines both directions |
| 8 | **Full-rig restart discipline** | **PASS** — all 9 restarted together came up clean; **0** receptionist/ClusterClient lines on any node; sites reconnected over gRPC; S&F resumed with no dupes |
### Check 1 — PSK negatives (and the contract clarification)
Against the two gated services on site-a (`:9023`), using the local proto descriptors
(`-proto sitestream.proto` / `site_command.proto`; server reflection is off in this build):
```
SiteStreamService/PullAuditEvents:
no credentials -> PermissionDenied "Control plane authentication failed."
wrong key (site-b's key) -> PermissionDenied
correct key -> success (audit events returned)
SiteCommandService/ExecuteQuery:
no credentials -> PermissionDenied
wrong key (site-b's key) -> PermissionDenied
```
site-a-a's log recorded **exactly 4** control-plane rejections — the four deliberate negative
probes above, nothing else.
**Clarification the gate produced:** the plan listed "missing `x-scadabridge-site`
`PermissionDenied`" as a negative case. It does **not** hold on the *site* side, and that is
correct by design. `ControlPlaneAuthInterceptor.Authorize` compares the presented bearer against
the node's **own single** `GrpcPsk` (`_options.Value.GrpcPsk`) and never reads
`x-scadabridge-site`. That header is a **central-side routing hint**`SitePskProvider` uses it to
pick *which* site's key to expect, because central holds many. A single-key site node needs no such
hint. Per-site key isolation — the actual security property — is proven by the **wrong-key**
rejection (site-b's key refused at site-a), not by the header. Recorded rather than "fixed": adding
a header requirement to the site interceptor would be theatre.
**LocalDb sync unaffected:** the replicated node (site-a) logged one boot-order
`faulted; reconnecting` (peer node-b not yet up), the passive peer accepted the
`/localdb_sync.v1.LocalDbSync/Sync` POST 1 s later, and the health report settled to
`localDbReplicationConnected:true, localDbOplogBacklog:0`. **0** LocalDb sync auth failures — the
control-plane interceptor and the separate `LocalDbSyncAuthInterceptor` don't interfere. site-b/c
report `localDbReplicationConnected:false` — the intended rig posture (only site-a replicated).
### Check 2 — site→central matrix
- **Notifications e2e:** `dbo.Notifications` climbed continuously at the driver's ~3 rows / 5 s,
**total == distinct** at every sample (insert-if-not-exists no-loss/no-dupe truth).
- **Both audit paths live over gRPC:** in a 3-minute window, `dbo.AuditLog` carried `node-a`/`node-b`
rows (105 each — **site-originated**, forwarded over the gRPC `IngestAuditEvents` RPC) *and*
`central-a` rows (420 — central-direct-write from the outbox dispatcher). Row count climbing live.
- **Health live per site:** `health summary` shows all three sites `isOnline:true`, sequence numbers
advancing, fresh heartbeats; site-a `enabledInstanceCount:3` with a live `Notification` S&F buffer.
- **Heartbeat → active flag:** central-a `active=200`, central-b `active=503`; each site's report
drives its online flag.
- **Reconcile self-heal:** restarting `site-a-a` produced a fresh (17:11:24) `Site→central transport:
gRPC to 2 central endpoint(s)` then `Reconcile pass … complete: 0 fetched, 0 failed, 0 orphan(s)`;
S&F never dropped across the restart (active node kept emitting), no dupes.
- **Not driven (carry-forward, bare rig):** cached-call telemetry (`SoakNotify` only calls
`Notify.Send`, no `CachedCall`/`CachedWrite`); a notification reaching **Delivered** rather than
`Parked` (no SMTP on the rig — the transport truth is the row-count, per Phase 2).
### Check 3 — central→site matrix
`ExecuteQuery` (event-log, all 3 sites → correlationIds + entries), `ExecuteParked` (parked-messages,
all 3 sites → empty, no parked ops on a bare rig), and `ExecuteLifecycle` (disable→enable #95 →
`success:true` both) all rode `SiteCommandService` gRPC; site active nodes logged them
(site-a-a: 8 `ExecuteLifecycle` + 4 `ExecuteParked` + 4 `ExecuteQuery`; site-b-a/c-a:
`ExecuteParked` + `ExecuteQuery`). **Carried forward, unchanged from Phase 3** (needs a deployed
OPC-bound instance / inbound method / a parked op / the destructive UI-only failover verb the bare
rig lacks): `ExecuteOpcUa`, `ExecuteRoute`, standby parked retry/discard, `TriggerFailover`
(the hard-kill in Check 4 is its live equivalent). All four are unit-proven (dispatcher routing ×28,
ack-before-`Leave` ordering).
### Checks 4 & 5 — failover / failback / mid-drain kill
Hard-killed the **active** central (central-a) mid-drain: site-a-b logged the sticky endpoint flip
`central-a:8083 → central-b:8083` **1 s** after the kill; central-b reached active in **26 s**
(auto-down). The notification count froze during the gap, then **flushed the buffered rows and
resumed** at the steady 3 / 5 s. Post-drain **total == distinct (4395 == 4395)** — zero loss, zero
duplicates. Failback: restarting central-a, it rejoined and central-b **retained** active
(`a=503` standby / `b=200` active) — oldest-Up, no role flap.
### Check 6 — frame-class retirement
`PullAuditEvents{since_utc: 2020-01-01, batch_size: 5000}` against the active site node returned a
single **297,574-byte** reply successfully. Over Akka's default 128 KB frame
(`log-frame-size-exceeding` off) this class of payload was silently dropped and the caller's Ask
timed out (`docs/known-issues/2026-06-26-…`). On gRPC (4 MB default cap) it is a normal reply.
### Check 7 — no cross-boundary Akka association
Each Akka cluster's membership is strictly its own pair: central `[central-a, central-b]`, site-a
`[site-a-a, site-a-b]`, site-c `[site-c-a, site-c-b]` — **no site node ever appears in central's
membership and vice versa**. Central's log holds **0** `Association with remote system …site-*`
lines and **0** references to any site address; site-a's log holds **0** references to any central
address. The two boundaries are wired only by per-pair Akka remoting (8082, internal) and the gRPC
control/data planes (8083); there is no Akka association across the site↔central line.
### Check 8 — full-rig restart discipline
Restarted all 9 rig containers together (pairs together, honouring the LocalDb-replication constraint).
Central re-formed with central-a active; **0** receptionist/ClusterClient lines across all 8 nodes;
all three sites logged `Site→central transport: gRPC to 2 central endpoint(s)`; S&F resumed
(4407 → 4483, **distinct == total**, no dupes).
### End state
The migration is complete and proven on the deletion build. The rig runs the committed all-gRPC
default (Phase 4 flipped the shipped appsettings to `CentralGrpcEndpoints` — unlike Phases 2/3, a
redeploy from `main` now yields all-gRPC, not all-Akka). No ClusterClient remains anywhere in the
running system or the source tree. Deferred, unchanged from earlier phases: the instance-dependent
central→site RPC matrix (`ExecuteOpcUa`/`ExecuteRoute`/standby parked retry) and live
`TriggerFailover`, all unit-proven; PSK **rotation** on a live pair. `docker-env2` is now gated —
see below.
---
## Env2 gate — `docker-env2` on the gRPC PSK build — **PASS** (2026-07-23, Gitea #31)
The secondary Transport-testing topology (`docker-env2/`: 2 central + 1 site-x × 2 nodes, host
ports 91XX, shared infra) carried its `GrpcPsk` in config since Phase 0 but had never been
redeployed or gated against the migration build. Rebuilt `scadabridge:latest` from `main` @
`8524a7f7` (includes the #28 Transport execution-strategy fix) and recreated only the env2
containers (`bash docker-env2/deploy.sh`; the primary rig's running image was untouched). Site-x
PSK `dev-grpc-psk-docker-env2-site-x` on both site nodes matches central's
`ScadaBridge__Communication__SitePsks__site-x`.
| # | Check | Result |
|---|-------|--------|
| 1 | Both site nodes boot with the key (StartupValidator passes) | **PASS** |
| 2 | Unauthenticated control-plane call ⇒ PermissionDenied; correct key ⇒ success | **PASS** |
| 3 | LocalDb unaffected | **PASS** |
### Check 1 — both site nodes boot
`scadabridge-env2-site-x-a` and `-b` both reached `Application started` with `Now listening on:
http://[::]:8083` (the gRPC control/data listener). `StartupValidator` is fail-closed on a missing
`ScadaBridge:Communication:GrpcPsk` — a site node without it refuses to boot — so reaching
`Application started` is itself the proof the key is present and valid. Each node also logged a clean
`Reconcile pass for site site-x node node-{a,b} complete: 0 fetched, 0 failed, 0 orphan(s)`, i.e.
the site→central gRPC path was already carrying traffic.
### Check 2 — PSK auth on the control plane
`grpcurl` against the site-hosted `SiteStreamService/PullAuditEvents` on both nodes
(`localhost:9123` = site-x-a, `localhost:9124` = site-x-b):
- **No auth header** ⇒ `PermissionDenied: Control plane authentication failed.`
- **Wrong bearer key** (`Bearer WRONG-KEY`, `x-scadabridge-site: site-x`) ⇒ `PermissionDenied`.
- **Correct key** (`Bearer dev-grpc-psk-docker-env2-site-x`) ⇒ `{}` (empty audit set — the env has
no S&F driver seeded — but **not** a permission error: auth accepted). Identical on both nodes.
(Same contract clarification as the primary rig: the site interceptor authenticates the node's single
`GrpcPsk` and does not require `x-scadabridge-site`; per-site isolation is proven by the wrong-key
rejection.)
### Check 3 — LocalDb unaffected
Env2 site-x runs LocalDb **local-only** (no `LocalDb:Replication:PeerAddress` configured — the rig's
replication posture is proven on the primary `docker/` stack, not here), so "unaffected" means the
consolidated site DB stays operational on the gRPC build. Site logs hold **0** LocalDb
errors/exceptions and both nodes boot healthy with the LocalDb at `/app/data/site-localdb.db`; the
clean reconcile passes above confirm the site DB + site→central path work end to end.
### Bonus / observations
- **No real ClusterClient.** Central logs hold **0** ClusterClient/receptionist references; the site
logs' only match is the benign string label `client=ClusterClientSiteAuditClient` in a
`SiteAuditTelemetryActor created` line (a legacy identifier, **not** an Akka `ClusterClient`) —
identical to the primary rig's site nodes. No real receptionist/ClusterClient actor path exists.
- **Central registers site-x over gRPC.** `Site site-x registered online via heartbeat` — the
site→central gRPC command/control path is live.
- **Seed-data note (not a migration issue).** `ScadaBridgeConfig2.dbo.Sites` has **0 rows**, so
central logs `Reconcile request from unknown site 'site-x' … replying with empty gap` and evicts
it from the health aggregator as "no longer configured." This is a first-time-setup seeding gap
(`docker-env2/seed-sites.sh` not yet run on the fresh DB), orthogonal to the transport — which
demonstrably carries the heartbeat and reconcile regardless.
@@ -323,16 +323,17 @@ Critical path ≈ 1B: **~46 weeks total**, matching the design estimate.
- [x] 1B DoD (proportionate): rig central on `SiteTransport=Grpc` proves central→site rides authenticated gRPC `SiteCommandService` for all 3 sites (`ExecuteQuery`/`ExecuteParked` → 200, per-site PSK, 0 auth failures); rebased on 1A. Instance-dependent commands (tag ops/lifecycle/standby parked retry) + `TriggerSiteFailover` deferred to Phase 3 (no deployed instance / destructive UI-only); command-plane coexistence not expressible (`SiteTransport` is central-wide). Gate: `2026-07-22-clusterclient-to-grpc-live-gate.md` - [x] 1B DoD (proportionate): rig central on `SiteTransport=Grpc` proves central→site rides authenticated gRPC `SiteCommandService` for all 3 sites (`ExecuteQuery`/`ExecuteParked` → 200, per-site PSK, 0 auth failures); rebased on 1A. Instance-dependent commands (tag ops/lifecycle/standby parked retry) + `TriggerSiteFailover` deferred to Phase 3 (no deployed instance / destructive UI-only); command-plane coexistence not expressible (`SiteTransport` is central-wide). Gate: `2026-07-22-clusterclient-to-grpc-live-gate.md`
**Phase 2 ∥ 3 — cutover + soak** **Phase 2 ∥ 3 — cutover + soak**
- [ ] P2 All sites `CentralTransport=Grpc`; central-kill S&F soak (no loss/dupes), failback observed, health sequences clean - [x] P2 All sites `CentralTransport=Grpc`; central-kill S&F soak (no loss/dupes), failback observed, health sequences clean — **PASS 2026-07-23** (live gate: single-node active-kill failover 29s + full-outage ~59s both-down drain; every 5s bucket = exactly 3 through both outages, 216 total == 216 distinct; 0 auth failures / 0 seq regressions; whole control plane — heartbeat/health/notification/audit — on gRPC `CentralControlService`)
- [ ] 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 - [x] 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 — **PASS 2026-07-23** (live gate: `ExecuteQuery`/`ExecuteParked` all 3 sites + `ExecuteLifecycle` disable/enable on site-a, all 200 over `SiteCommandService`; active-site-node hard-kill mid-command → clean `TIMEOUT` at the 30s deadline, next call failed over to site-a-b automatically; 0 PermissionDenied / 0 ClusterClient-to-site on both central. `ExecuteOpcUa`/`ExecuteRoute`/`TriggerFailover` deferred — no OPC-bound instance / no CLI verb, unit-proven)
**Phase 4 — deletion** **Phase 4 — deletion**
- [ ] Defaults flip to `Grpc` + soak; then delete Akka transports, ClusterClient creation, `DefaultSiteClientFactory`, receptionist registrations, `CentralContactPoints`, then the flags - [x] gRPC made the only transport (flags DELETED, not flipped — end state is identical) + deleted `AkkaCentralTransport`/`AkkaSiteTransport`, ClusterClient creation (`AkkaHostedService`), `DefaultSiteClientFactory`+`ISiteClientFactory`, both receptionist registrations (`:436`/`:1001`), `CentralContactPoints` + the `CentralTransport`/`SiteTransport` flags + `CentralTransportMode`/`SiteTransportKind` enums. `NoOpCentralTransport` added as the fail-loud null-default; `CentralGrpcEndpoints` now unconditional (StartupValidator requires ≥1 on Site). Kept `Akka.Cluster.Tools` (ClusterSingleton). Rig configs (docker ×6, docker-env2 ×2, Host default, wonder-app-vd03) moved `CentralContactPoints`→`CentralGrpcEndpoints`. **Full solution build 0/0; Communication.Tests 640, Host.Tests + StartupValidator + SiteActorPath green, audit-push integration green** (2026-07-23)
- [ ] Grep-gates pass (`clusterclient|receptionist` → historical docs only; `CentralContactPoints` → empty) - [x] Grep-gates pass (`CentralContactPoints` → only "replaces the former" doc refs + plan trackers; deleted symbols → 0 live refs; remaining `clusterclient` in src = the misleadingly-named `ClusterClientSiteAuditClient` [transport-agnostic, works unchanged] + stale inline doc-comments, noted as follow-up)
- [ ] Docs updated: `grpc_streams.md`, `Component-Host.md`, `Component-StoreAndForward.md:137`, known-issues cross-ref - [x] Docs updated: `Component-Communication.md`, `components/Communication.md`, `Component-Host.md`, `Component-StoreAndForward.md`, `topology-guide.md`, `grpc_streams.md`, known-issues frame-size amendment, **CLAUDE.md** transport decisions
**Phase 5 — live gate** (record in `2026-07-22-clusterclient-to-grpc-live-gate.md`) **Phase 5 — live gate** (record in `2026-07-22-clusterclient-to-grpc-live-gate.md`) — **PASS 2026-07-23** (deletion build, `main` @ `7fd5cb2b`)
- [ ] 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 - [x] 1 PSK negatives · [x] 2 site→central matrix · [x] 3 central→site matrix · [x] 4 failover/failback both directions · [x] 5 mid-drain kill · [x] 6 >128 KB frame-class proof · [x] 7 no cross-boundary Akka association · [x] 8 full-rig restart clean
- All 8 PASS. Notables: check 1 clarified the site-side gate uses the node's single `GrpcPsk` and ignores `x-scadabridge-site` (central-side routing hint) — per-site isolation proven by wrong-key reject, not the header; check 6 = a **297,574-byte** single gRPC reply (a payload Akka's 128 KB frame dropped); check 7 = Akka cluster membership strictly pair-only, 0 cross-boundary association; check 8 = full-rig restart, 0 receptionist/ClusterClient lines on any of the 8 nodes. Instance-dependent central→site RPCs (`ExecuteOpcUa`/`ExecuteRoute`/standby parked retry/`TriggerFailover`) carried forward unit-proven — bare rig has no OPC-bound instance / inbound method / parked op / CLI failover verb.
## Gotchas for the executor (will bite; read twice) ## Gotchas for the executor (will bite; read twice)
@@ -198,7 +198,7 @@
"id": "P2", "id": "P2",
"phase": "2", "phase": "2",
"subject": "All sites CentralTransport=Grpc; central-kill S&F soak, failback observed, health sequences clean", "subject": "All sites CentralTransport=Grpc; central-kill S&F soak, failback observed, health sequences clean",
"status": "pending", "status": "completed",
"activeForm": "Running the site->central cutover soak", "activeForm": "Running the site->central cutover soak",
"blockedBy": [ "blockedBy": [
"P1A.DoD" "P1A.DoD"
@@ -208,7 +208,7 @@
"id": "P3", "id": "P3",
"phase": "3", "phase": "3",
"subject": "Central SiteTransport=Grpc all sites; full UI command matrix; site-kill mid-command clean; zero ClusterClient activity", "subject": "Central SiteTransport=Grpc all sites; full UI command matrix; site-kill mid-command clean; zero ClusterClient activity",
"status": "pending", "status": "completed",
"activeForm": "Running the central->site cutover soak", "activeForm": "Running the central->site cutover soak",
"blockedBy": [ "blockedBy": [
"P1B.DoD" "P1B.DoD"
@@ -218,32 +218,35 @@
"id": "P4.1", "id": "P4.1",
"phase": "4", "phase": "4",
"subject": "Flip both flag defaults to Grpc + soak; delete Akka transports, ClusterClient creation, DefaultSiteClientFactory, receptionist registrations, CentralContactPoints, then the flags", "subject": "Flip both flag defaults to Grpc + soak; delete Akka transports, ClusterClient creation, DefaultSiteClientFactory, receptionist registrations, CentralContactPoints, then the flags",
"status": "pending", "status": "completed",
"activeForm": "Deleting the ClusterClient transport", "activeForm": "Deleting the ClusterClient transport",
"blockedBy": [ "blockedBy": [
"P2", "P2",
"P3" "P3"
] ],
"notes": "Branch feat/grpc-phase4-deletion (2026-07-23). Flags DELETED rather than flipped \u2014 end state is gRPC-only, identical. Deleted: AkkaCentralTransport, AkkaSiteTransport, ISiteClientFactory+DefaultSiteClientFactory, CentralCommunicationActor legacy ctor + SelectTransport, ClusterClient creation + both ClusterClientReceptionist.RegisterService in AkkaHostedService, CommunicationOptions.CentralContactPoints + CentralTransport/SiteTransport flags + CentralTransportMode/SiteTransportKind enums, RegisterCentralClient message + receive block, AkkaCentralTransportTests + DefaultSiteClientFactoryTests. Added NoOpCentralTransport (fail-loud null-default so command-dispatch TestKit suites keep constructing without a wired transport; production always injects GrpcCentralTransport). CommunicationOptionsValidator: CentralGrpcEndpoints now unconditional (no-blank-entries, role-agnostic); StartupValidator: Site-only requires \u22651 CentralGrpcEndpoint (fail-fast, mirrors GrpcPsk). Host builds GrpcSiteTransport (central) + GrpcCentralTransport (site) unconditionally. Kept Akka.Cluster.Tools (ClusterSingleton). Test rework: deleted the ClusterClient.Send per-site-routing tests (covered by CentralCommunicationActorTransportTests + GrpcSiteTransport suites), swapped ISiteClientFactory\u2192substitute ISiteCommandTransport across 5 files, converted SiteAuditPushFlow's ClusterClientRelay\u2192BridgeCentralTransport, converted Heartbeat_StampsIsActive to a substitute ICentralTransport, repurposed HealthReportAck no-client test to the NoOp fail-loud path, removed SiteActors_CentralClusterClient_Exists. Rig: docker \u00d76 + docker-env2 \u00d72 + Host appsettings.Site.json + deploy/wonder-app-vd03 moved CentralContactPoints\u2192CentralGrpcEndpoints. Full solution build 0/0; Communication.Tests 640, StartupValidator 59, SiteActorPath 5, audit-push integration 1 all green. DID NOT fold in the dead IntegrationCallRequest deletion (#32) \u2014 SiteEnvelope routing is transport-agnostic so it still compiles; user-owned behavioral decision, left OUT of this PR."
}, },
{ {
"id": "P4.2", "id": "P4.2",
"phase": "4", "phase": "4",
"subject": "Grep-gates pass + docs updated (grpc_streams.md, Component-Host.md, Component-StoreAndForward.md, known-issues cross-ref)", "subject": "Grep-gates pass + docs updated (grpc_streams.md, Component-Host.md, Component-StoreAndForward.md, known-issues cross-ref)",
"status": "pending", "status": "completed",
"activeForm": "Running the deletion grep-gates and doc sweep", "activeForm": "Running the deletion grep-gates and doc sweep",
"blockedBy": [ "blockedBy": [
"P4.1" "P4.1"
] ],
"notes": "Grep-gates: CentralContactPoints \u2192 only intentional 'replaces the former' doc refs + plan trackers + stale bin/ artifacts (regenerate on build); deleted symbols (DefaultSiteClientFactory/ISiteClientFactory/RegisterCentralClient/AkkaCentralTransport/AkkaSiteTransport/CentralTransportMode/SiteTransportKind/the flags) \u2192 0 live refs. Residual 'clusterclient' in src = ClusterClientSiteAuditClient (misleadingly named but transport-agnostic \u2014 it Asks SiteCommunicationActor, holds no ClusterClient; works unchanged) + ~25 stale inline XML-doc comments across SiteRuntime/Commons/SiteCallAudit \u2014 NOT scrubbed (out of plan scope, no behavior impact); FOLLOW-UP noted. Docs updated (subagent + me): Component-Communication.md, components/Communication.md, Component-Host.md, Component-StoreAndForward.md, deployment/topology-guide.md, plans/grpc_streams.md (SUPERSEDED note + LoadSiteAddressesFromDb correction), known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md (2026-07-23 frame-size-retired amendment), and CLAUDE.md (2 transport-decision passages). Phase 5 (live gate) is next and requires a rig redeploy."
}, },
{ {
"id": "P5", "id": "P5",
"phase": "5", "phase": "5",
"subject": "Live gate, 8 checks, recorded in docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md", "subject": "Live gate, 8 checks, recorded in docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md",
"status": "pending", "status": "completed",
"activeForm": "Running the live gate", "activeForm": "Running the live gate",
"blockedBy": [ "blockedBy": [
"P4.2" "P4.2"
] ],
"notes": "Live gate PASS 2026-07-23 on Phase 4 deletion build (main @ 7fd5cb2b). All 8 checks PASS: PSK negatives (site-header contract clarified \u2014 site node has one key, header is central-side routing hint; per-site isolation proven by wrong-key reject); site->central matrix (notif no-loss/dupe, both audit paths node-a/b via IngestAuditEvents + central-a direct, health/heartbeat/active, reconcile self-heal over gRPC); central->site matrix ExecuteQuery/Parked/Lifecycle (OpcUa/Route/standby-parked/TriggerFailover carried forward, unit-proven); active-central kill sticky-flip 1s + central-b active 26s; mid-drain total==distinct zero loss/dupes; 297574-byte gRPC reply (frame-class retired); 0 cross-boundary Akka association (cluster membership pair-only); full-rig restart 0 clusterclient lines all 8 nodes. Recorded in docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md."
} }
] ]
} }
@@ -0,0 +1,64 @@
# OtOpcUa v3 native-alarm B/C live-gate (Gitea #14) — PASS
**Date:** 2026-07-23 · **Scope:** items **B** (native-alarm dedup) + **C** (UNS-bound alarm routing) of #14,
against a UNS structure + scripted Part-9 alarm authored on the live `otopcua-dev` v3 cluster (see
`2026-07-23-otopcua-v3-raw-path-live-gate.md` for the connection + `SiteAOnly` substrate). Instrument:
ScadaBridge itself (its DCL A&C client is the thing under test).
## Headline
**Both B and C PASS, and item C's original premise turned out to be already-solved code.** No ScadaBridge
code change is needed for B/C. The scope-doc worry — "a UNS-bound source won't `StartsWith`-match a
condition whose `SourceName` is the RawPath/ScriptedAlarmId" — is **obsolete**: ScadaBridge routes native
alarms by the **subscribed node reference**, not the event `SourceName` (deliberate fix, Gitea #17). The
only remaining #14 work is the item-A data re-author (greenfield, operational).
## Substrate (on OtOpcUa, SITE-A cluster)
- UNS: `area-1/line-1/pump01` → signal `SiteAOnly` (equipment folder node `ns=3;s=EQ-54f7711e160d`,
which has **`EventNotifier=1`**; signal `ns=3;s=area-1/line-1/pump01/SiteAOnly` mirrors the raw value).
- Scripted Part-9 condition `ns=3;s=SA-siteahigh` (a `HasComponent` child of the equipment folder),
`AlarmConditionType`, severity 700, predicate driven by `SiteAOnly` (set always-active for the gate).
- `Server` (i=2253) `EventNotifier=1` (the connection-wide aggregate).
## What ScadaBridge did (bindings on template 2148, instance 98, connection `OtOpcUa-v3-raw`)
Two `TemplateNativeAlarmSource` bindings, deliberately overlapping to stress dedup:
- `ServerAlarms``--source-ref i=2253` (Server aggregate)
- `UnsEquipAlarms``--source-ref nsu=https://zb.com/otopcua/uns;s=EQ-54f7711e160d` (UNS equipment node)
## Findings / checks
| # | Check | Result |
|---|-------|--------|
| C1 | A **UNS-node-scoped** binding receives + routes the native alarm | **PASS**`UnsEquipAlarms` mirrored `SA-siteahigh.SiteA High` active=True, sev 700, `kind=NativeOpcUa` |
| C2 | The condition's `SourceName` = **ScriptedAlarmId** (`SA-siteahigh`), NOT RawPath | **Confirmed** (captured live) — and it does **not** break routing (routing keys off the subscribed node, Gitea #17), only feeds the alarm's identity (`alarmName = SA-siteahigh.SiteA High`) |
| C3 | Item C's original "SourceName mismatch drops UNS-bound transitions" premise | **Obsolete**`OpcUaAlarmMapper.BuildIdentity` stamps `SourceObjectReference` = the subscription ref; `DataConnectionActor` routes on that, not `SourceName` |
| B1 | Fanned condition (Server aggregate + equipment-folder notifier) mirrored **once**, not duplicated | **PASS** — active condition appears exactly once (on the more-specific equipment feed); the Server binding stays an inactive placeholder. Stable across repeated snapshots |
| B2 | Mechanism | ScadaBridge opens **one** OPC UA `Subscription` per connection with one `MonitoredItem` per source-ref; `ConditionRefresh` delivers each retained condition **once per subscription**, to the most-specific monitored notifier (the equipment folder) — so overlapping bindings do not double-mirror the fan-out |
| B3 | Residual double-mirror risk | Latent, **not triggered** here: it needs two bindings whose `SourceReference` strings are literal prefixes of one another AND a delivery matching both (`DataConnectionActor` has no cross-binding, per-ConditionId dedup — `InstanceActor._latestAlarmEvents` is last-writer-wins by `alarmName`). Disjoint node refs (as here) don't collide |
## Captured real payload (what §5 phase 3 wanted)
`kind=NativeOpcUa`, `alarmName='SA-siteahigh.SiteA High'` (`SourceName`=`SA-siteahigh` = ScriptedAlarmId,
`.` + `ConditionName`=`SiteA High`), `alarmTypeName='AlarmConditionType'`, `severity=700`,
`condition.active=True`. A&C state is delivered via events/`ConditionRefresh` (ScadaBridge triggers a
refresh on every subscribe), not a plain attribute read.
## Bottom line for #14
- **A** — raw/UNS binding re-author: validated end-to-end (raw-path gate proved `nsu=` bind→read; this gate
proved UNS bind→alarm). Remaining work is data re-authoring (greenfield), **no code**.
- **B** — dedup: **PASS**, no double-mirror across the v3 fan-out (one subscription + `ConditionRefresh`).
- **C** — UNS-bound routing: **PASS**; the original concern was already solved by Gitea #17.
- **D/E/F** — shipped in phase 2 (`2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md`).
**#14 needs no further ScadaBridge code for B/C.** The only shipped code from this whole effort was the
phase-2 `nsu=` hardening + the `RealOpcUaClient` host-rewrite fix (surfaced by the raw gate). #14 can close
once item A's re-author is done in the target environment(s).
## Rig artifacts (removable on next `docker/deploy.sh`)
OtOpcUa ConfigDb: `UnsArea SITEA-area1` / `UnsLine SITEA-line1` / `Equipment EQ-54f7711e160d` /
`UnsTagReference UTR-siteapump01-siteaonly` / `Script SC-siteahigh` / `ScriptedAlarm SA-siteahigh`.
ScadaBridge: template 2148 native-alarm-sources `ServerAlarms` + `UnsEquipAlarms`; instance 98; connection 3044.
@@ -0,0 +1,140 @@
# OtOpcUa v3.0 dual-namespace cutover — scoping (Gitea #14)
**Date:** 2026-07-23 · **Status:** SCOPING (decisions needed — see §6) · **Tracked:** Gitea
[#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14) · **Area:** Data Connection
Layer (OPC UA adapter) · **Upstream:** OtOpcUa v3.0 (merged `master` 2026-07-16, PR #472, merge
`ec6598ce`); design `~/Desktop/OtOpcUa/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`.
## 1. Headline
**ScadaBridge is already structurally v3-safe.** A prior refactor made the OPC UA reference a
**namespace-URI-durable** string resolved dynamically against the live server `NamespaceArray`, so
the retirement of OtOpcUa's `EquipmentNodeIds` scheme and the split into two namespaces does **not**
break any parsing or binding logic. There is **no hardcoded `otopcua` namespace URI anywhere in
`src/`** (only test fixtures), and **nothing in ScadaBridge parses the `{EquipmentId}/…` NodeId
shape** — the identifier is opaque.
The genuine cutover is therefore **narrow**: one **data** problem (a namespace-index collision on
legacy bindings), one real **code gap** (native-alarm dedup across the raw+uns notifier fan-out),
and some **UX / validation** polish. Most of it can only be *closed* against a re-seeded live v3
rig, so this is as much a live-gate as a code change.
## 2. The v3 wire contract (what changed upstream)
OtOpcUa replaced its single custom namespace `https://zb.com/otopcua/ns` with **two**:
| Subtree | Namespace URI | NodeId form | Shape |
|---|---|---|---|
| **Raw** (device tree, source of truth) | `https://zb.com/otopcua/raw` | `ns=<raw>;s=<RawPath>` | `Folder/…/Driver/Device/TagGroup/…/Tag` |
| **UNS** (equipment projection) | `https://zb.com/otopcua/uns` | `ns=<uns>;s=<Area>/<Line>/<Equipment>/<EffectiveName>` | Area→Line→Equipment→signal |
Semantics ScadaBridge can rely on (from the v3 design doc):
- Every value has **one source** (the raw tag), fanned to both the raw NodeId and each referencing
UNS NodeId with **identical value/quality/timestamp**. Each UNS variable `Organizes`-references
its raw node (cross-tree link is browseable).
- **Writes** route through **either** NodeId (same `WriteOperate` gating).
- **HistoryRead** works via **both** NodeIds under one historian tagname.
- **Native Part 9 alarms:** one condition instance at the raw tag, `ConditionId`/primary identity =
**RawPath**, fanned by a **single `ReportEvent`** to the raw device folder **and** every
referencing equipment folder. A Server-object subscriber gets **exactly one** copy (the SDK
dedups on the shared `InstanceStateSnapshot`); folder-scoped subscribers in each namespace see the
condition's events.
- The old `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme is **retired**.
OtOpcUa's own "Cross-repo impact" note sized this for us: **every ScadaBridge binding re-binds**;
it's a *data* migration, not a code rewrite; the fragile part is that stored refs hard-code the
**namespace index** and ScadaBridge stores no URI; the recommended remediation is to **move to
`nsu=`-qualified references** while re-binding.
## 3. Current-state assessment (what is already v3-safe)
Grounded in the DCL code (`src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/`):
- **`Adapters/OpcUaNodeReference.cs` — the single translation seam, already URI-durable.**
`Resolve` maps `nsu=<uri>` to the live index via `ExpandedNodeId.ToNodeId(expanded,
namespaceUris)` against the session's `NamespaceTable`, and **throws** when a URI is absent from
the server's `NamespaceArray` (no silent stale-index binding); rejects `svr=` cross-server refs.
`ToDurable` emits the `nsu=<uri>` form by reading `namespaceUris.GetString(NamespaceIndex)`.
**No change needed — this seam is the reason the cutover is low-risk.**
- **`Adapters/RealOpcUaClient.cs`** — every read/write/subscribe/browse routes NodeIds through the
seam with the live `_session.NamespaceUris`. No hardcoded URI, no assumed index.
- **`Adapters/OpcUaDataConnection.cs`** — passes the configured tag-path string straight to the
client; no path parsing/joining. There is **no browse-path→NodeId translation** anywhere
(bindings are absolute NodeIds), so there is no indirection layer to update.
- **`Commons/Types/DataConnections/OpcUaEndpointConfig.cs`** — carries only `EndpointUrl` + timing/
auth/heartbeat; **no namespace URI, index, or NodeId field**. Namespaces are discovered from the
live server, not configured — inherently v3-tolerant. Nothing to change here.
- **`Adapters/OpcUaAlarmMapper.cs` `BuildIdentity`** — the per-condition key is already
`SourceName (= RawPath post-v3) + "." + ConditionName`, i.e. **already aligned** with v3 keying on
RawPath; the routing identity is the binding string verbatim (namespace-form-independent).
## 4. The genuine cutover work
| # | Item | Kind | Primary files | Confidence |
|---|------|------|---------------|------------|
| A | **Legacy `ns=<index>` bindings collide with v3.** v2's sole custom namespace and v3's `raw` both land at `ns=2`, so a stored `ns=2;s=…` resolves **without error** but now means a raw-tree node. Every stored binding must be re-authored (or migrated) to the new address space, ideally in `nsu=` form. | **Data** | `TemplateAttribute.DataSourceReference`, `InstanceConnectionBinding.DataSourceReferenceOverride` (config DB); heartbeat `TagPath`; Transport bundles | High (inspection) |
| B | **Native-alarm dedup across the raw+uns fan-out.** `RealOpcUaClient.HandleAlarmEvent` keys on RawPath+ConditionName with **no `ConditionId`-based dedup**; it assumes one condition arrives on one feed. If a v3 server delivers the same condition through two notifier paths into the same feed, two `EventFieldList`s each call `onTransition`. Must verify the Server-object aggregate feed collapses to one copy (the v3 doc says the SDK dedups Server-object subscribers, so this *should* hold — but it is the biggest genuine gap and needs a live check; add ConditionId-based dedup only if the live server double-delivers). | **Code + live validation** | `Adapters/RealOpcUaClient.cs` (HandleAlarmEvent), `Adapters/OpcUaAlarmMapper.cs` | Medium (needs live v3) |
| C | **Alarm routing for UNS-bound sources (confirmed by D-1 = ingest both).** `DataConnectionActor` routes transitions by `SourceReference.StartsWith(bindingRef)`, but a v3 condition's `SourceName` is the **RawPath** regardless of subtree — so a **UNS-bound** alarm source won't `StartsWith`-match. Needs a routing change: resolve a UNS-bound source to its backing RawPath (via the browseable UNS→Raw `Organizes` reference) or map identities. Confirmed scope; validate the exact `SourceName`/`SourceNode` payload on the live rig before finalizing the mapping. | **Code + live validation** | `Actors/DataConnectionActor.cs` (alarm routing), `Adapters/RealOpcUaClient.cs` | Medium (needs live v3) |
| D | **Browse / search UX now shows two subtrees.** `BrowseChildrenAsync` / `AddressSpaceSearch.SearchAsync` start at the standard Objects root and will enumerate **both** raw and uns as sibling roots. Functional, not breaking — but the picker shows two roots, the manual-entry placeholder still nudges `ns=2;s=…`, and the deeper raw hierarchy stresses `VisitedNodeCeiling`/`maxDepth`. | **UX** | `CentralUI/Components/Dialogs/NodeBrowserDialog.razor`, `IOpcUaClient` search caps | High (inspection) |
| E | **`nsu=` hardening (optional, recommended).** The picker already emits `nsu=` via `ToDurable`, but the seam still **accepts** bare `ns=<index>`. Decide whether to warn/reject bare `ns=` on save (closes A permanently) or leave it permissive. | **Code** | `OpcUaNodeReference`, binding validation, picker | High (inspection) |
| F | **Doc drift.** `ns=` examples in code comments + `Component-DataConnectionLayer.md`; update to `nsu=`. Update the scadaproj umbrella index (cutover step 4). | **Docs** | doc comments, `docs/requirements/Component-DataConnectionLayer.md`, `../scadaproj/CLAUDE.md` | High |
## 5. Proposed phasing
1. **Decisions** (§6) — resolve D-1..D-3 before code.
2. **`nsu=` hardening + UX** (items D, E, F) — picker emits/enforces `nsu=`, placeholder + search
caps updated, docs swept. Pure ScadaBridge-side, unit-testable, no live server required.
3. **Re-seed a v3 rig** — bring up an OtOpcUa v3.0 server (docker) and re-author a representative
set of bindings across **both** subtrees via the picker (D-1); confirm subscribe/read/write
round-trip and HistoryRead via each subtree, and capture the exact native-alarm `SourceName`/
`SourceNode` payload so item C's UNS→Raw routing mapping is built against real data.
4. **Alarm live-gate** (items B, C) — against the v3 rig, drive a native condition that fans to both
raw + equipment notifiers and confirm ScadaBridge sees **exactly one** transition per state
change, correctly routed to the bound source. Add ConditionId-based dedup / routing fix **only if
the live server double-delivers**.
5. **Data migration decision** (item A) — either force re-authoring (greenfield, no automatic map)
or write a one-shot migration if a deterministic old→new mapping exists for the rig's data.
6. **Umbrella index + issue close.**
## 6. Decisions needed
- **D-1 — Which subtree does ScadaBridge ingest? → DECIDED (2026-07-23): BOTH.** The picker
surfaces both the Raw (`…/raw`, device tree) and UNS (`…/uns`, Area→Line→Equipment) subtrees, and
an operator binds each attribute / native-alarm source against whichever fits — UNS for
equipment-modelled signals, Raw where device-level identity is wanted. For **values** this is
free: a given ScadaBridge attribute binds to one NodeId, and v3 guarantees identical
value/quality/timestamp on both trees, so no per-value dedup arises.
**Consequence for alarms (promotes item C from "validate" to "fix"):** a native alarm condition is
keyed by its **RawPath** and its `SourceName` is the RawPath *regardless of which subtree the
source was bound on*. So a **UNS-bound** alarm source (prefix `nsu=…/uns;s=<Area>/<Line>/…`) will
**not** `StartsWith`-match a condition whose `SourceName` is the raw path. Ingesting both therefore
**requires** a routing change so a UNS-bound source resolves to the RawPath of its backing raw node
(the UNS→Raw `Organizes` reference is browseable and is the link to follow), or an equivalent
identity mapping. This is confirmed scope, gated on the live v3 rig (§5 phase 4).
- **D-2 — Enforce `nsu=` on save?** *Recommendation: yes* — warn (or reject) bare `ns=<index>`
bindings at authoring time so the index-collision class (item A) can never recur. Low effort, high
durability.
- **D-3 — Migrate legacy bindings, or re-author?** Upstream is greenfield (no reliable automatic
old→new mapping), so *re-authoring via the picker is the default*. A migration is only worth
writing if this environment has a deterministic mapping worth automating.
## 7. Risks / what needs a live v3 server
- Items **B** and **C** (alarm dedup + routing) **cannot be fully closed by code inspection** — they
depend on how a live v3 server actually delivers a fanned condition to an aggregate vs
folder-scoped subscription. The plan gates them behind a re-seeded v3 rig.
- No production config data is preserved upstream (greenfield), so there is **no migration
correctness risk** to legacy production rows — only the dev/test rigs need re-authoring.
- The `OpcUaEndpointConfig` has no namespace field, so **no schema/EF change** is anticipated; this
cutover is expected to add **no EF migration** (to be confirmed once D-2's validation surface is
decided).
## 8. Bottom line
The prior namespace-durability refactor did the heavy lifting: ScadaBridge already resolves
`nsu=`-qualified references against the live `NamespaceArray` and never assumes an index or a NodeId
shape. What remains is (1) getting existing bindings onto the new address space (data / re-author),
(2) optionally enforcing `nsu=` so the index-collision class is closed for good, (3) tidying the
two-subtree browse UX, and (4) a **live alarm-fan-out validation** that is the only item carrying
real code risk. Once §6 is decided, phase 2 (hardening + UX + docs) is straightforward ScadaBridge
work; phases 34 need a re-seeded v3 rig.
@@ -0,0 +1,263 @@
# OtOpcUa v3 cutover — `nsu=` hardening + browse UX (phase 2) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Ship the pure ScadaBridge-side slice of the OtOpcUa v3 dual-namespace cutover (Gitea [#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14)) — nudge operators toward durable `nsu=` bindings and tidy the two-subtree browse UX — without a live v3 server.
**Architecture:** Add one pure string helper in Commons (`OpcUaReferenceForm.IsDurable`) that flags bare `ns=<index>` references (which the v3 raw/uns split makes ambiguous — see scope doc §4 item A). Surface it as a non-blocking authoring warning in the node-browser picker and fix the manual-entry placeholder. Sweep the `ns=`-only doc examples to `nsu=`. This is the D-2-recommended **warn, don't reject** posture: existing `ns=` bindings keep resolving (the `OpcUaNodeReference.Resolve` seam is unchanged and still accepts both forms), so nothing breaks on the current v2 rig.
**Tech Stack:** C#/.NET 10, xunit, Blazor Server (Bootstrap), `TreatWarningsAsErrors`.
**Scope boundary (read first):** This plan is **phase 2** of the scope doc `docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md` — items **D, E, F** only. Items **A** (re-author legacy bindings), **B** (native-alarm dedup), and **C** (UNS-bound alarm routing) are **deferred — they require a re-seeded live OtOpcUa v3.0 rig** and live alarm-fan-out validation (scope doc §5 phases 35, §7). Do NOT attempt them here. Decisions folded in: **D-1 = ingest both subtrees** (already recorded); **D-2 = warn on bare `ns=`** (this plan); **D-3 = re-author, no migration code** (nothing to build).
---
### Task 1: Commons — `OpcUaReferenceForm.IsDurable` durability helper
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/OpcUaReferenceForm.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs`
A **pure string** helper — no `Opc.Ua` dependency (Commons has none, and CentralUI references only Commons). It answers one question for the authoring UI: *is this reference index-durable, or a bare server-namespace index we should warn about?* A bare `ns=<n>` with n ≥ 1 is exactly the form the v3 raw/uns namespace split makes ambiguous (scope doc §4 item A). `nsu=`, spec-fixed `ns=0`, and short forms with no namespace prefix (`i=85`, `s=Foo`) are durable and must not warn. Whitespace/empty returns `true` (nothing typed → nothing to warn about).
**Step 1: Write the failing tests**
```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
public class OpcUaReferenceFormTests
{
[Theory]
// durable → true
[InlineData("nsu=https://zb.com/otopcua/raw;s=Line1.Pump.Speed", true)]
[InlineData("nsu=https://zb.com/otopcua/uns;s=AreaA/Line1/Pump/Speed", true)]
[InlineData("NSU=https://x;s=y", true)] // case-insensitive prefix
[InlineData("ns=0;i=85", true)] // spec-fixed namespace 0
[InlineData("i=85", true)] // short form, implicit ns0
[InlineData("s=Devices.Pump1", true)] // no namespace prefix
[InlineData("", true)] // nothing typed
[InlineData(" ", true)]
[InlineData(null, true)]
// bare server-namespace index → false (warn)
[InlineData("ns=2;s=MyDevice.Temperature", false)]
[InlineData("ns=1;i=1001", false)]
[InlineData("ns=10;s=x", false)]
[InlineData(" ns=3;s=x ", false)] // trimmed before inspection
public void IsDurable_classifies_reference_forms(string? reference, bool expected)
{
Assert.Equal(expected, OpcUaReferenceForm.IsDurable(reference));
}
}
```
**Step 2: Run to verify it fails**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter OpcUaReferenceFormTests`
Expected: FAIL — `OpcUaReferenceForm` does not exist (compile error).
**Step 3: Write the minimal implementation**
```csharp
using System.Globalization;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
/// <summary>
/// Pure, protocol-package-free classification of an OPC UA node-reference string's
/// <i>form</i> — used by authoring UI to nudge operators toward index-durable bindings.
/// </summary>
/// <remarks>
/// A bare <c>ns=&lt;index&gt;;…</c> reference hard-codes a namespace <i>index</i>, which is
/// only meaningful relative to one server's namespace table. The OtOpcUa v3.0 raw/uns
/// split makes this actively dangerous: v2's sole custom namespace and v3's <c>raw</c>
/// tree both land at <c>ns=2</c>, so a stored <c>ns=2;s=…</c> resolves without error but
/// silently now means a raw-tree node (scope doc §4 item A). The durable form is
/// <c>nsu=&lt;uri&gt;;…</c>, resolved against the live namespace table at use time by
/// <c>OpcUaNodeReference.Resolve</c>. This helper only classifies the string; it does not
/// parse or resolve NodeIds (that stays in the DataConnectionLayer seam, which owns the
/// <c>Opc.Ua</c> dependency).
/// </remarks>
public static class OpcUaReferenceForm
{
/// <summary>
/// True when <paramref name="reference"/> is index-durable — the durable
/// <c>nsu=&lt;uri&gt;;…</c> form, spec-fixed <c>ns=0</c>, a short form with no namespace
/// prefix, or empty/whitespace (nothing to warn about). False only for a bare
/// server-namespace index (<c>ns=&lt;n&gt;;…</c> with n ≥ 1).
/// </summary>
public static bool IsDurable(string? reference)
{
if (string.IsNullOrWhiteSpace(reference))
return true;
var trimmed = reference.Trim();
// nsu=<uri>;… is the durable form (and its "ns" prefix must be checked before the
// bare "ns=" branch below, since "nsu=" also starts with "ns").
if (trimmed.StartsWith("nsu=", StringComparison.OrdinalIgnoreCase))
return true;
// Not a bare namespace-index reference at all → short forms (i=85, s=Foo) that
// imply namespace 0, which is spec-fixed and already durable.
if (!trimmed.StartsWith("ns=", StringComparison.OrdinalIgnoreCase))
return true;
// ns=<digits>;… — parse the index; ns=0 is spec-fixed (durable), n ≥ 1 is bare.
var semicolon = trimmed.IndexOf(';');
var digits = semicolon >= 0 ? trimmed[3..semicolon] : trimmed[3..];
if (int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var index))
return index == 0;
// Malformed "ns=" with non-numeric index — not a recognisable bare index; leave
// the real parse/reject to OpcUaNodeReference.Resolve. Don't warn on it here.
return true;
}
}
```
**Step 4: Run to verify it passes**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter OpcUaReferenceFormTests`
Expected: PASS (all theory rows).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/OpcUaReferenceForm.cs \
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/OpcUaReferenceFormTests.cs
git commit -m "feat(dcl): add OpcUaReferenceForm.IsDurable — flags bare ns= bindings (v3 cutover #14)"
```
---
### Task 2: Central UI — durable-binding nudge in the node-browser picker (items D + E)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (uses the Task 1 helper)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor`
Two UX changes, both non-blocking (D-2 = warn, not reject):
1. Manual-entry **placeholder** `ns=2;s=...``nsu=<namespace-uri>;s=...` (stop nudging the index form).
2. A muted inline **warning** below the manual-entry input when the typed reference is a bare `ns=` index (`!OpcUaReferenceForm.IsDurable(_manualNodeId)`), explaining that index bindings can silently re-point after a server namespace change and to prefer the browse tree / `nsu=` form. Safe for every protocol that uses this dialog: MxGateway node ids carry no `ns=` prefix, so `IsDurable` returns `true` and the warning never shows.
> Not in scope here: the picker already renders whatever root nodes the server exposes, so the two v3 subtrees (`raw`/`uns`) appear as sibling roots with **no code change**. Tuning the search `maxDepth: 6` for the deeper raw hierarchy is deferred — it needs a live v3 rig to calibrate (scope doc §5 phase 3).
**Step 1: Add the using and update the placeholder**
At the top of the file, add to the existing `@using` block:
```razor
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
```
Change the manual-node-id input (currently line ~92) placeholder:
```razor
<input class="form-control" @bind="_manualNodeId" @bind:event="oninput"
placeholder="nsu=&lt;namespace-uri&gt;;s=..." />
```
(Note the added `@bind:event="oninput"` so the warning below updates as the operator types.)
**Step 2: Add the inline durability warning**
Immediately after the manual-entry `input-group` `</div>` (line ~94, before the `modal-footer`), add:
```razor
@if (!OpcUaReferenceForm.IsDurable(_manualNodeId))
{
<small class="text-warning-emphasis d-block mt-1" data-test="ns-index-warning">
This is a bare namespace-<em>index</em> binding. Indexes can silently re-point after a
server namespace change — prefer selecting from the tree above (it stores the durable
<code>nsu=&lt;uri&gt;;…</code> form).
</small>
}
```
**Step 3: Build to verify it compiles**
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Expected: Build succeeded, 0 warnings (project is `TreatWarningsAsErrors`).
**Step 4: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
git commit -m "feat(ui): warn on bare ns= node bindings in the browse picker (v3 cutover #14)"
```
---
### Task 3: Docs — sweep `ns=` examples to `nsu=` (item F)
**Classification:** trivial
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:15`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs:36`
- Modify: `docs/requirements/Component-DataConnectionLayer.md`
- Modify: `../scadaproj/CLAUDE.md` (umbrella index — CLAUDE.md propagation rule)
Only the sole-example `ns=2;s=…` comments get updated. **Leave `OpcUaNodeReference.cs` alone** — it deliberately documents *both* forms and explains why. Do not touch any `ns=` string in code/tests that is an actual binding value or assertion.
**Step 1: Update the two code-comment examples**
`OpcUaDataConnection.cs:15` — change:
```
/// - TagPath → NodeId (e.g., "ns=2;s=MyDevice.Temperature")
```
to:
```
/// - TagPath → NodeId (durable form, e.g. "nsu=https://server/ns;s=MyDevice.Temperature";
/// the legacy "ns=2;s=..." index form is still accepted — see OpcUaNodeReference)
```
`IBrowsableDataConnection.cs:36` — change the `<param name="NodeId">` example from `"ns=2;s=Devices.Pump1.Speed"` to `"nsu=https://server/ns;s=Devices.Pump1.Speed"`.
**Step 2: Update the component doc**
In `docs/requirements/Component-DataConnectionLayer.md`: update any `ns=<index>` example bindings to the `nsu=<uri>` durable form, and add a short note (cross-referencing `docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md`) that OtOpcUa v3.0 splits its address space into `raw` + `uns` namespaces, that ScadaBridge is namespace-URI-durable via `OpcUaNodeReference`, and that the picker nudges operators to the `nsu=` form. First read the file to place the note in the OPC UA / node-reference section.
**Step 3: Update the umbrella index**
In `../scadaproj/CLAUDE.md`, find the ScadaBridge entry's OtOpcUa relationship line and note that ScadaBridge is v3-`nsu=`-durable and the #14 dual-namespace cutover is in progress (phase 2 shipped: `nsu=` authoring nudge + browse UX; alarm-fan-out live-gate deferred to a v3 rig). Read the surrounding lines first to match the index's style; keep it to one or two lines.
**Step 4: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs \
src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs \
docs/requirements/Component-DataConnectionLayer.md
git commit -m "docs(dcl): sweep ns= examples to durable nsu= form (v3 cutover #14)"
# umbrella index is a separate repo — commit it there
git -C ../scadaproj add CLAUDE.md && git -C ../scadaproj commit -m "docs: note ScadaBridge OtOpcUa v3 nsu= cutover phase 2 (#14)"
```
---
## Deferred — requires a live OtOpcUa v3.0 rig (NOT in this plan)
Tracked in the scope doc (§5 phases 35, §7). Do not attempt without a re-seeded v3 server:
- **Item A** — re-author existing dev/test bindings across both subtrees via the picker (data, greenfield; D-3 = no migration code).
- **Item B** — native-alarm dedup across the raw+uns notifier fan-out; add `ConditionId` dedup **only if** the live server double-delivers.
- **Item C** — UNS-bound alarm routing: a v3 condition's `SourceName` is always the RawPath, so a UNS-bound source won't `StartsWith`-match — resolve UNS→Raw via the browseable `Organizes` reference. Build against the real `SourceName`/`SourceNode` payload captured on the rig.
## Verification (whole-plan, after Task 3)
```bash
dotnet build ZB.MOM.WW.ScadaBridge.slnx
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj
```
Expected: build 0/0; Commons tests green (incl. `OpcUaReferenceFormTests`). No EF migration is added (scope doc §7 — `OpcUaEndpointConfig` has no namespace field).
@@ -0,0 +1,9 @@
{
"planPath": "docs/plans/2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md",
"tasks": [
{"id": 1, "subject": "Task 1: Commons — OpcUaReferenceForm.IsDurable durability helper + tests", "status": "completed", "classification": "small", "parallelizableWith": [3], "commit": "0eb44314"},
{"id": 2, "subject": "Task 2: Central UI — durable-binding nudge in node-browser picker (D+E)", "status": "completed", "classification": "small", "blockedBy": [1], "commit": "e04c2617"},
{"id": 3, "subject": "Task 3: Docs — sweep ns= examples to nsu= (F)", "status": "completed", "classification": "trivial", "parallelizableWith": [1], "commit": "97afa84f"}
],
"lastUpdated": "2026-07-23"
}
@@ -0,0 +1,79 @@
# OtOpcUa v3 raw-path live-gate (Gitea #14) — PASS
**Date:** 2026-07-23 · **Scope:** the raw-subtree slice of #14 (decision: "raw gate now, flag B/C").
**Rig:** ScadaBridge docker rig (`scadabridge:latest`, rebuilt with the host-rewrite fix) against the
**live `otopcua-dev` v3 cluster** (`otopcua-host:dev`, built 2026-07-23, branch `feat/mesh-phase5`,
past the v3 merge `ec6598ce`). Related: scope doc `2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md`,
phase-2 plan `2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md`.
## Headline
ScadaBridge is confirmed **structurally v3-safe against a real dual-namespace server** — durable
`nsu=` references are emitted by browse, stored on bindings, and resolved live against the server's
`NamespaceArray` to Good-quality reads. The gate also **surfaced and fixed a real DCL robustness
bug** (couldn't dial a server advertising `0.0.0.0`). Items **B/C remain deferred** — the dev
cluster has no UNS nodes and no native alarms to gate them against.
## Environment findings
- v3 cluster publishes both namespaces: `ns=2 = https://zb.com/otopcua/raw`,
`ns=3 = https://zb.com/otopcua/uns`. **Raw lands at ns=2 — the exact index collision scope §4
item A predicted** (v2's sole namespace and v3 raw both at ns=2).
- Raw tree is **sparsely populated**: one materialized live Variable,
`nsu=https://zb.com/otopcua/raw;s=sa-modbus2/plc/SiteAOnly` (Int16, AccessLevel=1 → **read-only**).
Other drivers (calc1, opcua1, pymodbus, gate-modbus, Area1) expose folders but no live leaf tags.
- **UNS (ns=3) is empty**`ns=3;s=OtOpcUa``BadNodeIdUnknown`; no Equipment authored.
- **No native alarms** — zero `HasCondition`/`HasEventSource` refs anywhere in the raw tree.
## The fix this gate produced
`RealOpcUaClient` took the server-advertised `EndpointDescription.EndpointUrl` verbatim for the
session channel. OtOpcUa advertises `opc.tcp://0.0.0.0:4840/OtOpcUa` (its
`OpcUaApplicationHostOptions.PublicHostname` defaults to `0.0.0.0`), and dialling `0.0.0.0` from
inside the client container hits its own loopback → connect fails. Added
`RealOpcUaClient.RewriteEndpointHostForReachability` (swaps the advertised authority for the
reachable discovery host/port, preserves scheme+path, no-op when already reachable), called in both
`ConnectAsync` and `VerifyEndpointAsync`. Mirrors `CoreClientUtils.SelectEndpoint` and OtOpcUa's own
client (`IEndpointDiscovery`). Commit `a1abbff7` on `fix/opcua-discovery-host-rewrite`; 9 unit tests
(`RealOpcUaClientEndpointRewriteTests`).
## Wiring (how the gate was run)
1. Rebuilt `scadabridge:latest` with the fix (`bash docker/deploy.sh`).
2. `docker network connect otopcua-dev_default scadabridge-site-a-a` (and `-b`) — bridged the two
docker networks so site-a can resolve `otopcua-dev-site-a-1-1`.
3. `data-connection create --site-id 1 --name OtOpcUa-v3-raw --protocol OpcUa`
`endpointUrl=opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa`, securityMode none (id **3044**).
4. `deploy artifacts --site-id 1` — pushed the def to site-a's LocalDb + made it live in the DCL.
5. Template **2148** (`OtOpcUaV3Probe`) + attribute `SiteA` (Int32,
`--data-source nsu=https://zb.com/otopcua/raw;s=sa-modbus2/plc/SiteAOnly`); instance **98**
(`otopcua-v3-probe-1`) on site-a; `set-bindings [["SiteA",3044]]`; `deploy instance`.
## Checks
| # | Check | Result |
|---|-------|--------|
| 1 | Connect via `ConnectAsync` (through the rewrite fix) | **PASS**`data-connection browse` returned the address space |
| 2 | Connect via `VerifyEndpointAsync` (second rewrite call site) | **PASS**`verify-endpoint``{"success":true}` |
| 3 | Browse emits durable `nsu=` throughout | **PASS** — root `nsu=…/raw;s=OtOpcUa`, drivers + `…;s=sa-modbus2/plc/SiteAOnly` |
| 4 | Both namespaces visible / discoverable | **PASS (partial)** — raw browsable; uns registered but empty (nothing to browse) |
| 5 | Durable `nsu=` binding resolves live | **PASS** — attribute stores `nsu=…`, subscription resolves it against the live `NamespaceArray` |
| 6 | Read round-trip, Good quality, live | **PASS**`SiteA` Good, values changing 59689→59806→59822→59834 across snapshots |
| 7 | Write round-trip | **N/A** — the only materialized tag is read-only (AccessLevel=1); no writable v3 tag to gate |
## Deferred (unchanged — need OtOpcUa-side authoring on the dev cluster)
- **Item A** — re-author legacy `ns=2` bindings to `nsu=` (data; the collision is confirmed real here).
- **Item B** — native-alarm dedup across raw+uns fan-out: **no native alarms exist** to drive it.
- **Item C** — UNS-bound → RawPath alarm routing: **no UNS nodes and no alarms exist** to drive it.
To gate B/C, the OtOpcUa dev cluster needs UNS Equipment authored (populate ns=3, referencing raw
tags) and at least one alarm-configured tag raising a Part-9 condition that fans to raw + equipment
notifiers.
## Rig artifacts created (non-default rig state)
- ScadaBridge site-a-a / site-a-b attached to `otopcua-dev_default` (dropped on next `docker/deploy.sh`).
- Site-a config: DataConnection **3044** `OtOpcUa-v3-raw`, template **2148**, instance **98** (deployed).
Harmless; remove with `instance delete 98` / `template delete 2148` / `data-connection delete 3044`
if a clean rig is wanted.
+15 -4
View File
@@ -1,10 +1,21 @@
# gRPC Streaming Channel: Site → Central Real-Time Data # gRPC Streaming Channel: Site → Central Real-Time Data
> **Status update (2026-07-23) — the "ClusterClient keeps command/control" framing is SUPERSEDED.**
> This plan introduced gRPC for *streaming only*, leaving command/control on Akka ClusterClient. That
> split no longer holds: **Phase 4 of the ClusterClient→gRPC migration moved command/control to gRPC
> too and deleted the ClusterClient site↔central transport entirely.** Command/control now rides gRPC
> in both directions — site→central to the central-hosted `CentralControlService` (`GrpcCentralTransport`)
> and central→site to the site-hosted `SiteCommandService` (`GrpcSiteTransport`) — alongside the
> streaming `SiteStreamService` this plan describes. Wherever the text below says "ClusterClient handles
> command/control", read it as "gRPC command/control (`CentralControlService` / `SiteCommandService`)".
> See `docs/requirements/Component-Communication.md` and
> `docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`.
## Context ## Context
Debug streaming events currently flow through Akka.NET ClusterClient (`InstanceActor → SiteCommunicationActor → ClusterClient.Send → CentralCommunicationActor → bridge actor`). ClusterClient wasn't built for high-throughput value streaming — it's a cluster coordination tool with gossip-based routing. As we scale beyond debug view to health streaming, alarm feeds, or future live dashboards, pushing all real-time data through ClusterClient will become a bottleneck. Debug streaming events currently flow through Akka.NET ClusterClient (`InstanceActor → SiteCommunicationActor → ClusterClient.Send → CentralCommunicationActor → bridge actor`). ClusterClient wasn't built for high-throughput value streaming — it's a cluster coordination tool with gossip-based routing. As we scale beyond debug view to health streaming, alarm feeds, or future live dashboards, pushing all real-time data through ClusterClient will become a bottleneck.
**Goal**: Add a dedicated gRPC server-streaming channel on each site node. Central subscribes to sites over gRPC for real-time data. ClusterClient continues to handle command/control (subscribe, unsubscribe, deploy, lifecycle) but all streaming values flow through the gRPC channel. **Goal**: Add a dedicated gRPC server-streaming channel on each site node. Central subscribes to sites over gRPC for real-time data. Command/control (subscribe, unsubscribe, deploy, lifecycle) and streaming values flow over gRPC. *(As originally written, command/control stayed on ClusterClient; Phase 4 later moved it to gRPC — see the status note above.)*
**Scope**: General-purpose site→central streaming transport. Debug view is the first consumer, but the proto and server are designed so future features (health streaming, alarm feeds, live dashboards) can subscribe with different event types and filters. **Scope**: General-purpose site→central streaming transport. Debug view is the first consumer, but the proto and server are designed so future features (health streaming, alarm feeds, live dashboards) can subscribe with different event types and filters.
@@ -57,7 +68,7 @@ flowchart TD
class PB dec class PB dec
``` ```
**Key separation**: ClusterClient handles subscribe/unsubscribe/snapshot (request-response). gRPC handles the ongoing value stream (server-streaming). **Key separation**: command/control handles subscribe/unsubscribe/snapshot (request-response); the ongoing value stream is server-streaming. Both ride gRPC after Phase 4 (subscribe/unsubscribe/snapshot over `SiteCommandService`, the value stream over `SiteStreamService`); as originally drawn, the request-response leg was ClusterClient.
## Port & Address Configuration ## Port & Address Configuration
@@ -164,7 +175,7 @@ Add corresponding columns to the sites list table. Wire `_formGrpcNodeAAddress`
### SiteStreamGrpcClientFactory ### SiteStreamGrpcClientFactory
Reads `GrpcNodeAAddress` / `GrpcNodeBAddress` from the `Site` entity (loaded by `CentralCommunicationActor.LoadSiteAddressesFromDb()`) when creating per-site gRPC channels. Falls back to NodeB if NodeA connection fails (same pattern as ClusterClient dual-contact-point failover). Reads `GrpcNodeAAddress` / `GrpcNodeBAddress` from the `Site` entity when creating per-site gRPC channels. As of Phase 4, `CentralCommunicationActor.LoadSiteAddressesFromDb()` no longer builds Akka ClusterClient contacts at all — it builds a **per-site gRPC-endpoint cache** from those same `GrpcNodeAAddress` / `GrpcNodeBAddress` columns, feeding a `SitePairChannelProvider` that both this streaming factory and the command/control `GrpcSiteTransport` (`SiteCommandService`) dial. Falls back to NodeB if NodeA connection fails.
### Docker Compose Port Allocation ### Docker Compose Port Allocation
@@ -364,7 +375,7 @@ public async Task<StreamSubscription> SubscribeAsync(
### Client Factory ### Client Factory
`SiteStreamGrpcClientFactory` caches per-site `GrpcChannel` instances (same pattern as `CentralCommunicationActor._siteClients` caching per-site ClusterClient instances). `SiteStreamGrpcClientFactory` caches per-site `GrpcChannel` instances. (As originally written this mirrored `CentralCommunicationActor._siteClients` caching per-site ClusterClient instances; after Phase 4 that dictionary caches per-site gRPC endpoints/channels, not ClusterClients.)
## Failover & Reconnection ## Failover & Reconnection
@@ -137,6 +137,27 @@ Behavior, covered by `SelfFirstSeedBootstrapTests` (real in-process clusters bui
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**. 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**.
### Simultaneous cold start — the bootstrap guard (opt-in, Gitea #33)
Self-first-on-both has a residual cost. When **both** nodes cold-start at the same instant, each is `seed-nodes[0]` for itself, so **each runs `FirstSeedNodeProcess`**, times out waiting for the other, and forms its **own** single-node cluster — a split brain (two oldest members, two singletons, dual-active) that does **not** auto-merge and persists until an operator restarts one side. In-process loopback tests usually converge (the `InitJoin` handshake resolves before either self-join deadline, so row 3 above passes), which is exactly why the split hides until a real deployment powers up both VMs truly in parallel — a shared power event or a hypervisor host reboot. The sister project **OtOpcUa** (same `ZB.MOM.WW.*` Akka topology) reproduced it reliably on its docker rig.
The **bootstrap guard** eliminates the split without giving up cold-start-alone. It is an **opt-in dark switch**`ScadaBridge:Cluster:BootstrapGuard:Enabled`, **default off**, so a node with the guard disabled keeps Akka's config-driven self-first auto-join byte-identical. When enabled, the node starts with **no config seed nodes** (`AkkaHostedService.BuildHocon` emits an empty seed list, so Akka does not auto-join) and a coordinator (`ClusterBootstrapCoordinator`, an `IHostedService` registered in both the Central and Site composition roots) picks the join order after a reachability probe:
- The node with the lexicographically **lower** canonical `host:port` is the **preferred founder**: it joins self-first and forms immediately if no peer answers — no probe, no delay. The tie-break is **case-insensitive** (a hostname-casing mismatch between the two configs must never make both think they are the founder and re-open the split).
- The **higher** node TCP-probes its partner's Akka port (a node binds its port well before it forms) up to `PartnerProbeSeconds`: **reachable ⇒ peer-first** (join the founder, never race it); **unreachable after the window ⇒ self-first** (the partner is genuinely down, so cold-start-alone is preserved for the higher node too).
- The order is decided **before** a single `Cluster.JoinSeedNodes`, from an explicit reachability signal. The coordinator **never re-forms mid-handshake** — that was the failure mode of the rejected self-form timer above (a `Join(self)` on a bare timeout is not ignored mid-handshake; it wins and islands a node).
**Residual trade-off (accepted, operator-visible).** Once the higher node commits peer-first it cannot self-form (`JoinSeedNodeProcess` retries forever). If the founder dies in the small probe→join window, the higher node hangs unjoined; the coordinator logs a WARNING after a bounded grace, and a **restart** recovers it (the guard re-runs, finds the founder down, and forms alone). The guard deliberately does not re-decide mid-handshake.
| Key | Default | Meaning |
|---|---|---|
| `BootstrapGuard:Enabled` | `false` | Dark switch. Only meaningful on a node that is one of its own two pair seeds; inert elsewhere (single-node install, self absent, 3+ seeds). |
| `BootstrapGuard:PartnerProbeSeconds` | `25` | Higher node's probe window. Must comfortably exceed the founder's process-start-to-Akka-bind time, or a slow-but-alive founder is mistaken for dead and the split re-opens. Validated `> 0` at boot when the guard is on. |
| `BootstrapGuard:PartnerProbeIntervalMs` | `500` | Interval between probes. Validated `> 0` when enabled. |
| `BootstrapGuard:ProbeConnectTimeoutMs` | `1000` | Per-probe TCP connect timeout. Validated `> 0` when enabled. |
Decision core (`ClusterBootstrapGuard`) is a pure, fully unit-tested function; the probe + `JoinSeedNodes` runtime (`ClusterBootstrapCoordinator`) is covered by real-ActorSystem tests including the load-bearing higher-node-cold-start-alone case and the headline both-cold-start-together-form-one-cluster case (`ClusterBootstrapCoordinatorTests`). The alternative is purely operational (stagger the two VMs' service-manager start / start the founder first); the docker rig's compose `depends_on` does that today, but that does not exist on the real co-located VMs — the guard is the production-faithful fix. Ported from OtOpcUa (`lmxopcua` commit `d1dac87f`); implementing it in both products keeps their failure/recovery model identical, since a shared power event hits both pairs at once.
### Manual Failover (admin-triggered) ### 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`). 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`).
@@ -191,7 +212,7 @@ These values balance failover speed with stability — fast enough that data col
If both nodes in a cluster fail simultaneously (e.g., site power outage): If both nodes in a cluster fail simultaneously (e.g., site power outage):
1. **No manual intervention required.** Since both nodes are configured as seed nodes, whichever node starts first forms a new cluster. The second node joins when it starts. 1. **No manual intervention required.** Since both nodes are configured as seed nodes, whichever node starts first forms a new cluster (self-first ordering) and the second node joins when it starts. If both power up truly simultaneously, each self-first seed can run `FirstSeedNodeProcess` and form its own 1-node cluster — a split brain; enable the opt-in **bootstrap guard** (Gitea #33, see *Simultaneous cold start* above) on co-located VM pairs that share a power domain to make them converge on one cluster instead.
2. **State recovery** (each node has its own local copy of all required data): 2. **State recovery** (each node has its own local copy of all required data):
- **Site clusters**: The Deployment Manager singleton reads deployed configurations from local SQLite and re-creates the full Instance Actor hierarchy. Store-and-forward buffers are already persisted locally. Alarm states re-evaluate from incoming data values. - **Site clusters**: The Deployment Manager singleton reads deployed configurations from local SQLite and re-creates the full Instance Actor hierarchy. Store-and-forward buffers are already persisted locally. Alarm states re-evaluate from incoming data values.
- **Central cluster**: All state is in MS SQL (configuration database). The active node resumes normal operation. - **Central cluster**: All state is in MS SQL (configuration database). The active node resumes normal operation.
+54 -52
View File
@@ -2,7 +2,7 @@
## Purpose ## Purpose
The Communication component manages all messaging between the central cluster and site clusters. It provides the transport layer for deployments, instance lifecycle commands, integration routing, debug streaming, health reporting, notification submission, and remote queries (parked messages, event logs). Two transports are used: **Akka.NET ClusterClient** for command/control messaging and **gRPC server-streaming** for real-time data (attribute values, alarm states). The Communication component manages all messaging between the central cluster and site clusters. It provides the transport layer for deployments, instance lifecycle commands, integration routing, debug streaming, health reporting, notification submission, and remote queries (parked messages, event logs). **gRPC is the only inter-cluster transport** (ClusterClient/ClusterClientReceptionist for site↔central messaging was removed in Phase 4 of the ClusterClient→gRPC migration, 2026-07-23). It carries three concerns: **command/control** in both directions — site→central to the central-hosted `CentralControlService` (heartbeats, health reports, notification submit/status, audit/telemetry ingest, reconcile) and central→site to the site-hosted `SiteCommandService` (deployments, lifecycle, OPC UA, remote queries, parked, route, failover); **gRPC server-streaming** for real-time data (attribute values, alarm states) via `SiteStreamService`; and **plain token-gated HTTP** for the deployment-config fetch (notify-and-fetch). Akka remoting remains, but only *intra-cluster* (pair-internal), never across the site↔central boundary.
## Location ## Location
@@ -10,8 +10,8 @@ Both central and site clusters. Each side has communication actors that handle m
## Responsibilities ## Responsibilities
- Resolve site addresses (Akka remoting and gRPC) from the configuration database and maintain a cached address map. - Resolve site addresses (Akka remoting — intra-cluster only — and gRPC) from the configuration database and maintain a cached address map, including the per-site gRPC command endpoints used by central→site command/control.
- Establish and maintain cross-cluster connections using Akka.NET ClusterClient/ClusterClientReceptionist for command/control. - Establish and maintain cross-cluster command/control connections over gRPC: site→central to `CentralControlService` (`GrpcCentralTransport`) and central→site to `SiteCommandService` (`GrpcSiteTransport`).
- Establish and maintain per-site gRPC streaming connections for real-time data delivery (site→central). - Establish and maintain per-site gRPC streaming connections for real-time data delivery (site→central).
- Route messages between central and site clusters in a hub-and-spoke topology. - Route messages between central and site clusters in a hub-and-spoke topology.
- Broker requests from external systems (via central) to sites and return responses. - Broker requests from external systems (via central) to sites and return responses.
@@ -46,6 +46,7 @@ Both central and site clusters. Each side has communication actors that handle m
- Central routes the request to the appropriate site. - Central routes the request to the appropriate site.
- Site reads values from the Instance Actor and responds. - Site reads values from the Instance Actor and responds.
- Central returns the response to the external system. - Central returns the response to the external system.
- **Implementation**: this brokered round-trip is served by the **Inbound API's routed-site-script path** — a central-side inbound API method script composes the typed `RouteTo*` verbs on `CommunicationService` (`RouteToCall`, `RouteToGetAttributes`, `RouteToSetAttributes`, `RouteToWaitForAttribute`), driven from `InboundAPI/CommunicationServiceInstanceRouter` and sharing `IntegrationTimeout`. An earlier generic `IntegrationCallRequest` primitive was scaffolded for this pattern but never wired to a producer or a site handler; it was removed as dead code (Gitea #32, 2026-07-23).
### 5. Recipe/Command Delivery (External System → Central → Site) ### 5. Recipe/Command Delivery (External System → Central → Site)
- **Pattern**: Fire-and-forget with acknowledgment. - **Pattern**: Fire-and-forget with acknowledgment.
@@ -54,15 +55,15 @@ Both central and site clusters. Each side has communication actors that handle m
- Site applies and acknowledges. - Site applies and acknowledges.
### 6. Debug Streaming (Site → Central) ### 6. Debug Streaming (Site → Central)
- **Pattern**: Subscribe/push with initial snapshot. Two transports: **ClusterClient** for the subscribe/unsubscribe handshake and initial snapshot, **gRPC server-streaming** for ongoing real-time events. - **Pattern**: Subscribe/push with initial snapshot. Both legs ride gRPC: the subscribe/unsubscribe handshake and initial snapshot go over **gRPC command/control** (`SiteCommandService`), and ongoing real-time events over **gRPC server-streaming** (`SiteStreamService`).
- A **DebugStreamBridgeActor** (one per active debug session) is created on the central cluster by the **DebugStreamService**. The bridge actor first opens a **gRPC server-streaming subscription** to the site via `SiteStreamGrpcClient`, then sends a `SubscribeDebugViewRequest` to the site via `CentralCommunicationActor` (ClusterClient). The site's `InstanceActor` replies with an initial snapshot via the ClusterClient reply path. - A **DebugStreamBridgeActor** (one per active debug session) is created on the central cluster by the **DebugStreamService**. The bridge actor first opens a **gRPC server-streaming subscription** to the site via `SiteStreamGrpcClient`, then sends a `SubscribeDebugViewRequest` to the site as a central→site command over `SiteCommandService` (`GrpcSiteTransport`). The site's `InstanceActor` replies with an initial snapshot on that gRPC request/response.
- **gRPC stream (real-time events)**: The site's **SiteStreamGrpcServer** receives the gRPC `SubscribeInstance` call and creates a **StreamRelayActor** that subscribes to **SiteStreamManager** for the requested instance. Events (`AttributeValueChanged`, `AlarmStateChanged`) flow from `SiteStreamManager``StreamRelayActor``Channel<SiteStreamEvent>` (bounded, 1000, DropOldest) → gRPC response stream → `SiteStreamGrpcClient` on central → `DebugStreamBridgeActor`. - **gRPC stream (real-time events)**: The site's **SiteStreamGrpcServer** receives the gRPC `SubscribeInstance` call and creates a **StreamRelayActor** that subscribes to **SiteStreamManager** for the requested instance. Events (`AttributeValueChanged`, `AlarmStateChanged`) flow from `SiteStreamManager``StreamRelayActor``Channel<SiteStreamEvent>` (bounded, 1000, DropOldest) → gRPC response stream → `SiteStreamGrpcClient` on central → `DebugStreamBridgeActor`.
- The `DebugStreamEvent` message type no longer exists — events are not routed through ClusterClient. `SiteCommunicationActor` and `CentralCommunicationActor` have no role in streaming event delivery. - The `DebugStreamEvent` message type no longer exists — events are not routed through a command/control channel. `SiteCommunicationActor` and `CentralCommunicationActor` have no role in streaming event delivery.
- The bridge actor forwards received events to the consumer via callbacks (Blazor component or SignalR hub). - The bridge actor forwards received events to the consumer via callbacks (Blazor component or SignalR hub).
- **Snapshot-to-stream handoff**: The gRPC stream is opened **before** the snapshot request to avoid missing events. The consumer applies the snapshot as baseline, then replays buffered gRPC events with timestamps newer than the snapshot (timestamp-based dedup). - **Snapshot-to-stream handoff**: The gRPC stream is opened **before** the snapshot request to avoid missing events. The consumer applies the snapshot as baseline, then replays buffered gRPC events with timestamps newer than the snapshot (timestamp-based dedup).
- Attribute value stream messages: `[InstanceUniqueName].[AttributePath].[AttributeName]`, value, quality, timestamp. - Attribute value stream messages: `[InstanceUniqueName].[AttributePath].[AttributeName]`, value, quality, timestamp.
- Alarm state stream messages: `[InstanceUniqueName].[AlarmName]`, state (active/normal), priority, timestamp. - Alarm state stream messages: `[InstanceUniqueName].[AlarmName]`, state (active/normal), priority, timestamp.
- Central sends an unsubscribe request via ClusterClient when the debug session ends. The gRPC stream is cancelled. The site's `StreamRelayActor` is stopped and the SiteStreamManager subscription is removed. - Central sends an unsubscribe request over `SiteCommandService` (gRPC command/control) when the debug session ends. The gRPC stream is cancelled. The site's `StreamRelayActor` is stopped and the SiteStreamManager subscription is removed.
- The stream is session-based and temporary. - The stream is session-based and temporary.
- **Accepted limitation (central-side session locality):** debug sessions are process-local state on the central node hosting the `DebugStreamBridgeActor`. A central **restart or failover drops all active sessions** with no `OnStreamTerminated` signal to the operator — the engineer simply re-establishes the debug session from the UI. Debug streaming is an interactive, transient diagnostic aid, so this is an accepted trade-off rather than a durability gap (arch review 02, U7). - **Accepted limitation (central-side session locality):** debug sessions are process-local state on the central node hosting the `DebugStreamBridgeActor`. A central **restart or failover drops all active sessions** with no `OnStreamTerminated` signal to the operator — the engineer simply re-establishes the debug session from the UI. Debug streaming is an interactive, transient diagnostic aid, so this is an accepted trade-off rather than a durability gap (arch review 02, U7).
- **Backpressure is lossy-by-design and now observable:** the per-session `Channel<SiteStreamEvent>` (bounded 1000, `DropOldest`) silently evicts the oldest event when a slow consumer falls behind. The eviction is counted via the channel's `itemDropped` callback and logged at Warning (first eviction, then every 500th) so real event loss on a debug stream is visible instead of silent. - **Backpressure is lossy-by-design and now observable:** the per-session `Channel<SiteStreamEvent>` (bounded 1000, `DropOldest`) silently evicts the oldest event when a slow consumer falls behind. The eviction is counted via the channel's `itemDropped` callback and logged at Warning (first eviction, then every 500th) so real event loss on a debug stream is visible instead of silent.
@@ -89,7 +90,7 @@ Delivered 2026-07-10 (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.m
#### Central-Side Debug Stream Components #### Central-Side Debug Stream Components
- **DebugStreamService**: Singleton service that manages debug stream sessions. Resolves instance ID to unique name and site, creates and tears down `DebugStreamBridgeActor` instances, and provides a clean API for both Blazor components and the SignalR hub. Injects `SiteStreamGrpcClientFactory` for gRPC stream creation. - **DebugStreamService**: Singleton service that manages debug stream sessions. Resolves instance ID to unique name and site, creates and tears down `DebugStreamBridgeActor` instances, and provides a clean API for both Blazor components and the SignalR hub. Injects `SiteStreamGrpcClientFactory` for gRPC stream creation.
- **DebugStreamBridgeActor**: One per active debug session. Opens a gRPC streaming subscription via `SiteStreamGrpcClient` and receives real-time events via callback. Also receives the initial `DebugViewSnapshot` via ClusterClient. Forwards all events to the consumer via callbacks. Handles gRPC stream errors with reconnection logic: tries the other site node endpoint, retries with backoff (max 3 retries), terminates the session if all retries fail. - **DebugStreamBridgeActor**: One per active debug session. Opens a gRPC streaming subscription via `SiteStreamGrpcClient` and receives real-time events via callback. Also receives the initial `DebugViewSnapshot` over gRPC command/control (`SiteCommandService`). Forwards all events to the consumer via callbacks. Handles gRPC stream errors with reconnection logic: tries the other site node endpoint, retries with backoff (max 3 retries), terminates the session if all retries fail.
- **SiteStreamGrpcClient**: Per-site gRPC client that manages `GrpcChannel` instances and streaming subscriptions. Reads from the gRPC response stream in a background task, converts protobuf messages to domain events, and invokes the `onEvent` callback. - **SiteStreamGrpcClient**: Per-site gRPC client that manages `GrpcChannel` instances and streaming subscriptions. Reads from the gRPC response stream in a background task, converts protobuf messages to domain events, and invokes the `onEvent` callback.
- **SiteStreamGrpcClientFactory**: Caches per-site `SiteStreamGrpcClient` instances. Reads `GrpcNodeAAddress` / `GrpcNodeBAddress` from the `Site` entity (loaded by `CentralCommunicationActor`). Falls back to NodeB if NodeA connection fails. Disposes clients on site removal or address change. - **SiteStreamGrpcClientFactory**: Caches per-site `SiteStreamGrpcClient` instances. Reads `GrpcNodeAAddress` / `GrpcNodeBAddress` from the `Site` entity (loaded by `CentralCommunicationActor`). Falls back to NodeB if NodeA connection fails. Disposes clients on site removal or address change.
- **DebugStreamHub**: SignalR hub at `/hubs/debug-stream` for external consumers (e.g., CLI). Authenticates via Basic Auth + LDAP and requires the **Deployment** role. Server-to-client methods: `OnSnapshot`, `OnAttributeChanged`, `OnAlarmChanged`, `OnStreamTerminated`. - **DebugStreamHub**: SignalR hub at `/hubs/debug-stream` for external consumers (e.g., CLI). Authenticates via Basic Auth + LDAP and requires the **Deployment** role. Server-to-client methods: `OnSnapshot`, `OnAttributeChanged`, `OnAlarmChanged`, `OnStreamTerminated`.
@@ -98,10 +99,10 @@ Delivered 2026-07-10 (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.m
The streaming protocol is defined in `sitestream.proto` (`src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto`): The streaming protocol is defined in `sitestream.proto` (`src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto`):
- **Service**: `SiteStreamService` — hosted on each site node by `SiteStreamGrpcServer` — exposes six RPCs. Two are real-time **server-streaming** subscriptions (`SubscribeInstance`, `SubscribeSite`); the other four are **unary request/response** calls added by the Audit Log (#23) and Site Call Audit (#22) components. A unary call is request/response and is distinct from the command/control ClusterClient channel — gRPC on this service is no longer real-time-stream-only: - **Service**: `SiteStreamService` — hosted on each site node by `SiteStreamGrpcServer` — exposes six RPCs. Two are real-time **server-streaming** subscriptions (`SubscribeInstance`, `SubscribeSite`); the other four are **unary request/response** calls added by the Audit Log (#23) and Site Call Audit (#22) components. These are distinct from the command/control gRPC services (`CentralControlService` on central, `SiteCommandService` on the site) — `SiteStreamService` is the streaming + audit-pull surface, not the command/control channel:
- `SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent)` — the per-instance real-time debug stream (§6). - `SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent)` — the per-instance real-time debug stream (§6).
- `SubscribeSite(SiteStreamRequest) returns (stream SiteStreamEvent)` — the **site-wide, alarm-only** aggregated stream (§6.1) added for the operator Alarm Summary live cache. Additive (new RPC + new `SiteStreamRequest { correlation_id }` message; no field renumbering). The server handler mirrors `SubscribeInstance` — same bounded `Channel(1000, DropOldest)`, `GrpcMaxConcurrentStreams`/`GrpcMaxStreamLifetime` limits, `SiteConnectionOpened/Closed` telemetry, and `StreamRelayActor` mapping — but subscribes via `SiteStreamManager.SubscribeSiteAlarms` (all instances, `AlarmStateChanged` only; attribute events dropped, no per-instance filter). - `SubscribeSite(SiteStreamRequest) returns (stream SiteStreamEvent)` — the **site-wide, alarm-only** aggregated stream (§6.1) added for the operator Alarm Summary live cache. Additive (new RPC + new `SiteStreamRequest { correlation_id }` message; no field renumbering). The server handler mirrors `SubscribeInstance` — same bounded `Channel(1000, DropOldest)`, `GrpcMaxConcurrentStreams`/`GrpcMaxStreamLifetime` limits, `SiteConnectionOpened/Closed` telemetry, and `StreamRelayActor` mapping — but subscribes via `SiteStreamManager.SubscribeSiteAlarms` (all instances, `AlarmStateChanged` only; attribute events dropped, no per-instance filter).
- `IngestAuditEvents(AuditEventBatch) returns (IngestAck)` — central-side **ingest** receiving surface for Audit Log (#23) telemetry; routes the batch to the central `AuditLogIngestActor` proxy and returns the accepted `EventId`s. (The production *push* path is still ClusterClient via `ClusterClientSiteAuditClient`; this RPC is the gRPC-receiving counterpart.) - `IngestAuditEvents(AuditEventBatch) returns (IngestAck)` a legacy central-side ingest surface on the *site*-hosted service; it is **dead in the shipped topology** because no site ever dials a site for ingest. The production audit-telemetry *push* path is site→central gRPC to `CentralControlService`, which routes the batch to the central `AuditLogIngestActor` proxy and returns the accepted `EventId`s.
- `IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck)` — ingest receiving surface for the combined cached-call telemetry packet (audit row + `SiteCalls` operational upsert written in one transaction). - `IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck)` — ingest receiving surface for the combined cached-call telemetry packet (audit row + `SiteCalls` operational upsert written in one transaction).
- `PullAuditEvents(PullAuditEventsRequest) returns (PullAuditEventsResponse)` — central→site **reconciliation pull** for the Audit Log self-heal feed; the site serves `Pending`/`Forwarded` rows from its `ISiteAuditQueue`. - `PullAuditEvents(PullAuditEventsRequest) returns (PullAuditEventsResponse)` — central→site **reconciliation pull** for the Audit Log self-heal feed; the site serves `Pending`/`Forwarded` rows from its `ISiteAuditQueue`.
- `PullSiteCalls(PullSiteCallsRequest) returns (PullSiteCallsResponse)` — central→site reconciliation pull for the Site Call Audit (#22) self-heal feed; the site serves operation-tracking rows changed since a cursor from its `IOperationTrackingStore`. A separate RPC from `PullAuditEvents` because the tracking store is the operational source of truth, distinct from the site audit queue. - `PullSiteCalls(PullSiteCallsRequest) returns (PullSiteCallsResponse)` — central→site reconciliation pull for the Site Call Audit (#22) self-heal feed; the site serves operation-tracking rows changed since a cursor from its `IOperationTrackingStore`. A separate RPC from `PullAuditEvents` because the tracking store is the operational source of truth, distinct from the site audit queue.
@@ -205,15 +206,15 @@ Keepalive settings are configurable via `CommunicationOptions`:
- The site **Store-and-Forward Engine** sends a `NotificationSubmit` message to central carrying the notification — `NotificationId`, target list name, subject, body, and source provenance. - The site **Store-and-Forward Engine** sends a `NotificationSubmit` message to central carrying the notification — `NotificationId`, target list name, subject, body, and source provenance.
- Central ingests the submission with an insert-if-not-exists on `NotificationId` and acknowledges **after the row is persisted** to the `Notifications` table in the central configuration database. The site S&F engine clears the buffered message only on that ack. - Central ingests the submission with an insert-if-not-exists on `NotificationId` and acknowledges **after the row is persisted** to the `Notifications` table in the central configuration database. The site S&F engine clears the buffered message only on that ack.
- The `NotificationId` GUID — generated at the site — is the **idempotency key**. The handoff is at-least-once: a re-sent submission after a lost ack is harmless because central's insert-if-not-exists treats the duplicate as a no-op. - The `NotificationId` GUID — generated at the site — is the **idempotency key**. The handoff is at-least-once: a re-sent submission after a lost ack is harmless because central's insert-if-not-exists treats the duplicate as a no-op.
- **Transport**: ClusterClient (site→central command/control), consistent with how other site→central messages are sent. - **Transport**: gRPC to `CentralControlService` (site→central command/control), consistent with how other site→central messages are sent.
### 10. Cached Call Telemetry (Site → Central) ### 10. Cached Call Telemetry (Site → Central)
- **Pattern**: Fire-and-forget telemetry with a periodic reconciliation pull. - **Pattern**: Fire-and-forget telemetry with a periodic reconciliation pull.
- The site **Store-and-Forward Engine** emits a `CachedCallTelemetry` message to central on **every** cached-call lifecycle transition (`Pending → Retrying → Delivered / Parked / Failed / Discarded`). The first telemetry event for an operation carries its initial status — `Pending` when a transient failure has buffered the call, or directly `Delivered`/`Failed` for a cached call that never buffers. The message carries the `TrackedOperationId`, source site, `Kind` (the `TrackedOperationKind` enum), target summary, status, retry count, last error, key timestamps, and source provenance. - The site **Store-and-Forward Engine** emits a `CachedCallTelemetry` message to central on **every** cached-call lifecycle transition (`Pending → Retrying → Delivered / Parked / Failed / Discarded`). The first telemetry event for an operation carries its initial status — `Pending` when a transient failure has buffered the call, or directly `Delivered`/`Failed` for a cached call that never buffers. The message carries the `TrackedOperationId`, source site, `Kind` (the `TrackedOperationKind` enum), target summary, status, retry count, last error, key timestamps, and source provenance.
- Emission is **best-effort and at-least-once**, **idempotent on `TrackedOperationId`** — central's Site Call Audit component ingests with insert-if-not-exists then upsert-on-newer-status, so a re-sent or out-of-order event is harmless. - Emission is **best-effort and at-least-once**, **idempotent on `TrackedOperationId`** — central's Site Call Audit component ingests with insert-if-not-exists then upsert-on-newer-status, so a re-sent or out-of-order event is harmless.
- **Reconciliation pull**: because telemetry is best-effort, the central **Site Call Audit** component periodically — and on site reconnect — pulls the changed rows back from each site over the **`PullSiteCalls` unary gRPC RPC** on `SiteStreamService` (not a ClusterClient round-trip). Central sends a `PullSiteCallsRequest` (`since_utc` cursor + `batch_size`); the site reads its `IOperationTrackingStore` and replies with a `PullSiteCallsResponse` carrying the matching operation-tracking rows (as `SiteCallOperationalDto`s) plus a `more_available` flag that signals a saturated batch so central advances the cursor and pulls again. Any telemetry missed during a disconnect self-heals through this pull. The Audit Log (#23) reconciliation feed uses the sibling `PullAuditEvents` RPC the same way. - **Reconciliation pull**: because telemetry is best-effort, the central **Site Call Audit** component periodically — and on site reconnect — pulls the changed rows back from each site over the **`PullSiteCalls` unary gRPC RPC** on `SiteStreamService` (a distinct surface from the `CentralControlService`/`SiteCommandService` command/control path). Central sends a `PullSiteCallsRequest` (`since_utc` cursor + `batch_size`); the site reads its `IOperationTrackingStore` and replies with a `PullSiteCallsResponse` carrying the matching operation-tracking rows (as `SiteCallOperationalDto`s) plus a `more_available` flag that signals a saturated batch so central advances the cursor and pulls again. Any telemetry missed during a disconnect self-heals through this pull. The Audit Log (#23) reconciliation feed uses the sibling `PullAuditEvents` RPC the same way.
- Central audit is an **eventually-consistent mirror** — the site's operation tracking table remains the source of truth for cached-call status (`Tracking.Status(id)` is always answered site-locally). - Central audit is an **eventually-consistent mirror** — the site's operation tracking table remains the source of truth for cached-call status (`Tracking.Status(id)` is always answered site-locally).
- **Transport**: the *push* telemetry emission rides **ClusterClient** (site→central command/control), consistent with how other site→central messages are sent; the *reconciliation pull* rides the **gRPC** unary `PullSiteCalls` RPC (central→site request/response). The two paths are complementary — push is the fast, best-effort feed; pull is the slower self-heal backfill. - **Transport**: the *push* telemetry emission rides **gRPC to `CentralControlService`** (site→central command/control), consistent with how other site→central messages are sent; the *reconciliation pull* rides the **gRPC** unary `PullSiteCalls` RPC on `SiteStreamService` (central→site request/response). The two paths are complementary — push is the fast, best-effort feed; pull is the slower self-heal backfill.
## Topology ## Topology
@@ -221,37 +222,38 @@ Keepalive settings are configurable via `CommunicationOptions`:
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%% %%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart LR flowchart LR
subgraph Central["Central Cluster"] subgraph Central["Central Cluster"]
CCA["ClusterClient<br/>(command/control)"] CCS["CentralControlService<br/>(gRPC server — site→central cmd/ctrl)"]
CCB["ClusterClient<br/>(command/control)"] SCA["GrpcSiteTransport → SiteCommandService<br/>(gRPC client, per site)"]
CCN["ClusterClient<br/>(command/control)"] SCB["GrpcSiteTransport → SiteCommandService"]
SCN["GrpcSiteTransport → SiteCommandService"]
GRPCC["SiteStreamGrpcClient<br/>(real-time data)"] GRPCC["SiteStreamGrpcClient<br/>(real-time data)"]
end end
subgraph SiteA["Site A Cluster"] subgraph SiteA["Site A Cluster"]
SACOMM["SiteCommunicationActor<br/>(via Receptionist)"] SACMD["SiteCommandService<br/>(gRPC server — central→site cmd/ctrl)"]
SAGRPC["SiteStreamGrpcServer<br/>(Kestrel HTTP/2, port 8083)"] SAGRPC["SiteStreamGrpcServer<br/>(Kestrel HTTP/2, port 8083)"]
SACC["ClusterClient to Central<br/>(CentralCommunicationActor)"] SACC["GrpcCentralTransport → CentralControlService<br/>(gRPC client)"]
end end
subgraph SiteB["Site B Cluster"] subgraph SiteB["Site B Cluster"]
SBCOMM["SiteCommunicationActor<br/>(via Receptionist)"] SBCMD["SiteCommandService"]
SBGRPC["SiteStreamGrpcServer"] SBGRPC["SiteStreamGrpcServer"]
end end
subgraph SiteN["Site N Cluster"] subgraph SiteN["Site N Cluster"]
SNCOMM["SiteCommunicationActor<br/>(via Receptionist)"] SNCMD["SiteCommandService"]
SNGRPC["SiteStreamGrpcServer"] SNGRPC["SiteStreamGrpcServer"]
end end
CCA -->|command/control| SACOMM SCA -->|"gRPC command/control"| SACMD
CCB -->|command/control| SBCOMM SCB -->|"gRPC command/control"| SBCMD
CCN -->|command/control| SNCOMM SCN -->|"gRPC command/control"| SNCMD
SAGRPC -->|"gRPC stream (real-time data)"| GRPCC SAGRPC -->|"gRPC stream (real-time data)"| GRPCC
SBGRPC -->|gRPC stream| GRPCC SBGRPC -->|gRPC stream| GRPCC
SNGRPC -->|gRPC stream| GRPCC SNGRPC -->|gRPC stream| GRPCC
SACC -.->|command/control| Central SACC -.->|"gRPC command/control"| CCS
NOTE["Sites do NOT communicate with each other.<br/>All inter-cluster communication flows through Central."] NOTE["Sites do NOT communicate with each other.<br/>All inter-cluster communication flows through Central."]
@@ -260,7 +262,7 @@ flowchart LR
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111; classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111; classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666; classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class CCA,CCB,CCN,SACOMM,SACC,SBCOMM,SNCOMM dec class CCS,SCA,SCB,SCN,SACMD,SACC,SBCMD,SNCMD dec
class GRPCC,SAGRPC,SBGRPC,SNGRPC start class GRPCC,SAGRPC,SBGRPC,SNGRPC start
class NOTE muted class NOTE muted
class Central proc class Central proc
@@ -269,24 +271,24 @@ flowchart LR
- Sites do **not** communicate with each other. - Sites do **not** communicate with each other.
- All inter-cluster communication flows through central. - All inter-cluster communication flows through central.
- Both **CentralCommunicationActor** and **SiteCommunicationActor** are registered with their cluster's **ClusterClientReceptionist** for cross-cluster discovery. - Command/control uses gRPC in both directions: central dials each site's **SiteCommandService** (per-site NodeA→NodeB failover channel pair), and each site dials the central **CentralControlService** (sticky central-a→central-b failover channel pair). There is no ClusterClientReceptionist and no cross-cluster actor discovery — the endpoints are dialled directly.
## Site Address Resolution ## Site Address Resolution
Central discovers site addresses through the **configuration database**, not runtime registration: Central discovers site addresses through the **configuration database**, not runtime registration:
- Each site record in the Sites table includes optional **NodeAAddress** and **NodeBAddress** fields containing base Akka addresses of the site's cluster nodes (e.g., `akka.tcp://scadabridge@host:port`), and optional **GrpcNodeAAddress** and **GrpcNodeBAddress** fields containing gRPC endpoints (e.g., `http://host:8083`). - Each site record in the Sites table includes optional **GrpcNodeAAddress** and **GrpcNodeBAddress** fields containing the site nodes' gRPC endpoints (e.g., `http://host:8083`), used for **both** central→site command/control (`SiteCommandService`) and site→central streaming (`SiteStreamService`). The legacy **NodeAAddress** / **NodeBAddress** Akka fields are retained but no longer drive cross-cluster dialling — Akka remoting is now intra-cluster only.
- The **CentralCommunicationActor** loads all site addresses from the database at startup and creates one **ClusterClient per site**, configured with both NodeA and NodeB as contact points. The **SiteStreamGrpcClientFactory** uses `GrpcNodeAAddress` / `GrpcNodeBAddress` to create per-site gRPC channels for streaming. - The **CentralCommunicationActor** loads all site addresses from the database at startup and builds a per-site gRPC-endpoint cache. `GrpcSiteTransport` uses it (via a `SitePairChannelProvider`) to dial each site's `SiteCommandService` as a **per-site NodeANodeB failover channel pair**; the **SiteStreamGrpcClientFactory** uses the same `GrpcNodeAAddress` / `GrpcNodeBAddress` for per-site streaming channels.
- The address cache is **refreshed every 60 seconds** and **on-demand** when site records are added, edited, or deleted via the Central UI or CLI. ClusterClient instances are recreated when contact points change. - The address cache is **refreshed every 60 seconds** and **on-demand** when site records are added, edited, or deleted via the Central UI or CLI. Channels are rebuilt when a site's gRPC endpoints change.
- When routing a message to a site, central sends via `ClusterClient.Send("/user/site-communication", msg)`. **ClusterClient handles failover between NodeA and NodeB internally** — there is no application-level NodeA preference/NodeB fallback logic. - When routing a command to a site, central calls `SiteCommandService` on the site's active gRPC endpoint; on error it flips to the other node endpoint. The failover is an explicit NodeA→NodeB channel-pair flip, not internal transport routing.
- **Heartbeats** from sites serve **health monitoring only** — they do not serve as a registration or address discovery mechanism. - **Heartbeats** from sites serve **health monitoring only** — they do not serve as a registration or address discovery mechanism.
- If no addresses are configured for a site, messages to that site are **dropped** and the caller's Ask times out. - If no gRPC endpoints are configured for a site, commands to that site fail and the caller's call times out.
### Site → Central Communication ### Site → Central Communication
- Site nodes configure a list of **CentralContactPoints** (both central node addresses) instead of a single `CentralActorPath`. - Site nodes configure a list of **CentralGrpcEndpoints** — the gRPC h2c URLs of the central nodes (e.g., `http://scadabridge-central-a:8083`, on the central's `CentralGrpcPort`, default 8083) — replacing the former `CentralContactPoints` list of Akka addresses. A site node must list at least one; central nodes leave it empty (they host `CentralControlService`, they do not dial it). Note these are the central nodes' **direct** gRPC endpoints, not Traefik (which is HTTP/1 only).
- The site creates a **ClusterClient** using the central contact points and sends heartbeats, health reports, and other messages via `ClusterClient.Send("/user/central-communication", msg)`. - `GrpcCentralTransport` dials `CentralControlService` over these endpoints as a **sticky central-a→central-b failover channel pair**, and the site sends heartbeats, health reports, notification submit/status, audit/telemetry ingest, and reconcile over it.
- ClusterClient handles automatic failover between central nodes — if the active central node goes down, the site's ClusterClient reconnects to the standby node transparently. - If the active central node goes down, the site's transport flips to the standby central endpoint transparently.
## Message Timeouts ## Message Timeouts
@@ -301,17 +303,17 @@ Each request/response pattern has a default timeout that can be overridden in co
| 5. Recipe/Command Delivery | 30 seconds | Fire-and-forget with ack | | 5. Recipe/Command Delivery | 30 seconds | Fire-and-forget with ack |
| 8. Remote Queries | 30 seconds | Querying parked messages or event logs | | 8. Remote Queries | 30 seconds | Querying parked messages or event logs |
| 9. Notification Submission | 30 seconds | Fire-and-forget with ack; central acks after persisting the row | | 9. Notification Submission | 30 seconds | Fire-and-forget with ack; central acks after persisting the row |
| 10. Cached Call Telemetry | 30 seconds | Telemetry emission (ClusterClient) is fire-and-forget; the reconciliation pull is the unary gRPC `PullSiteCalls` request/response (its deadline is the gRPC call timeout, not the Akka ask) | | 10. Cached Call Telemetry | 30 seconds | Telemetry emission (gRPC to `CentralControlService`) is fire-and-forget; the reconciliation pull is the unary gRPC `PullSiteCalls` request/response (its deadline is the gRPC call timeout) |
Timeouts use the Akka.NET **ask pattern**. If no response is received within the timeout, the caller receives a timeout failure. Command/control request/response now uses **gRPC calls with a deadline** in place of the Akka ask; the per-pattern values above are applied as the gRPC call timeout. If no response is received within the timeout, the caller receives a timeout failure.
## Transport Configuration ## Transport Configuration
Akka.NET remoting provides the underlying transport for both intra-cluster communication and ClusterClient connections. The following transport-level settings are **explicitly configured** (not left to framework defaults) for predictable behavior: Akka.NET remoting provides the underlying transport for **intra-cluster** (pair-internal) communication only; it no longer crosses the site↔central boundary. Cross-cluster command/control, streaming, and audit pulls all ride gRPC. The following settings apply to each concern:
- **Transport heartbeat interval**: Configurable interval at which heartbeat messages are sent over remoting connections (e.g., every 5 seconds). - **Transport heartbeat interval** (Akka remoting, intra-cluster): Configurable interval at which heartbeat messages are sent over remoting connections within a cluster (e.g., every 5 seconds).
- **Failure detection threshold**: Number of missed heartbeats before the connection is considered lost (e.g., 3 missed heartbeats = 15 seconds with a 5-second interval). - **Failure detection threshold** (Akka remoting, intra-cluster): Number of missed heartbeats before an intra-cluster connection is considered lost.
- **Reconnection**: ClusterClient handles reconnection and failover between contact points automatically for cross-cluster communication. No custom reconnection logic is required. - **gRPC reconnection/failover**: Both command/control transports handle reconnection and node failover via their channel pairs — `GrpcCentralTransport` (site→central, central-a→central-b) and `GrpcSiteTransport` (central→site, NodeA→NodeB). No ClusterClient reconnection is involved.
These settings should be tuned for the expected network conditions between central and site clusters. These settings should be tuned for the expected network conditions between central and site clusters.
@@ -327,32 +329,32 @@ This provides protocol-level safety beyond Akka.NET's transport guarantees, whic
## Message Ordering ## Message Ordering
Akka.NET guarantees message ordering between a specific sender/receiver actor pair. The Communication Layer relies on this guarantee — messages to a given site are processed in the order they are sent. Callers do not need to handle out-of-order delivery. Within a cluster, Akka.NET guarantees message ordering between a specific sender/receiver actor pair. Across the site↔central boundary, ordering is preserved by the single gRPC channel each transport holds to a given endpoint — commands to a given site over `SiteCommandService` are issued in order over that channel. Callers do not need to handle out-of-order delivery.
## ManagementActor and ClusterClient ## ManagementActor and cross-cluster access
The ManagementActor runs at the well-known path `/user/management` on central nodes. It is **not** advertised via ClusterClientReceptionist, and no ClusterClient reaches it. The ManagementActor runs at the well-known path `/user/management` on central nodes. It is **not** advertised for cross-cluster access, and nothing outside the central cluster reaches it directly.
That registration existed until 2026-07-22 for an out-of-cluster CLI that was never built. The shipped CLI speaks **HTTP Basic to the central `/management` endpoints**; those endpoints ask the ManagementActor **in-process** through `ManagementActorHolder` (`ManagementEndpoints.cs`). Nothing in the repo ever sent to `/user/management` across the boundary, so the registration was deleted — leaving exactly one receptionist registration per cluster role (`CentralCommunicationActor` on central, `SiteCommunicationActor` on sites), both of which serve inter-cluster central↔site messaging and are themselves scheduled for removal by the gRPC transport migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`). A ClusterClientReceptionist registration for it existed until 2026-07-22, for an out-of-cluster CLI that was never built. The shipped CLI speaks **HTTP Basic to the central `/management` endpoints**; those endpoints ask the ManagementActor **in-process** through `ManagementActorHolder` (`ManagementEndpoints.cs`). Nothing in the repo ever sent to `/user/management` across the boundary, so the registration was deleted. The two remaining receptionist registrations — `CentralCommunicationActor` on central and `SiteCommunicationActor` on sites — were subsequently **removed entirely in Phase 4 of the ClusterClient→gRPC migration** (2026-07-23): cross-cluster messaging is now gRPC (`CentralControlService` / `SiteCommandService`), reached by dialling the configured endpoints, so `ClusterClientReceptionist` is no longer used anywhere. `Akka.Cluster.Tools` remains a dependency for ClusterSingleton (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
## Connection Failure Behavior ## Connection Failure Behavior
Disconnect is detected at the **transport layer**, never via an application-level signal from central. There is no `ConnectionStateChanged`-style synchronous notification: the central coordinator does not maintain a model of "this site is up / down" because the two transports already report unavailability at their natural cadence. Disconnect is detected at the **transport layer**, never via an application-level signal from central. There is no `ConnectionStateChanged`-style synchronous notification: the central coordinator does not maintain a model of "this site is up / down" because the two transports already report unavailability at their natural cadence.
- **In-flight command/control messages (ClusterClient + Ask)**: When a connection drops while a request is in flight (e.g., a deployment sent but no response received), the Akka ask pattern times out and the caller receives a failure. There is **no automatic retry or buffering at central** — the engineer sees the failure in the UI and re-initiates the action. This is consistent with the design principle that central does not buffer messages. An in-progress deployment whose round-trip exceeds the Ask timeout (default 120 s at `CommunicationService.DeployInstanceAsync`) surfaces as `DeploymentStatus.Failed` to the caller. - **In-flight command/control messages (gRPC call + deadline)**: When a connection drops while a request is in flight (e.g., a deployment sent but no response received), the gRPC call fails or hits its deadline and the caller receives a failure. There is **no automatic retry or buffering at central** — the engineer sees the failure in the UI and re-initiates the action. This is consistent with the design principle that central does not buffer messages. An in-progress deployment whose round-trip exceeds the timeout (default 120 s at `CommunicationService.DeployInstanceAsync`) surfaces as `DeploymentStatus.Failed` to the caller.
- **Debug streams (gRPC)**: Any gRPC stream interruption is detected by the HTTP/2 keepalive PING (~25 s) and triggers reconnection logic in the `DebugStreamBridgeActor`. The bridge actor attempts to reconnect to the other site node endpoint (NodeB if NodeA failed, or vice versa), with up to 3 retries and 5-second backoff. If all retries fail, the consumer is notified via `OnStreamTerminated` and the bridge actor is stopped. Events during the reconnection gap are lost (acceptable for real-time debug view). On successful reconnection, the consumer can request a fresh snapshot to re-sync state. - **Debug streams (gRPC)**: Any gRPC stream interruption is detected by the HTTP/2 keepalive PING (~25 s) and triggers reconnection logic in the `DebugStreamBridgeActor`. The bridge actor attempts to reconnect to the other site node endpoint (NodeB if NodeA failed, or vice versa), with up to 3 retries and 5-second backoff. If all retries fail, the consumer is notified via `OnStreamTerminated` and the bridge actor is stopped. Events during the reconnection gap are lost (acceptable for real-time debug view). On successful reconnection, the consumer can request a fresh snapshot to re-sync state.
## Failover Behavior ## Failover Behavior
- **Central failover**: The standby node takes over the Akka.NET cluster role. In-progress deployments are treated as failed. Site ClusterClients automatically reconnect to the standby central node via their configured contact points. - **Central failover**: The standby node takes over the Akka.NET cluster role. In-progress deployments are treated as failed. Each site's `GrpcCentralTransport` flips to the standby central endpoint (`CentralGrpcEndpoints` pair) transparently.
- **Site failover**: The standby node takes over. The Deployment Manager singleton restarts and re-creates the Instance Actor hierarchy. Central's per-site ClusterClient automatically reconnects to the surviving site node. Ongoing debug streams are interrupted and must be re-established by the engineer. - **Site failover**: The standby node takes over. The Deployment Manager singleton restarts and re-creates the Instance Actor hierarchy. Central's per-site `GrpcSiteTransport` flips to the surviving site node's gRPC endpoint. Ongoing debug streams are interrupted and must be re-established by the engineer.
## Dependencies ## Dependencies
- **Akka.NET Remoting + ClusterClient**: Provides the command/control transport layer. ClusterClient/ClusterClientReceptionist used for cross-cluster command/control messaging (deployments, lifecycle, subscribe/unsubscribe handshake, snapshots). - **gRPC (Grpc.AspNetCore + Grpc.Net.Client)**: Provides the **only** cross-cluster transport — command/control (`CentralControlService` on central, `SiteCommandService` on sites), real-time streaming (`SiteStreamService`, site-hosted), and the audit-pull RPCs. Central hosts `CentralControlService` and dials each site's `SiteCommandService` (`GrpcSiteTransport`) + `SiteStreamService`; sites host `SiteCommandService` + `SiteStreamGrpcServer` and dial `CentralControlService` (`GrpcCentralTransport`).
- **gRPC (Grpc.AspNetCore + Grpc.Net.Client)**: Provides the real-time data streaming transport. Site nodes host a gRPC server (SiteStreamGrpcServer); central nodes create per-site gRPC clients (SiteStreamGrpcClient). - **Akka.NET Remoting + `Akka.Cluster.Tools`**: Remoting provides **intra-cluster** transport only. `Akka.Cluster.Tools` provides ClusterSingleton; ClusterClient/ClusterClientReceptionist are no longer used (removed in Phase 4).
- **Cluster Infrastructure**: Manages node roles and failover detection. - **Cluster Infrastructure**: Manages node roles and failover detection.
- **Configuration Database**: Provides site node addresses (NodeAAddress, NodeBAddress for Akka remoting; GrpcNodeAAddress, GrpcNodeBAddress for gRPC streaming) for address resolution. - **Configuration Database**: Provides site node gRPC endpoints (GrpcNodeAAddress, GrpcNodeBAddress — used for both command/control and streaming) for address resolution; the legacy Akka NodeAAddress/NodeBAddress fields are retained but no longer drive cross-cluster dialling.
- **Site Runtime (SiteStreamManager)**: The SiteStreamGrpcServer subscribes to SiteStreamManager to receive real-time events for gRPC delivery. - **Site Runtime (SiteStreamManager)**: The SiteStreamGrpcServer subscribes to SiteStreamManager to receive real-time events for gRPC delivery.
- **`ISiteAuditQueue` (site-local)**: Handed to `SiteStreamGrpcServer` (post-construction, on site roles) so the `PullAuditEvents` RPC can read the site's `Pending`/`Forwarded` audit rows to serve the Audit Log (#23) reconciliation pull. Null when not wired (central-only host) — the handler then returns an empty response. - **`ISiteAuditQueue` (site-local)**: Handed to `SiteStreamGrpcServer` (post-construction, on site roles) so the `PullAuditEvents` RPC can read the site's `Pending`/`Forwarded` audit rows to serve the Audit Log (#23) reconciliation pull. Null when not wired (central-only host) — the handler then returns an empty response.
- **`IOperationTrackingStore` (site-local)**: Handed to `SiteStreamGrpcServer` (post-construction, on site roles) so the `PullSiteCalls` RPC can read operation-tracking rows changed since a cursor to serve the Site Call Audit (#22) reconciliation pull. Null when not wired — the handler returns an empty response. - **`IOperationTrackingStore` (site-local)**: Handed to `SiteStreamGrpcServer` (post-construction, on site roles) so the `PullSiteCalls` RPC can read operation-tracking rows changed since a cursor to serve the Site Call Audit (#22) reconciliation pull. Null when not wired — the handler returns an empty response.
@@ -364,8 +366,8 @@ Disconnect is detected at the **transport layer**, never via an application-leve
- **Site Runtime**: Receives deployments, lifecycle commands, and artifact updates. Provides debug view data. - **Site Runtime**: Receives deployments, lifecycle commands, and artifact updates. Provides debug view data.
- **Central UI**: Debug view requests and remote queries flow through communication. - **Central UI**: Debug view requests and remote queries flow through communication.
- **Health Monitoring**: Receives periodic health reports from sites. - **Health Monitoring**: Receives periodic health reports from sites.
- **Store-and-Forward Engine (site)**: Parked message queries/commands are routed through communication. Also emits `CachedCallTelemetry` (push, ClusterClient) and serves the `PullSiteCalls` gRPC reconciliation pull from its `IOperationTrackingStore`, and receives relayed `RetryParkedOperation` / `DiscardParkedOperation` commands. - **Store-and-Forward Engine (site)**: Parked message queries/commands are routed through communication. Also emits `CachedCallTelemetry` (push, gRPC to `CentralControlService`) and serves the `PullSiteCalls` gRPC reconciliation pull from its `IOperationTrackingStore`, and receives relayed `RetryParkedOperation` / `DiscardParkedOperation` commands.
- **Site Call Audit (central)**: Receives cached-call telemetry and issues the `PullSiteCalls` gRPC reconciliation pulls to sites; relays parked-operation Retry/Discard commands to sites through communication. - **Site Call Audit (central)**: Receives cached-call telemetry and issues the `PullSiteCalls` gRPC reconciliation pulls to sites; relays parked-operation Retry/Discard commands to sites through communication.
- **Audit Log (#23)**: Sites forward audit-event telemetry (push) and serve the `PullAuditEvents` gRPC reconciliation pull from their `ISiteAuditQueue`; the central `AuditLogIngestActor` is the ingest target for both the push path and the combined cached-call telemetry packet. - **Audit Log (#23)**: Sites forward audit-event telemetry (push) and serve the `PullAuditEvents` gRPC reconciliation pull from their `ISiteAuditQueue`; the central `AuditLogIngestActor` is the ingest target for both the push path and the combined cached-call telemetry packet.
- **Site Event Logging**: Event log queries are routed through communication. - **Site Event Logging**: Event log queries are routed through communication.
- **Management Service**: The ManagementActor is registered with ClusterClientReceptionist on central nodes. The CLI communicates with the ManagementActor via ClusterClient, which is a separate channel from inter-cluster remoting. - **Management Service**: The ManagementActor runs at `/user/management` on central nodes and is reached **in-process** via `ManagementActorHolder`. The CLI communicates with it over the central HTTP `/management` endpoints (HTTP Basic), not over any cluster transport. It is no longer registered with ClusterClientReceptionist (that registration, and the whole ClusterClient surface, is gone).
@@ -203,6 +203,7 @@ DCL is a clean data pipe on the hot path. Browse is an **opt-in capability** for
- `Resolve(reference, session.NamespaceUris)` accepts **both** `ns=<index>;s=<id>` (existing bindings, unchanged) and the durable `nsu=<namespace-uri>;s=<id>`, mapping the URI to the live index at use time. It is used at every parse site — subscribe, read, write, alarm-subscribe, browse. A URI absent from the server's `NamespaceArray` throws with a message naming the URI, rather than binding to whatever sits at that index; a `svr=` reference to another server is rejected outright. - `Resolve(reference, session.NamespaceUris)` accepts **both** `ns=<index>;s=<id>` (existing bindings, unchanged) and the durable `nsu=<namespace-uri>;s=<id>`, mapping the URI to the live index at use time. It is used at every parse site — subscribe, read, write, alarm-subscribe, browse. A URI absent from the server's `NamespaceArray` throws with a message naming the URI, rather than binding to whatever sits at that index; a `svr=` reference to another server is rejected outright.
- `ToDurable(expandedNodeId, session.NamespaceUris)` is what the browser emits, so **what the picker shows is what gets stored** and newly-authored bindings are index-proof from the start. Namespace 0 is exempt (spec-fixed) and keeps its short form. It also closes a round-trip gap: the browse previously emitted `ExpandedNodeId.ToString()`, which for a URI- or server-index-carrying reference produced a string `NodeId.Parse` could not read back. - `ToDurable(expandedNodeId, session.NamespaceUris)` is what the browser emits, so **what the picker shows is what gets stored** and newly-authored bindings are index-proof from the start. Namespace 0 is exempt (spec-fixed) and keeps its short form. It also closes a round-trip gap: the browse previously emitted `ExpandedNodeId.ToString()`, which for a URI- or server-index-carrying reference produced a string `NodeId.Parse` could not read back.
- Bindings stored before this change keep their `ns=` form and keep working; they are only as durable as the server's namespace order. Re-authoring against a picker (which now emits `nsu=`) is what makes them durable — see `docs/plans/` and Gitea #14 for the OtOpcUa v3.0 cutover, where v2's sole custom namespace and v3's `raw` namespace both sit at index 2, so v2-era bindings resolve against v3 without error while meaning something else. - Bindings stored before this change keep their `ns=` form and keep working; they are only as durable as the server's namespace order. Re-authoring against a picker (which now emits `nsu=`) is what makes them durable — see `docs/plans/` and Gitea #14 for the OtOpcUa v3.0 cutover, where v2's sole custom namespace and v3's `raw` namespace both sit at index 2, so v2-era bindings resolve against v3 without error while meaning something else.
- **OtOpcUa v3.0 dual-namespace cutover**: v3.0 splits OtOpcUa's address space into two namespaces, `raw` (`https://zb.com/otopcua/raw`) and `uns` (`https://zb.com/otopcua/uns`), where v2 exposed one. ScadaBridge is already namespace-URI-durable against this split: `OpcUaNodeReference` resolves a stored `nsu=<uri>;s=...` binding against the live session `NamespaceArray` at use time, so it is index-proof regardless of which namespace a given tag lands in or how many namespaces the server publishes. The node-browser picker already nudges operators toward the durable form, since `ToDurable` is what it emits for newly-authored bindings. See `docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md` for the full cutover scope.
- Browse runs against the live session; no caching at DCL. - Browse runs against the live session; no caching at DCL.
- **Frame-size guard**: the reply crosses the site→central Akka frame (default 128 KB) on a temp Ask actor; an oversized reply is silently discarded by remoting, hanging the picker. The child handler caps each `BrowseNodeResult` to a byte budget (~100 KB) before replying, OR-ing the adapter's own truncation signal into `Truncated`. This is protocol-agnostic (every adapter's reply funnels through it). Per-protocol upstream caps narrow the window first: OPC UA requests at most 500 references per node (continuation point → `Truncated`); MxGateway relies on the gateway's `BrowseChildren` page cap. - **Frame-size guard**: the reply crosses the site→central Akka frame (default 128 KB) on a temp Ask actor; an oversized reply is silently discarded by remoting, hanging the picker. The child handler caps each `BrowseNodeResult` to a byte budget (~100 KB) before replying, OR-ing the adapter's own truncation signal into `Truncated`. This is protocol-agnostic (every adapter's reply funnels through it). Per-protocol upstream caps narrow the window first: OPC UA requests at most 500 references per node (continuation point → `Truncated`); MxGateway relies on the gateway's `BrowseChildren` page cap.
- **`BrowseNext` paging** (OPC UA): a `Truncated` level no longer forces manual node-id entry. When the OPC UA browse is truncated, the adapter returns the session's continuation point as the reply's opaque `ContinuationToken`; a follow-up `BrowseNodeCommand` carrying that token calls `Session.BrowseNext` to fetch the next page (the picker exposes this as **"Load more"**). Continuation points are session-bound and can expire — `BadContinuationPointInvalid` is caught and the browse restarts from a fresh first page rather than failing. Manual node-id entry remains as a fallback when the site or its session is offline. - **`BrowseNext` paging** (OPC UA): a `Truncated` level no longer forces manual node-id entry. When the OPC UA browse is truncated, the adapter returns the session's continuation point as the reply's opaque `ContinuationToken`; a follow-up `BrowseNodeCommand` carrying that token calls `Session.BrowseNext` to fetch the next page (the picker exposes this as **"Load more"**). Continuation points are session-bound and can expire — `BadContinuationPointInvalid` is caught and the browse restarts from a fresh first page rather than failing. Manual node-id entry remains as a fallback when the site or its session is offline.
+15 -10
View File
@@ -45,7 +45,7 @@ The Host must bind configuration sections from `appsettings.json` to strongly-ty
| Section | Options Class | Owner | Contents | | Section | Options Class | Owner | Contents |
|---------|--------------|-------|----------| |---------|--------------|-------|----------|
| `ScadaBridge:Node` | `NodeOptions` | Host | Role, NodeHostname, SiteId, RemotingPort, GrpcPort (site only, default 8083) | | `ScadaBridge:Node` | `NodeOptions` | Host | Role, NodeHostname, SiteId, RemotingPort, GrpcPort (site: hosts `SiteCommandService` + `SiteStreamGrpcServer`, default 8083), CentralGrpcPort (central: hosts `CentralControlService`, default 8083) |
| `ScadaBridge:Cluster` | `ClusterOptions` | ClusterInfrastructure | SeedNodes, SplitBrainResolverStrategy, StableAfter, HeartbeatInterval, FailureDetectionThreshold, MinNrOfMembers | | `ScadaBridge:Cluster` | `ClusterOptions` | ClusterInfrastructure | SeedNodes, SplitBrainResolverStrategy, StableAfter, HeartbeatInterval, FailureDetectionThreshold, MinNrOfMembers |
| `ScadaBridge:Database` | `DatabaseOptions` | Host | Central: ConfigurationDb, MachineDataDb connection strings; Site: SQLite paths | | `ScadaBridge:Database` | `DatabaseOptions` | Host | Central: ConfigurationDb, MachineDataDb connection strings; Site: SQLite paths |
@@ -113,7 +113,7 @@ The Host bootstraps the Akka.NET actor system from a **hand-assembled, injection
- **Coordinated shutdown**: `run-coordinated-shutdown-when-down = on` plus a cluster-leave phase timeout above the singleton drain budget (see REQ-HOST-4a / the down-if-alone recovery contract in Component-ClusterInfrastructure.md). - **Coordinated shutdown**: `run-coordinated-shutdown-when-down = on` plus a cluster-leave phase timeout above the singleton drain budget (see REQ-HOST-4a / the down-if-alone recovery contract in Component-ClusterInfrastructure.md).
- **Actor registration**: each component's actors registered conditional on the node's role. - **Actor registration**: each component's actors registered conditional on the node's role.
> **Bootstrap-mechanism note.** The `Akka.Hosting` / `Akka.Cluster.Hosting` / `Akka.Remote.Hosting` typed-builder packages were referenced but unused and were dropped (arch-review 01 Task 22). The hand-rolled HOCON path is a single hardened, production-shape code path with full test coverage; migrating to Akka.Hosting's typed options is deliberately **not** done here and is tracked as possible future work rather than carried as an unused dependency. Only `Akka.Cluster.Tools` (ClusterSingleton/ClusterClient, transitively Akka.Cluster) remains. > **Bootstrap-mechanism note.** The `Akka.Hosting` / `Akka.Cluster.Hosting` / `Akka.Remote.Hosting` typed-builder packages were referenced but unused and were dropped (arch-review 01 Task 22). The hand-rolled HOCON path is a single hardened, production-shape code path with full test coverage; migrating to Akka.Hosting's typed options is deliberately **not** done here and is tracked as possible future work rather than carried as an unused dependency. Only `Akka.Cluster.Tools` (ClusterSingleton, transitively Akka.Cluster) remains — its ClusterClient/ClusterClientReceptionist surface is no longer used since Phase 4 moved cross-cluster messaging to gRPC.
> **Persistence note.** ScadaBridge does not use Akka.Persistence. Durable state > **Persistence note.** ScadaBridge does not use Akka.Persistence. Durable state
> (store-and-forward buffers, site event logs, static attribute writes, > (store-and-forward buffers, site event logs, static attribute writes,
@@ -123,13 +123,18 @@ The Host bootstraps the Akka.NET actor system from a **hand-assembled, injection
> `akka.persistence` section and references no persistence plugin. There are no > `akka.persistence` section and references no persistence plugin. There are no
> `PersistentActor` subclasses in the system by design. > `PersistentActor` subclasses in the system by design.
### REQ-HOST-6a: ClusterClientReceptionist (Central Only) ### REQ-HOST-6a: gRPC command/control services (no ClusterClientReceptionist)
On central nodes, the Host must configure the Akka.NET **ClusterClientReceptionist** and register the **CentralCommunicationActor** with it, so that site clusters' ClusterClients can reach the central command/control endpoint without joining the central cluster. As of Phase 4 of the ClusterClient→gRPC migration (2026-07-23), the Host configures **no** `ClusterClientReceptionist` and registers **no** actor with one. Both remaining receptionist registrations were removed:
**The ManagementActor is NOT registered with the receptionist** (removed 2026-07-22). That registration was written for an out-of-cluster CLI that was never built: the shipped CLI speaks HTTP Basic to the central `/management` endpoints, which ask the ManagementActor **in-process** via `ManagementActorHolder` (`ManagementEndpoints.cs`). No sender to `/user/management` existed anywhere in the repo, so the registration only widened the cluster-client surface for nothing. The actor itself still runs at `/user/management`; only its cross-boundary advertisement is gone. - The central **CentralCommunicationActor** registration is gone. Sites now reach central command/control over gRPC by dialling the central-hosted **`CentralControlService`** (`GrpcCentralTransport`, sticky central-a→central-b failover), not via a ClusterClient locating a receptionist.
- The site **SiteCommunicationActor** registration is gone. Central now reaches site command/control over gRPC by dialling the site-hosted **`SiteCommandService`** (`GrpcSiteTransport`, per-site NodeA→NodeB failover).
> **Migration note.** This receptionist registration — and the `CentralCommunicationActor` one that remains — are scheduled for deletion once the site↔central transport moves to gRPC. See `docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`. Central hosts `CentralControlService` (site dials in); each site hosts `SiteCommandService` and `SiteStreamGrpcServer` (central dials in). Endpoints are dialled directly from configuration/DB, so there is no cross-cluster actor discovery.
**The ManagementActor was never reachable over the receptionist either** (its registration was removed 2026-07-22, ahead of Phase 4). That registration was written for an out-of-cluster CLI that was never built: the shipped CLI speaks HTTP Basic to the central `/management` endpoints, which ask the ManagementActor **in-process** via `ManagementActorHolder` (`ManagementEndpoints.cs`). The actor itself still runs at `/user/management`.
> **Migration note.** `Akka.Cluster.Tools` stays a Host dependency for ClusterSingleton; only its ClusterClient/ClusterClientReceptionist surface is now unused. See `docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`.
### REQ-HOST-7: ASP.NET Web Endpoints ### REQ-HOST-7: ASP.NET Web Endpoints
@@ -138,7 +143,7 @@ On central nodes, the Host must use `WebApplication.CreateBuilder` to produce a
- Central UI (via `MapCentralUI()` extension method). - Central UI (via `MapCentralUI()` extension method).
- Inbound API (via `MapInboundAPI()` extension method). - Inbound API (via `MapInboundAPI()` extension method).
On site nodes, the Host must also use `WebApplication.CreateBuilder` (not `Host.CreateDefaultBuilder`) to host the **SiteStreamGrpcServer** via Kestrel HTTP/2 on the configured `GrpcPort` (default 8083). Kestrel is configured with `HttpProtocols.Http2` on the gRPC port only — no HTTP/1.1 web endpoints are exposed. The gRPC service is mapped via `MapGrpcService<SiteStreamGrpcServer>()`. On site nodes, the Host must also use `WebApplication.CreateBuilder` (not `Host.CreateDefaultBuilder`) to host the **SiteStreamGrpcServer** and the **SiteCommandService** (central→site command/control, added in Phase 4) via Kestrel HTTP/2 on the configured `GrpcPort` (default 8083). Kestrel is configured with `HttpProtocols.Http2` on the gRPC port only — no HTTP/1.1 web endpoints are exposed. On central nodes, the Host additionally hosts the **CentralControlService** (site→central command/control) via Kestrel HTTP/2 on the configured `CentralGrpcPort` (default 8083), alongside the HTTP/1.1 Central UI / Inbound API endpoints. The gRPC services are mapped via `MapGrpcService<>()`.
**Startup ordering (site nodes)**: **Startup ordering (site nodes)**:
1. Actor system and SiteStreamManager must be initialized before gRPC begins accepting connections. 1. Actor system and SiteStreamManager must be initialized before gRPC begins accepting connections.
@@ -174,7 +179,7 @@ Each component library must expose its services to the Host via a consistent set
- `AkkaConfigurationBuilder.AddXxxActors()` — registers the component's actors with the Akka.NET actor system (for components that have actors). - `AkkaConfigurationBuilder.AddXxxActors()` — registers the component's actors with the Akka.NET actor system (for components that have actors).
- `WebApplication.MapXxx()` — maps the component's web endpoints (only for CentralUI and InboundAPI). - `WebApplication.MapXxx()` — maps the component's web endpoints (only for CentralUI and InboundAPI).
The Host's `Program.cs` calls these extension methods; the component libraries own the registration logic. This keeps the Host thin and each component self-contained. The ManagementService component additionally registers the ManagementActor with ClusterClientReceptionist in its `AddManagementServiceActors()` method. The Host's `Program.cs` calls these extension methods; the component libraries own the registration logic. This keeps the Host thin and each component self-contained. (The ManagementService component no longer registers the ManagementActor with ClusterClientReceptionist — that registration was removed 2026-07-22, and the whole receptionist surface went in Phase 4.)
--- ---
@@ -209,7 +214,7 @@ The Host's `Program.cs` calls these extension methods; the component libraries o
## Dependencies ## Dependencies
- **All 19 component libraries**: The Host references every component project to call their extension methods (excludes CLI, which is a separate executable). Audit Log (#23) ships its central+site code in `ZB.MOM.WW.ScadaBridge.AuditLog`; the Host calls `AddAuditLog()` on both roles, M2+ will add `AddAuditLogActors()`. - **All 19 component libraries**: The Host references every component project to call their extension methods (excludes CLI, which is a separate executable). Audit Log (#23) ships its central+site code in `ZB.MOM.WW.ScadaBridge.AuditLog`; the Host calls `AddAuditLog()` on both roles, M2+ will add `AddAuditLogActors()`.
- **Akka.Cluster.Tools**: ClusterSingleton (singleton manager/proxy) and ClusterClient/ClusterClientReceptionist; transitively pulls Akka.Cluster (SBR provider) and Akka.Remote. The actor system is built from hand-assembled HOCON (REQ-HOST-6), so the `Akka.Hosting`/`Akka.Remote.Hosting`/`Akka.Cluster.Hosting` typed-builder packages are **not** referenced (dropped in arch-review 01 Task 22). No Akka.Persistence plugin — see the Persistence note under REQ-HOST-6. - **Akka.Cluster.Tools**: ClusterSingleton (singleton manager/proxy); transitively pulls Akka.Cluster (SBR provider) and Akka.Remote. Its ClusterClient/ClusterClientReceptionist surface is no longer used — cross-cluster messaging is gRPC as of Phase 4. The actor system is built from hand-assembled HOCON (REQ-HOST-6), so the `Akka.Hosting`/`Akka.Remote.Hosting`/`Akka.Cluster.Hosting` typed-builder packages are **not** referenced (dropped in arch-review 01 Task 22). No Akka.Persistence plugin — see the Persistence note under REQ-HOST-6.
- **Serilog.AspNetCore**: For structured logging integration. - **Serilog.AspNetCore**: For structured logging integration.
- **Microsoft.Extensions.Hosting.WindowsServices**: For Windows Service support. - **Microsoft.Extensions.Hosting.WindowsServices**: For Windows Service support.
- **ASP.NET Core** (central only): For web endpoint hosting. - **ASP.NET Core** (central only): For web endpoint hosting.
@@ -220,5 +225,5 @@ The Host's `Program.cs` calls these extension methods; the component libraries o
- **Configuration Database**: The Host registers the DbContext and wires repository implementations to their interfaces. In development, triggers auto-migration; in production, validates schema version. - **Configuration Database**: The Host registers the DbContext and wires repository implementations to their interfaces. In development, triggers auto-migration; in production, validates schema version.
- **ClusterInfrastructure**: The Host configures the underlying Akka.NET cluster that ClusterInfrastructure manages at runtime. - **ClusterInfrastructure**: The Host configures the underlying Akka.NET cluster that ClusterInfrastructure manages at runtime.
- **CentralUI / InboundAPI**: The Host maps their web endpoints into the ASP.NET Core pipeline on central nodes. - **CentralUI / InboundAPI**: The Host maps their web endpoints into the ASP.NET Core pipeline on central nodes.
- **ManagementService**: The Host registers the ManagementActor and configures ClusterClientReceptionist on central nodes, enabling CLI access. - **ManagementService**: The Host registers the ManagementActor on central nodes; the CLI reaches it over the HTTP `/management` endpoints (in-process via `ManagementActorHolder`), not via any cluster transport. No ClusterClientReceptionist is configured (removed in Phase 4).
- **HealthMonitoring**: The Host's startup validation and logging configuration provide the foundation for health reporting. - **HealthMonitoring**: The Host's startup validation and logging configuration provide the foundation for health reporting.
@@ -134,7 +134,7 @@ Each buffered message stores:
## Dependencies ## Dependencies
- **SQLite**: Local persistence on each node. - **SQLite**: Local persistence on each node.
- **Communication Layer**: Application-level replication to standby node; remote query handling from central; carries buffered notifications to the central cluster (ClusterClient) and receives central's acks. - **Communication Layer**: Application-level replication to standby node; remote query handling from central; carries buffered notifications to the central cluster (gRPC to `CentralControlService`) and receives central's acks.
- **External System Gateway**: Delivers external system API calls. - **External System Gateway**: Delivers external system API calls.
- **CentralSite Communication**: The delivery target for the notification category — a buffered notification is forwarded to the central cluster over CentralSite Communication and cleared on central's ack. Also carries `CachedCallTelemetry` and reconciliation responses to central, and receives `RetryParkedOperation` / `DiscardParkedOperation` commands. - **CentralSite Communication**: The delivery target for the notification category — a buffered notification is forwarded to the central cluster over CentralSite Communication and cleared on central's ack. Also carries `CachedCallTelemetry` and reconciliation responses to central, and receives `RetryParkedOperation` / `DiscardParkedOperation` commands.
- **Site Call Audit**: The central audit mirror for cached calls — receives this engine's cached-call telemetry and reconciliation responses, and relays operator Retry/Discard of parked cached calls back as commands. - **Site Call Audit**: The central audit mirror for cached calls — receives this engine's cached-call telemetry and reconciliation responses, and relays operator Retry/Discard of parked cached calls back as commands.
+169
View File
@@ -0,0 +1,169 @@
# Environment Variables
A deep-scan inventory of every environment variable ScadaBridge reads — the Host, the CLI, the
DelmiaNotifier client, EF design-time tooling, and the test/CI harness — plus the supporting infra
containers. Compiled by scanning `Environment.GetEnvironmentVariable` call sites, the Host's
`AddEnvironmentVariables()` config source, `docker/` + `docker-env2/` compose files,
`deploy/wonder-app-vd03/install.ps1`, and every `appsettings*.json`.
**Scan date:** 2026-07-24 · **Branch:** `main`
Two columns qualify each variable:
- **Scope** — where it is read: **Runtime** (the running product — Host / CLI / DelmiaNotifier),
**Design-time** (`dotnet ef` tooling), **Test** (unit / integration / E2E / perf harness only, never
the shipped product), or **Infra** (a sidecar container, not ScadaBridge code).
- **In appsettings?** — whether a shipped `appsettings*.json` already carries the same config key
(so the env var is an *override* or *secret substitute*), versus being env-only. `${secret:...}`
means the key is present but its value is a secret token, not a literal.
## How configuration resolves (read this first)
The Host builds its configuration in `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` in this precedence
order (later overrides earlier):
1. `appsettings.json`
2. `appsettings.{SCADABRIDGE_CONFIG}.json` (role file — `Central` or `Site`)
3. **`.AddEnvironmentVariables()`** ← every variable below in the "config-override" table
4. `.AddCommandLine(args)`
Then a **pre-host secrets expander** resolves any `${secret:NAME}` tokens left in the merged config,
reading the encrypted secrets store whose master key comes from **`ZB_SECRETS_MASTER_KEY`**. Because
step 3 runs *before* expansion, a whole-key env override (e.g. `ScadaBridge__Database__ConfigurationDb`)
wins over — and short-circuits — the corresponding `${secret:...}` token. That is the deliberate
"rollback / dev" path the docker rigs use.
**Config-key → env-var mapping:** replace each `:` in a config path with `__` (double underscore).
So `ScadaBridge:Communication:GrpcPsk` is set via `ScadaBridge__Communication__GrpcPsk`. Any config
key in the app can be overridden this way; the table below lists only the ones with an established
purpose (secrets kept off disk, or dev-rig wiring).
---
## 1. Role & host environment selection
| Variable | Scope | In appsettings? | Consumed by | Purpose | Potential values | Required |
|---|---|---|---|---|---|---|
| `SCADABRIDGE_CONFIG` | Runtime | No (it *selects* the `appsettings.{value}.json` file) | Host (`Program.cs:42`) | Selects the role-specific overlay. **Primary role switch.** | `Central`, `Site` | Effectively yes (falls back to `DOTNET_ENVIRONMENT`, then `Production`) |
| `DOTNET_ENVIRONMENT` | Runtime | No | Host (`Program.cs:43`) | Fallback role selector when `SCADABRIDGE_CONFIG` is unset; generic .NET environment name. | `Central`, `Site`, `Development`, `Production` | No |
| `ASPNETCORE_ENVIRONMENT` | Runtime | No | ASP.NET Core + `DisableLoginGuard` (`Program.cs:438`) | Standard ASP.NET environment. `DisableLogin` is refused unless this is `Development`. | `Development`, `Production` | No (defaults to `Production` behavior) |
| `ASPNETCORE_URLS` | Runtime | No (Kestrel binding; not in these appsettings) | Kestrel | HTTP bind addresses. Docker sets `http://+:5000`; install.ps1 sets `http://+:{WebPort}` (8085). | e.g. `http://+:5000`, `http://+:8085` | No |
> `SCADABRIDGE_CONFIG` and `ASPNETCORE_ENVIRONMENT` are independent. The docker rig runs
> `SCADABRIDGE_CONFIG=Central` **and** `ASPNETCORE_ENVIRONMENT=Development` simultaneously.
## 2. Secrets store master key (KEK)
| Variable | Scope | In appsettings? | Consumed by | Purpose | Potential values | Required |
|---|---|---|---|---|---|---|
| `ZB_SECRETS_MASTER_KEY` | Runtime | Name only (`Secrets:MasterKey:EnvVarName` in `appsettings.json`; the value is never committed) | Host secrets expander | The KEK that decrypts the `ZB.MOM.WW.Secrets` store, from which every `${secret:...}` token resolves. A missing reference fails closed (`SecretNotFoundException`) before any SQL/LDAP/cluster wiring. | an opaque base64/hex master key | Yes on any node whose appsettings uses `${secret:...}` (shipped Central + Site do). Docker rigs avoid it with whole-key overrides. |
The variable *name* is itself configurable via `Secrets:MasterKey:EnvVarName` (defaults to
`ZB_SECRETS_MASTER_KEY`). See `docs/operations/2026-07-16-secrets-clustered-master-key.md`.
## 3. Config-override env vars (`ScadaBridge__*` whole-key)
Map onto config keys via the `:``__` rule. Keep secrets off disk (production, via a secret manager)
or wire the dev rigs without a KEK. The `__site-x` / `__site-a` leaf is the literal `siteId`.
All are **Runtime** scope (the Host reads them; Host integration-test fixtures set the same names, but
that is the same variable, not a separate one).
| Variable | In appsettings? | Config key | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `ScadaBridge__Database__ConfigurationDb` | Yes — `${secret:sql/scadabridge/configdb-connection}` in `appsettings.Central.json` | `ScadaBridge:Database:ConfigurationDb` | Central config DB connection string (MS SQL). Central-only. | `Server=host,1433;Database=ScadaBridgeConfig;User Id=...;Password=...;TrustServerCertificate=true` | Yes on Central — via env **or** the `${secret}` |
| `ScadaBridge__Database__MachineDataDb` | No in shipped src appsettings; present in the deploy overlay (`deploy/wonder-app-vd03/appsettings.Central.json`, as `${...}`). install.ps1 derives it as `<DbName>MachineData`. | `ScadaBridge:Database:MachineDataDb` | Central machine-data DB connection string. Central-only. | connection string | Yes on Central |
| `ScadaBridge__Security__JwtSigningKey` | Yes — `${secret:security/scadabridge/jwt-signing-key}` in `appsettings.Central.json` | `ScadaBridge:Security:JwtSigningKey` | HMAC-SHA256 signing key for the cookie-embedded session JWT. | ≥32-char secret | Yes on Central |
| `ScadaBridge__Security__Ldap__ServiceAccountPassword` | Yes — `${secret:ldap/scadabridge/service-account-password}` in `appsettings.Central.json` | `ScadaBridge:Security:Ldap:ServiceAccountPassword` | LDAP bind-account password. (Renamed from the old flat `...__LdapServiceAccountPassword`.) | the bind password | Yes on Central if LDAP bind is used |
| `ScadaBridge__InboundApi__ApiKeyPepper` | No — env/secret only (not in any shipped appsettings) | `ScadaBridge:InboundApi:ApiKeyPepper` | Per-environment pepper for the inbound API-key peppered-HMAC verifier. Hard Central startup requirement since 2026-06-03. | ≥16-char secret, unique per environment | Yes on Central |
| `ScadaBridge__Communication__GrpcPsk` | Yes — `${secret:SB-GRPC-PSK-site-1}` in `appsettings.Site.json` | `ScadaBridge:Communication:GrpcPsk` | **Site side** of the per-site gRPC control-plane PSK. StartupValidator refuses to boot a Site node without it; both pair nodes carry the same value. | per-site secret (e.g. `dev-grpc-psk-docker-site-a`); prod `${secret:SB-GRPC-PSK-<siteId>}` | Yes on Site |
| `ScadaBridge__Communication__SitePsks__<siteId>` | No in shipped src appsettings (central normally resolves the `SB-GRPC-PSK-<siteId>` secret); present only in the docker-compose env blocks | `ScadaBridge:Communication:SitePsks:<siteId>` | **Central side** of the same key: the value central presents when dialling that site. Must equal the site's `GrpcPsk`. Override for hosts without a master secret store. | matches the site's `GrpcPsk` | On Central, either this **or** the `SB-GRPC-PSK-<siteId>` store secret |
Docker dev values (dev-only-insecure, committed in `docker/docker-compose.yml`):
`ScadaBridge__Communication__SitePsks__site-a = dev-grpc-psk-docker-site-a` (also `site-b`, `site-c`);
`docker-env2` uses `SitePsks__site-x = dev-grpc-psk-docker-env2-site-x`.
> Any other config key is overridable the same way (e.g. `ScadaBridge__Cluster__SplitBrainResolverStrategy`,
> `ScadaBridge__Logging__MinimumLevel`) — these are the standard .NET config binder, not "special" env
> vars. Prefer `appsettings` files for non-secret values.
### `${secret:...}` tokens (resolved from the store, NOT env vars)
Not environment variables — config values resolved by the secrets expander using `ZB_SECRETS_MASTER_KEY`.
All are **Runtime** scope and, by definition, present **In appsettings** as tokens.
| Token (in `appsettings`) | Meaning |
|---|---|
| `${secret:sql/scadabridge/configdb-connection}` | Central config DB connection string |
| `${secret:ldap/scadabridge/service-account-password}` | LDAP bind password |
| `${secret:security/scadabridge/jwt-signing-key}` | JWT signing key |
| `${secret:SB-GRPC-PSK-<siteId>}` | Per-site gRPC PSK (site side); central resolves `SB-GRPC-PSK-{siteId}` at channel-build time |
## 4. CLI (`scadabridge` / `ZB.MOM.WW.ScadaBridge.CLI`)
Resolution order per value: command-line flag → env var → `~/.scadabridge/config.json`. The CLI does
**not** read `appsettings.json` — its config file is `~/.scadabridge/config.json` (a separate schema).
| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `SCADABRIDGE_MANAGEMENT_URL` | Runtime (CLI) | No — CLI config file has `managementUrl` instead | Management API base URL. Overridden by `--url`; overrides the config file. Missing all three → exit `1`, `NO_URL`. | e.g. `http://localhost:9000` | One of flag/env/config |
| `SCADABRIDGE_FORMAT` | Runtime (CLI) | No — CLI config file has `defaultFormat` instead | Default output format. Overridden by `--format`. | `json`, `table` | No (defaults to `json`) |
| `SCADABRIDGE_USERNAME` | Runtime (CLI) | No | LDAP username fallback when `--username` absent. | e.g. `multi-role` | No (some commands need auth) |
| `SCADABRIDGE_PASSWORD` | Runtime (CLI) | No | LDAP password fallback when `--password` absent. **Preferred over `--password`** — avoids leaking into process listings / shell history. | the user's password | No (some commands need auth) |
## 5. DelmiaNotifier client (`WWNotifier.exe`)
| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `SCADABRIDGE_API_KEY` | Runtime (client) | No — the notifier's `appsettings.json` holds only `BaseUrls`/`TimeoutSeconds`/`LogPath`; the key is **env-only by design** | Inbound API key sent as `X-API-Key`. Read only from the environment, never a file. Missing/empty → prints `API key not configured (SCADABRIDGE_API_KEY)`, exit `-1`, no HTTP attempt. | e.g. `sbk_...` | Yes |
> This is also why `System.Environment` is wholly forbidden in the script trust model — it would let a
> site/inbound script exfiltrate `SCADABRIDGE_API_KEY` and other host env secrets.
## 6. EF Core design-time tooling
| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING` | Design-time | Fallback for `ScadaBridge:Database:ConfigurationDb` — the factory reads that appsettings key first, then this env var | Connection string for `dotnet ef` migrations tooling when the Host's appsettings isn't the source. No hardcoded fallback — missing → actionable failure. | a config DB connection string | Only for `dotnet ef` when appsettings is unavailable |
## 7. Test / CI-only
Not read by the running product — the test harness only. Most default gracefully (skip, or use a
local docker value) when unset.
| Variable | Scope | In appsettings? | Consumed by | Purpose | Potential values |
|---|---|---|---|---|---|
| `SCADABRIDGE_MSSQL_TEST_CONN` | Test | No | `MsSqlMigrationFixture`, AuditLog migration tests | Admin (sa) connection to a running MS SQL; absent → local docker sa default; drives `Skip.If`. | sa connection string |
| `SCADABRIDGE_PLAYWRIGHT_DB` | Test | No | `PlaywrightDbConnection` | DB connection for Playwright E2E seeding; absent → `localhost:1433`. | connection string |
| `SCADABRIDGE_CLI_DLL` | Test | No | `CliRunner` | Absolute path to the built CLI dll for Playwright when auto-probing fails. | path to `...CLI.dll` |
| `SCADABRIDGE_AUDIT_FILTER_4KB_P95_US` | Test | No | `HotPathLatencyTests` | Override P95 latency threshold (µs) for the 4 KB audit-filter perf test. | positive number (default 5) |
| `SCADABRIDGE_AUDIT_FILTER_RAW_P95_US` | Test | No | `HotPathLatencyTests` | Override P95 threshold for the raw-capture perf test. | positive number |
| `SCADABRIDGE_AUDIT_FILTER_NOOP_P95_US` | Test | No | `HotPathLatencyTests` | Override P95 threshold for the no-op fast-path perf test. | positive number (default 5) |
| `HOME` / `USERPROFILE` | Test | No | `CliConfig` tests | Redirected to locate `~/.scadabridge/config.json` under test. | temp dir path |
The Host test fixtures (`CentralDbTestEnvironment`, `ScadaBridgeWebApplicationFactory`,
`StartupValidationTests`) set the **section-3 config-override vars** (`ScadaBridge__Database__*`,
`ScadaBridge__InboundApi__ApiKeyPepper`, etc.) and `DOTNET_ENVIRONMENT=Central` at runtime; those are
the same variables already documented above, not new ones.
## 8. Supporting infrastructure containers (not the app)
Set on the sidecar/infra containers in `infra/docker-compose.yml`, consumed by those images, not by
ScadaBridge code — included for completeness when standing up a rig.
| Variable | Scope | In appsettings? | Container | Purpose | Value used |
|---|---|---|---|---|---|
| `MSSQL_SA_PASSWORD` | Infra | No | `scadabridge-mssql` | SQL Server `sa` password. | `ScadaBridge_Dev1#` (dev-only) |
| `MSSQL_PID` | Infra | No | `scadabridge-mssql` | SQL Server edition. | `Developer` |
---
## Quick reference: minimum sets to boot a real deploy
**Central node** (via env, or the `${secret:...}` equivalents + `ZB_SECRETS_MASTER_KEY`):
`SCADABRIDGE_CONFIG=Central`, `ScadaBridge__Database__ConfigurationDb`,
`ScadaBridge__Database__MachineDataDb`, `ScadaBridge__Security__JwtSigningKey`,
`ScadaBridge__Security__Ldap__ServiceAccountPassword`, `ScadaBridge__InboundApi__ApiKeyPepper`, and
for each managed site either the `SB-GRPC-PSK-<siteId>` secret or `ScadaBridge__Communication__SitePsks__<siteId>`.
**Site node:** `SCADABRIDGE_CONFIG=Site` and `ScadaBridge__Communication__GrpcPsk` (or its
`${secret:SB-GRPC-PSK-<siteId>}` form). StartupValidator hard-fails without the PSK.
@@ -1,6 +1,7 @@
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@inject IBrowseService BrowseService @inject IBrowseService BrowseService
@@ -89,9 +90,17 @@
<div class="input-group"> <div class="input-group">
<span class="input-group-text">Manual node id:</span> <span class="input-group-text">Manual node id:</span>
<input class="form-control" @bind="_manualNodeId" placeholder="ns=2;s=..." /> <input class="form-control" @bind="_manualNodeId" @bind:event="oninput" placeholder="nsu=&lt;namespace-uri&gt;;s=..." />
<button class="btn btn-outline-secondary" @onclick="UseManual" disabled="@string.IsNullOrWhiteSpace(_manualNodeId)">Use</button> <button class="btn btn-outline-secondary" @onclick="UseManual" disabled="@string.IsNullOrWhiteSpace(_manualNodeId)">Use</button>
</div> </div>
@if (!OpcUaReferenceForm.IsDurable(_manualNodeId))
{
<small class="text-warning-emphasis d-block mt-1" data-test="ns-index-warning">
This is a bare namespace-<em>index</em> binding. Indexes can silently re-point after a
server namespace change — prefer selecting from the tree above (it stores the durable
<code>nsu=&lt;uri&gt;;…</code> form).
</small>
}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span> <span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
@@ -0,0 +1,155 @@
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
/// <summary>
/// Pure decision logic for the simultaneous-cold-start split-brain guard (Gitea #33).
/// </summary>
/// <remarks>
/// <para>
/// Both nodes of a 2-node pair are self-first seeds so <b>either</b> can cold-start alone when
/// its partner is dead (see <see cref="ClusterOptions.SeedNodes"/>). The cost is that when
/// BOTH cold-start at the same instant, each runs Akka's <c>FirstSeedNodeProcess</c>, times out
/// waiting for the other, and forms its OWN single-node cluster — a split brain (two oldest
/// nodes, two singletons, dual-active). Two independent clusters do not auto-merge, so the
/// split persists until an operator restarts one side. It bites on any event that powers up
/// both site VMs together (site power restoration, hypervisor host reboot).
/// </para>
/// <para>
/// This guard breaks the symmetry deterministically without giving up cold-start-alone. The
/// node with the lexicographically <b>lower</b> canonical address is the <i>preferred
/// founder</i>: it always uses self-first order and forms immediately. The <b>higher</b> node
/// first probes whether its partner's Akka endpoint is reachable (see
/// <see cref="ClusterBootstrapCoordinator"/>): reachable ⇒ peer-first order so it JOINS the
/// founder rather than racing it; unreachable after the probe window ⇒ the partner is genuinely
/// down, so it falls back to self-first and forms alone. Exactly one node founds when both are
/// present; the lower node always founds when it starts alone.
/// </para>
/// <para>
/// <b>Residual trade-off (accepted).</b> Once the higher node observes the partner reachable it
/// commits to peer-first — Akka's <c>JoinSeedNodeProcess</c> retries <c>InitJoin</c> forever and
/// never self-forms. If the founder dies in the small window between the probe succeeding and
/// the join completing, the higher node hangs unjoined until it is restarted (a restart re-runs
/// the guard, finds the founder unreachable, and self-forms). We deliberately do NOT re-decide
/// mid-handshake — that is exactly the retired <c>SelfFormAfter</c> failure mode. Instead
/// <see cref="ClusterBootstrapCoordinator"/> makes the hang operator-visible with a warning if
/// the node has not come Up within a bounded time after committing peer-first.
/// </para>
/// <para>
/// This decides the join order BEFORE issuing a single <c>JoinSeedNodes</c>, from an explicit
/// reachability signal — unlike a timer-based watchdog, which fires a <c>Join(self)</c>
/// mid-handshake on a bare timeout and could not tell "no seed answered" from "a join is in
/// flight", islanding a node a failover had just bounced (the rejected self-form-watchdog
/// design; see <c>SelfFirstSeedBootstrapTests</c>).
/// </para>
/// </remarks>
public static class ClusterBootstrapGuard
{
private static readonly Regex SeedPattern =
new(@"^akka(?:\.[a-z0-9]+)?://[^@/]+@(?<host>[^:/]+):(?<port>\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>The analyzed bootstrap role of this node within its seed list.</summary>
/// <param name="Applies">True only when the guard engages: exactly two seeds, one of them this node.</param>
/// <param name="IsFounder">True when this node is the preferred founder (lower address) — form immediately, no probe.</param>
/// <param name="PartnerHost">The partner's advertised host (to probe when this node is the higher one); null when not applicable.</param>
/// <param name="PartnerPort">The partner's Akka port.</param>
/// <param name="SelfFirstOrder"><c>[self, partner]</c> — self-first; forms a new cluster when no peer answers.</param>
/// <param name="PeerFirstOrder"><c>[partner, self]</c> — peer-first; joins the partner's cluster, never self-forms while it is up.</param>
public sealed record BootstrapRole(
bool Applies,
bool IsFounder,
string? PartnerHost,
int PartnerPort,
string[] SelfFirstOrder,
string[] PeerFirstOrder);
/// <summary>
/// Analyzes the configured seed list to decide this node's bootstrap role. The guard engages only
/// for the pair shape (exactly two seeds, one of them this node); any other shape
/// (single-seed / single-node install, three+ seeds, self absent) returns
/// <see cref="BootstrapRole.Applies"/> = false and the caller joins the configured seeds unchanged.
/// </summary>
/// <param name="seeds">The configured seed URIs (self-first by convention, but order is not relied on here).</param>
/// <param name="selfHost">This node's advertised host (<c>NodeOptions.NodeHostname</c>).</param>
/// <param name="selfPort">This node's Akka remoting port (<c>NodeOptions.RemotingPort</c>).</param>
/// <returns>The analyzed role; never null.</returns>
public static BootstrapRole Analyze(IReadOnlyList<string> seeds, string selfHost, int selfPort)
{
ArgumentNullException.ThrowIfNull(seeds);
ArgumentException.ThrowIfNullOrWhiteSpace(selfHost);
var na = new BootstrapRole(false, false, null, 0, Array.Empty<string>(), Array.Empty<string>());
if (seeds.Count != 2) return na;
var selfKey = Key(selfHost, selfPort);
string? self = null, partner = null;
string? partnerHost = null;
var partnerPort = 0;
foreach (var seed in seeds)
{
if (!TryParse(seed, out var host, out var port)) return na;
if (string.Equals(Key(host, port), selfKey, StringComparison.OrdinalIgnoreCase))
self = seed;
else
{
partner = seed;
partnerHost = host;
partnerPort = port;
}
}
// Self must be exactly one of the two, and the other must be a distinct partner.
if (self is null || partner is null || partnerHost is null) return na;
var selfFirst = new[] { self, partner };
var peerFirst = new[] { partner, self };
// Deterministic tie-break: the lower canonical address is the preferred founder. Both nodes
// compute the same comparison (same two seeds), so exactly one is the founder. Case-INSENSITIVE
// to match how self/partner are classified above (and StartupValidator.SeedNodeIsSelf):
// container/DNS hostnames are conventionally case-insensitive, and a casing difference between
// the two sides' seed config must NOT make both think they are the founder (which would reopen
// the very split this guard closes).
var isFounder = string.Compare(
Key(selfHost, selfPort),
Key(partnerHost, partnerPort),
StringComparison.OrdinalIgnoreCase) < 0;
return new BootstrapRole(true, isFounder, partnerHost, partnerPort, selfFirst, peerFirst);
}
/// <summary>
/// The order the higher (non-founder) node uses once it has learned whether its partner is
/// reachable: peer-first when the founder is up (join it), self-first when the founder is absent
/// (form alone — cold-start-alone preserved).
/// </summary>
/// <param name="role">The analyzed role (must be applicable and NOT the founder).</param>
/// <param name="partnerReachable">Whether the partner's Akka endpoint became reachable within the probe window.</param>
/// <returns>The seed order to pass to <c>Cluster.JoinSeedNodes</c>.</returns>
public static string[] HigherNodeOrder(BootstrapRole role, bool partnerReachable)
{
ArgumentNullException.ThrowIfNull(role);
return partnerReachable ? role.PeerFirstOrder : role.SelfFirstOrder;
}
/// <summary>Parses <c>akka.tcp://system@host:port[/...]</c> into host + port.</summary>
/// <param name="seed">The seed URI.</param>
/// <param name="host">The parsed advertised host.</param>
/// <param name="port">The parsed Akka port.</param>
/// <returns>True when the seed matched the expected shape.</returns>
public static bool TryParse(string? seed, out string host, out int port)
{
host = string.Empty;
port = 0;
if (string.IsNullOrWhiteSpace(seed)) return false;
var m = SeedPattern.Match(seed.Trim());
if (!m.Success) return false;
if (!int.TryParse(m.Groups["port"].Value, out port)) return false;
host = m.Groups["host"].Value;
return true;
}
private static string Key(string host, int port) => $"{host}:{port}";
}
@@ -114,4 +114,50 @@ public class ClusterOptions
/// dials forever — log noise and a defeated validation intent (review 01). /// dials forever — log noise and a defeated validation intent (review 01).
/// </summary> /// </summary>
public bool AllowSingleNodeCluster { get; set; } public bool AllowSingleNodeCluster { get; set; }
/// <summary>
/// The simultaneous-cold-start split-brain guard (<c>ScadaBridge:Cluster:BootstrapGuard</c>).
/// Default OFF — a dark switch, so existing deployments and tests keep Akka's config-driven
/// self-first auto-join byte-identical. See <see cref="ClusterBootstrapGuard"/> for the
/// decision logic and <c>ClusterBootstrapCoordinator</c> for the runtime (Gitea #33).
/// </summary>
public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new();
}
/// <summary>
/// Configuration for the simultaneous-cold-start split-brain guard. Both nodes of a pair are
/// self-first seeds so <b>either</b> can cold-start alone when its partner is dead — but the cost
/// is that when BOTH cold-start at the same instant each runs Akka's <c>FirstSeedNodeProcess</c>,
/// times out waiting for the other, and forms its own single-node cluster (a split brain that does
/// not auto-merge). When <see cref="Enabled"/>, the node does NOT auto-join from its config seeds
/// (<c>AkkaHostedService.BuildHocon</c> emits an empty seed list); a coordinator picks the join
/// order — founder self-first / joiner peer-first — after a reachability probe. See
/// <see cref="ClusterBootstrapGuard"/>.
/// </summary>
public sealed class ClusterBootstrapGuardOptions
{
/// <summary>
/// Whether the guard is active. Default <see langword="false"/> — Akka auto-joins from the
/// config seed list exactly as before. Only meaningful on a node that is one of its own two
/// pair seeds; inert everywhere else (single-seed site node, self absent, 3+ seeds).
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// How long the higher-address node probes its partner's Akka endpoint before concluding the
/// partner is dead and forming alone. Must comfortably exceed the partner's worst-case
/// process-start-to-Akka-bind time so a slow-but-alive partner is never mistaken for a dead one
/// (which would re-open the split). Default 25s. Validated <c>&gt; 0</c> at boot when the guard is on.
/// </summary>
public int PartnerProbeSeconds { get; set; } = 25;
/// <summary>
/// The interval between partner reachability probes, in milliseconds. Default 500ms.
/// </summary>
public int PartnerProbeIntervalMs { get; set; } = 500;
/// <summary>
/// The per-probe TCP connect timeout, in milliseconds. Default 1000ms.
/// </summary>
public int ProbeConnectTimeoutMs { get; set; } = 1000;
} }
@@ -30,6 +30,8 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
/// <inheritdoc /> /// <inheritdoc />
protected override void Validate(ValidationBuilder builder, ClusterOptions options) protected override void Validate(ValidationBuilder builder, ClusterOptions options)
{ {
ValidateBootstrapGuard(builder, options.BootstrapGuard);
// The design doc states "both nodes are seed nodes — each node lists // The design doc states "both nodes are seed nodes — each node lists
// both itself and its partner" so a properly-configured deployment lists // both itself and its partner" so a properly-configured deployment lists
// two. Accepting a single-seed configuration silently defeats the // two. Accepting a single-seed configuration silently defeats the
@@ -78,4 +80,33 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
+ "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 "
+ "younger node forms its own, producing two live clusters."); + "younger node forms its own, producing two live clusters.");
} }
/// <summary>
/// When the bootstrap guard is enabled (Gitea #33), its timing knobs must be positive. A
/// zero/negative <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/> in particular
/// silently degrades the guard to "never wait, always conclude the partner is dead" — the
/// higher node would form alone immediately and re-open the very split the guard exists to
/// close. Fail fast at boot rather than producing that silent degradation. Nothing is checked
/// when the guard is off (the knobs are inert), so a disabled guard never blocks a boot.
/// </summary>
private static void ValidateBootstrapGuard(ValidationBuilder builder, ClusterBootstrapGuardOptions? guard)
{
if (guard is null || !guard.Enabled)
{
return;
}
builder.RequireThat(guard.PartnerProbeSeconds > 0,
$"ClusterOptions.BootstrapGuard.PartnerProbeSeconds must be > 0 when the guard is enabled "
+ $"(was {guard.PartnerProbeSeconds}); a non-positive value makes the higher node conclude its "
+ "partner is dead without waiting and form alone, re-opening the split the guard prevents.");
builder.RequireThat(guard.PartnerProbeIntervalMs > 0,
$"ClusterOptions.BootstrapGuard.PartnerProbeIntervalMs must be > 0 when the guard is enabled "
+ $"(was {guard.PartnerProbeIntervalMs}).");
builder.RequireThat(guard.ProbeConnectTimeoutMs > 0,
$"ClusterOptions.BootstrapGuard.ProbeConnectTimeoutMs must be > 0 when the guard is enabled "
+ $"(was {guard.ProbeConnectTimeoutMs}).");
}
} }
@@ -33,7 +33,7 @@ public record BrowseChildrenResult(
bool Truncated, bool Truncated,
string? ContinuationToken = null); string? ContinuationToken = null);
/// <param name="NodeId">Server-issued node identifier (e.g. <c>"ns=2;s=Devices.Pump1.Speed"</c>).</param> /// <param name="NodeId">Server-issued node identifier (e.g. <c>"nsu=https://server/ns;s=Devices.Pump1.Speed"</c>).</param>
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param> /// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
/// <param name="NodeClass">Classifies the node for UI purposes (Variable rows are selectable; Object rows are navigable).</param> /// <param name="NodeClass">Classifies the node for UI purposes (Variable rows are selectable; Object rows are navigable).</param>
/// <param name="HasChildren">Hint so the UI can render an expand chevron without a second roundtrip.</param> /// <param name="HasChildren">Hint so the UI can render an expand chevron without a second roundtrip.</param>
@@ -1,14 +0,0 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
/// <summary>
/// Request routed from central to site to invoke an integration method
/// (external system call or notification) on behalf of the central UI or API.
/// </summary>
public record IntegrationCallRequest(
string CorrelationId,
string SiteId,
string InstanceUniqueName,
string TargetSystemName,
string MethodName,
IReadOnlyDictionary<string, object?> Parameters,
DateTimeOffset Timestamp);
@@ -1,12 +0,0 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
/// <summary>
/// Response for an integration call routed through central-site communication.
/// </summary>
public record IntegrationCallResponse(
string CorrelationId,
string SiteId,
bool Success,
string? ResultJson,
string? ErrorMessage,
DateTimeOffset Timestamp);
@@ -0,0 +1,55 @@
using System.Globalization;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
/// <summary>
/// Pure, protocol-package-free classification of an OPC UA node-reference string's
/// <i>form</i> — used by authoring UI to nudge operators toward index-durable bindings.
/// </summary>
/// <remarks>
/// A bare <c>ns=&lt;index&gt;;…</c> reference hard-codes a namespace <i>index</i>, which is
/// only meaningful relative to one server's namespace table. The OtOpcUa v3.0 raw/uns
/// split makes this actively dangerous: v2's sole custom namespace and v3's <c>raw</c>
/// tree both land at <c>ns=2</c>, so a stored <c>ns=2;s=…</c> resolves without error but
/// silently now means a raw-tree node. The durable form is <c>nsu=&lt;uri&gt;;…</c>,
/// resolved against the live namespace table at use time by
/// <c>OpcUaNodeReference.Resolve</c>. This helper only classifies the string; it does not
/// parse or resolve NodeIds (that stays in the DataConnectionLayer seam, which owns the
/// <c>Opc.Ua</c> dependency).
/// </remarks>
public static class OpcUaReferenceForm
{
/// <summary>
/// True when <paramref name="reference"/> is index-durable — the durable
/// <c>nsu=&lt;uri&gt;;…</c> form, spec-fixed <c>ns=0</c>, a short form with no namespace
/// prefix, or empty/whitespace (nothing to warn about). False only for a bare
/// server-namespace index (<c>ns=&lt;n&gt;;…</c> with n ≥ 1).
/// </summary>
public static bool IsDurable(string? reference)
{
if (string.IsNullOrWhiteSpace(reference))
return true;
var trimmed = reference.Trim();
// nsu=<uri>;… is the durable form (and its "ns" prefix must be checked before the
// bare "ns=" branch below, since "nsu=" also starts with "ns").
if (trimmed.StartsWith("nsu=", StringComparison.OrdinalIgnoreCase))
return true;
// Not a bare namespace-index reference at all → short forms (i=85, s=Foo) that
// imply namespace 0, which is spec-fixed and already durable.
if (!trimmed.StartsWith("ns=", StringComparison.OrdinalIgnoreCase))
return true;
// ns=<digits>;… — parse the index; ns=0 is spec-fixed (durable), n ≥ 1 is bare.
var semicolon = trimmed.IndexOf(';');
var digits = semicolon >= 0 ? trimmed[3..semicolon] : trimmed[3..];
if (int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var index))
return index == 0;
// Malformed "ns=" with non-numeric index — not a recognisable bare index; leave
// the real parse/reject to OpcUaNodeReference.Resolve. Don't warn on it here.
return true;
}
}
@@ -1,185 +0,0 @@
using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.Event;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <summary>
/// The <see cref="ICentralTransport"/> that carries the seven site→central sends over Akka
/// <c>ClusterClient</c> — the transport in production today, and the default. Every method is a
/// verbatim lift of the corresponding <c>SiteCommunicationActor</c> send block: it forwards a
/// <see cref="ClusterClient.Send"/> to <c>/user/central-communication</c> with the
/// <paramref name="replyTo"/> as the send's sender, so central's reply routes straight back to the
/// waiting Ask rather than through the site communication actor.
/// </summary>
/// <remarks>
/// The <c>ClusterClient</c> reference arrives after construction via
/// <see cref="SetCentralClient"/> (the actor forwards the <c>RegisterCentralClient</c> message it
/// receives once the Host builds the client). Until then — and if central contact points are not
/// configured at all — the client is null and each method answers the same transient-failure reply
/// the old inline handlers did.
/// </remarks>
public sealed class AkkaCentralTransport : ICentralTransport
{
/// <summary>The receptionist-registered path of the central communication actor.</summary>
private const string CentralPath = "/user/central-communication";
private readonly ILoggingAdapter? _log;
private IActorRef? _centralClient;
/// <summary>Creates the transport with no logging adapter (behaviourally identical; warnings are dropped).</summary>
public AkkaCentralTransport()
{
}
/// <summary>Creates the transport bound to the site communication actor's logging adapter.</summary>
/// <param name="log">Logging adapter used for the "no ClusterClient registered" warnings.</param>
public AkkaCentralTransport(ILoggingAdapter log)
{
_log = log;
}
/// <summary>
/// Registers the central <c>ClusterClient</c> once the Host has built it. Called from the site
/// communication actor's <c>RegisterCentralClient</c> handler.
/// </summary>
/// <param name="centralClient">The ClusterClient reaching the central cluster.</param>
public void SetCentralClient(IActorRef centralClient)
{
_centralClient = centralClient;
_log?.Info("Registered central ClusterClient");
}
/// <inheritdoc />
public void SubmitNotification(NotificationSubmit message, IActorRef replyTo)
{
if (_centralClient == null)
{
// No ClusterClient registered yet (e.g. central contact points not
// configured, or registration not yet completed). A non-accepted ack
// makes the S&F forwarder treat this as transient and retry later.
_log?.Warning(
"Cannot forward NotificationSubmit {0} — no central ClusterClient registered",
message.NotificationId);
replyTo.Tell(new NotificationSubmitAck(
message.NotificationId, Accepted: false, Error: "Central ClusterClient not registered"));
return;
}
_log?.Debug("Forwarding NotificationSubmit {0} to central", message.NotificationId);
_centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo);
}
/// <inheritdoc />
public void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo)
{
if (_centralClient == null)
{
// No ClusterClient registered yet. Reply Found: false so Notify.Status
// falls back to the site S&F buffer to decide Forwarding vs Unknown.
_log?.Warning(
"Cannot forward NotificationStatusQuery {0} — no central ClusterClient registered",
message.NotificationId);
replyTo.Tell(new NotificationStatusResponse(
message.CorrelationId, Found: false, Status: "Unknown",
RetryCount: 0, LastError: null, DeliveredAt: null));
return;
}
_log?.Debug("Forwarding NotificationStatusQuery {0} to central", message.NotificationId);
_centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo);
}
/// <inheritdoc />
public void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo)
{
if (_centralClient == null)
{
// No ClusterClient registered yet. Faulting the Ask makes the
// SiteAuditTelemetryActor drain loop treat this as transient and keep
// the rows Pending for the next tick.
_log?.Warning(
"Cannot forward IngestAuditEventsCommand ({0} events) — no central ClusterClient registered",
message.Events.Count);
replyTo.Tell(new Status.Failure(
new InvalidOperationException("Central ClusterClient not registered")));
return;
}
_log?.Debug("Forwarding IngestAuditEventsCommand ({0} events) to central", message.Events.Count);
_centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo);
}
/// <inheritdoc />
public void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo)
{
if (_centralClient == null)
{
_log?.Warning(
"Cannot forward IngestCachedTelemetryCommand ({0} entries) — no central ClusterClient registered",
message.Entries.Count);
replyTo.Tell(new Status.Failure(
new InvalidOperationException("Central ClusterClient not registered")));
return;
}
_log?.Debug("Forwarding IngestCachedTelemetryCommand ({0} entries) to central", message.Entries.Count);
_centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo);
}
/// <inheritdoc />
public void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo)
{
if (_centralClient == null)
{
// No ClusterClient registered yet. Faulting the Ask makes the
// SiteReconciliationActor treat the pass as best-effort-failed; it
// logs a warning and retries reconcile on the next node startup.
_log?.Warning(
"Cannot forward ReconcileSiteRequest for site {0} node {1} — no central ClusterClient registered",
message.SiteIdentifier, message.NodeId);
replyTo.Tell(new Status.Failure(
new InvalidOperationException("Central ClusterClient not registered")));
return;
}
_log?.Debug(
"Forwarding ReconcileSiteRequest for site {0} node {1} ({2} local instance(s)) to central",
message.SiteIdentifier, message.NodeId, message.LocalNameToRevisionHash.Count);
_centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo);
}
/// <inheritdoc />
public void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo)
{
if (_centralClient == null)
{
// No ClusterClient registered yet. A non-accepted ack makes the
// sender's counter-restore path treat this tick as a loss.
_log?.Warning(
"Cannot forward SiteHealthReport #{0} — no central ClusterClient registered",
message.SequenceNumber);
replyTo.Tell(new SiteHealthReportAck(
message.SiteId, message.SequenceNumber, Accepted: false,
Error: "Central ClusterClient not registered"));
return;
}
_centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo);
}
/// <inheritdoc />
public void SendHeartbeat(HeartbeatMessage message, IActorRef self)
{
if (_centralClient == null)
{
return;
}
_centralClient.Tell(new ClusterClient.Send(CentralPath, message), self);
}
}
@@ -1,141 +0,0 @@
using System.Collections.Immutable;
using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.Event;
namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <summary>
/// The default (and, until the Phase 1B cutover, the shipping) central→site transport: routes each
/// <see cref="SiteEnvelope"/> through a per-site Akka <see cref="ClusterClient"/>, exactly as
/// <c>CentralCommunicationActor</c> did inline before the seam was extracted.
/// </summary>
/// <remarks>
/// <para>
/// Behaviour is identical to the pre-seam code: the per-site client map is (re)built from the DB
/// refresh cache; a send to a site with no client is warned and dropped (the caller's Ask times
/// out — central never buffers); a send preserves the reply-to sender so the site's reply routes
/// straight back to the waiting Ask (or the debug-bridge actor).
/// </para>
/// <para>
/// Both members run on the owning actor's thread, so the client map needs no synchronisation and
/// the stored <see cref="IActorContext"/> (used for <c>Context.Stop</c> and <c>Context.System</c>)
/// is only ever touched there.
/// </para>
/// </remarks>
public sealed class AkkaSiteTransport : ISiteCommandTransport
{
private readonly ISiteClientFactory _siteClientFactory;
private readonly IActorContext _context;
private readonly ILoggingAdapter _log;
/// <summary>
/// Per-site ClusterClient instances and their contact addresses.
/// Maps SiteIdentifier → (ClusterClient actor, set of contact address strings).
/// Refreshed by <see cref="ReconcileSites"/>.
/// </summary>
private readonly Dictionary<string, (IActorRef Client, ImmutableHashSet<string> ContactAddresses)> _siteClients = new();
/// <summary>Creates the Akka transport bound to the owning actor's context.</summary>
/// <param name="siteClientFactory">Factory that creates a ClusterClient per site.</param>
/// <param name="context">The owning actor's context (for <c>Stop</c>/<c>System</c>); calls stay on the actor thread.</param>
/// <param name="log">The owning actor's logger, so warnings keep the actor's log source.</param>
public AkkaSiteTransport(ISiteClientFactory siteClientFactory, IActorContext context, ILoggingAdapter log)
{
_siteClientFactory = siteClientFactory ?? throw new ArgumentNullException(nameof(siteClientFactory));
_context = context ?? throw new ArgumentNullException(nameof(context));
_log = log ?? throw new ArgumentNullException(nameof(log));
}
/// <inheritdoc />
public void Send(SiteEnvelope envelope, IActorRef replyTo)
{
ArgumentNullException.ThrowIfNull(envelope);
if (!_siteClients.TryGetValue(envelope.SiteId, out var entry))
{
_log.Warning("No ClusterClient for site {0}, cannot route message {1}",
envelope.SiteId, envelope.Message.GetType().Name);
// The Ask will timeout on the caller side — no central buffering
return;
}
// Route via ClusterClient — replyTo is preserved for Ask response routing
entry.Client.Tell(
new ClusterClient.Send("/user/site-communication", envelope.Message),
replyTo);
}
/// <inheritdoc />
public void ReconcileSites(SiteAddressCacheLoaded cache)
{
ArgumentNullException.ThrowIfNull(cache);
var newSiteIds = cache.SiteContacts.Keys.ToHashSet();
var existingSiteIds = _siteClients.Keys.ToHashSet();
// Stop ClusterClients for removed sites
foreach (var removed in existingSiteIds.Except(newSiteIds))
{
_log.Info("Stopping ClusterClient for removed site {0}", removed);
_context.Stop(_siteClients[removed].Client);
_siteClients.Remove(removed);
}
// Add or update
foreach (var (siteId, addresses) in cache.SiteContacts)
{
// Parse all addresses up front inside a try/catch so a
// single malformed site row cannot abort the whole refresh loop and leave
// the cache half-updated. A bad site is logged and skipped; others proceed.
ImmutableHashSet<ActorPath> contactPaths;
try
{
contactPaths = addresses
.Select(a => ActorPath.Parse($"{a}/system/receptionist"))
.ToImmutableHashSet();
}
catch (Exception ex)
{
_log.Warning(ex,
"Malformed contact address for site {0}; skipping this site in the refresh "
+ "(other sites are unaffected)", siteId);
continue;
}
var contactStrings = addresses.ToImmutableHashSet();
// Skip if unchanged
if (_siteClients.TryGetValue(siteId, out var existing) && existing.ContactAddresses.SetEquals(contactStrings))
continue;
// Stop old client if addresses changed
if (_siteClients.ContainsKey(siteId))
{
_log.Info("Updating ClusterClient for site {0} (addresses changed)", siteId);
_context.Stop(_siteClients[siteId].Client);
// Remove now: if the replacement create below fails, a stale entry
// would route envelopes to a stopping actor.
_siteClients.Remove(siteId);
}
IActorRef client;
try
{
client = _siteClientFactory.Create(_context.System, siteId, contactPaths);
}
catch (Exception ex)
{
_log.Error(ex,
"Failed to create ClusterClient for site {0}; site is unroutable until the next refresh",
siteId);
continue;
}
_siteClients[siteId] = (client, contactStrings);
_log.Info("Created ClusterClient for site {0} with {1} contact(s)", siteId, addresses.Count);
}
_log.Info("Site ClusterClient cache refreshed with {0} site(s)", _siteClients.Count);
}
}
@@ -1,11 +1,8 @@
using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.Cluster.Tools.PublishSubscribe; using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event; using Akka.Event;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
@@ -17,61 +14,10 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <summary> /// <summary>
/// Abstraction for creating ClusterClient instances per site, enabling testability. /// Central-side actor that routes messages from central to site clusters over the gRPC
/// </summary> /// <c>SiteCommandService</c> command plane (<see cref="Grpc.GrpcSiteTransport"/>). Resolves site
public interface ISiteClientFactory /// addresses from the database on a periodic refresh cycle and reconciles the transport's per-site
{ /// channel pairs.
/// <summary>Creates a ClusterClient actor for the given site with the specified contact points.</summary>
/// <param name="system">The actor system in which to create the client.</param>
/// <param name="siteId">The site identifier, used to name the actor.</param>
/// <param name="contacts">The set of receptionist actor paths to use as initial contacts.</param>
/// <returns>An actor reference for the new ClusterClient.</returns>
IActorRef Create(ActorSystem system, string siteId, ImmutableHashSet<ActorPath> contacts);
}
/// <summary>
/// Default implementation that creates a real ClusterClient for each site.
/// </summary>
public class DefaultSiteClientFactory : ISiteClientFactory
{
/// <summary>
/// Per-incarnation generation counter. Context.Stop of the previous client is
/// asynchronous — its actor name stays reserved until termination completes —
/// so a same-named recreate in the same message handling throws
/// InvalidActorNameException. A generation suffix makes every incarnation's
/// name unique by construction (mirrors SiteStreamGrpcServer._actorCounter).
/// </summary>
private long _generation;
/// <inheritdoc />
public IActorRef Create(ActorSystem system, string siteId, ImmutableHashSet<ActorPath> contacts)
{
var settings = ClusterClientSettings.Create(system).WithInitialContacts(contacts);
var name = $"site-client-{SanitizeForActorName(siteId)}-{System.Threading.Interlocked.Increment(ref _generation)}";
return system.ActorOf(ClusterClient.Props(settings), name);
}
/// <summary>
/// Maps an arbitrary SiteIdentifier onto a valid Akka actor-path element:
/// letters/digits/'-'/'_' pass through, everything else becomes '_'. Uniqueness
/// is NOT required here (the generation suffix guarantees it); only validity is.
/// </summary>
/// <param name="siteId">The SiteIdentifier to sanitize.</param>
/// <returns>A valid Akka actor-path element derived from <paramref name="siteId"/>.</returns>
internal static string SanitizeForActorName(string siteId)
{
if (string.IsNullOrEmpty(siteId)) return "site";
var sanitized = new string(siteId
.Select(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' ? c : '_')
.ToArray());
return ActorPath.IsValidPathElement(sanitized) ? sanitized : "site";
}
}
/// <summary>
/// Central-side actor that routes messages from central to site clusters via ClusterClient.
/// Resolves site addresses from the database on a periodic refresh cycle and manages
/// per-site ClusterClient instances.
/// ///
/// All 8 message patterns routed through this actor. /// All 8 message patterns routed through this actor.
/// Ask timeout on connection drop (no central buffering). Debug streams killed on interruption. /// Ask timeout on connection drop (no central buffering). Debug streams killed on interruption.
@@ -82,11 +28,10 @@ public class CentralCommunicationActor : ReceiveActor
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
/// <summary> /// <summary>
/// The active central→site command transport, chosen by /// The central→site command transport (the gRPC <see cref="Grpc.GrpcSiteTransport"/>, built by
/// <c>ScadaBridge:Communication:SiteTransport</c> (default <see cref="SiteTransportKind.Akka"/>). /// the Host and injected). The <see cref="SiteEnvelope"/> handler delegates every send here, and
/// The <see cref="SiteEnvelope"/> handler delegates every send here, and each DB refresh tick /// each DB refresh tick reconciles its per-site channel pairs. Assigned in the constructor body
/// reconciles its per-site resources (ClusterClients for Akka, channel pairs for gRPC). Assigned /// before any message can arrive.
/// in the public constructor body before any message can arrive.
/// </summary> /// </summary>
private ISiteCommandTransport _transport = null!; private ISiteCommandTransport _transport = null!;
@@ -160,32 +105,9 @@ public class CentralCommunicationActor : ReceiveActor
private const string HealthReportTopic = "site-health-replica"; private const string HealthReportTopic = "site-health-replica";
/// <summary> /// <summary>
/// Legacy constructor: builds the transport by reading /// Constructs the actor over the given central→site command transport (production injects the
/// <c>ScadaBridge:Communication:SiteTransport</c> and wrapping <paramref name="siteClientFactory"/> /// gRPC <see cref="Grpc.GrpcSiteTransport"/>, built by the Host from the DI
/// in an <see cref="AkkaSiteTransport"/> (default) or resolving the gRPC transport from /// <see cref="Grpc.SitePairChannelProvider"/>; tests substitute a fake).
/// <paramref name="serviceProvider"/>. Kept so the Host and the existing TestKit suites construct
/// the actor exactly as before (the factory is still the Akka seam).
/// </summary>
/// <param name="serviceProvider">DI service provider for scoped repository and aggregator access.</param>
/// <param name="siteClientFactory">Factory used to create per-site ClusterClient actors (Akka transport).</param>
/// <param name="auditIngestAskTimeout">
/// Optional override for the audit-ingest Ask timeout; defaults to
/// <see cref="Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout"/> (30 s). Exists only so tests can
/// exercise the timeout/fault path quickly — production always uses the default.
/// </param>
public CentralCommunicationActor(
IServiceProvider serviceProvider,
ISiteClientFactory siteClientFactory,
TimeSpan? auditIngestAskTimeout = null)
: this(serviceProvider, auditIngestAskTimeout)
{
ArgumentNullException.ThrowIfNull(siteClientFactory);
_transport = SelectTransport(serviceProvider, siteClientFactory);
}
/// <summary>
/// Primary constructor: takes the already-selected <see cref="ISiteCommandTransport"/> directly.
/// Used by tests that substitute the transport, and reachable via the legacy constructor.
/// </summary> /// </summary>
/// <param name="serviceProvider">DI service provider for scoped repository and aggregator access.</param> /// <param name="serviceProvider">DI service provider for scoped repository and aggregator access.</param>
/// <param name="transport">The central→site command transport to route every <see cref="SiteEnvelope"/> through.</param> /// <param name="transport">The central→site command transport to route every <see cref="SiteEnvelope"/> through.</param>
@@ -223,7 +145,7 @@ public class CentralCommunicationActor : ReceiveActor
// distinguish "no sites configured" from "database is down". Log at Warning. // distinguish "no sites configured" from "database is down". Log at Warning.
Receive<Status.Failure>(failure => Receive<Status.Failure>(failure =>
_log.Warning(failure.Cause, _log.Warning(failure.Cause,
"Failed to load site addresses from the database; the site ClusterClient " "Failed to load site addresses from the database; the per-site gRPC channel "
+ "cache was not refreshed and may be stale or empty")); + "cache was not refreshed and may be stale or empty"));
// Health monitoring: heartbeats and health reports from sites // Health monitoring: heartbeats and health reports from sites
@@ -489,43 +411,12 @@ public class CentralCommunicationActor : ReceiveActor
private void HandleSiteEnvelope(SiteEnvelope envelope) private void HandleSiteEnvelope(SiteEnvelope envelope)
{ {
// Below-the-seam routing: the active transport (Akka ClusterClient or gRPC) owns the // Below-the-seam routing: the gRPC transport owns the "no route for this site ⇒ warn +
// "no route for this site ⇒ warn + drop, caller's Ask times out" contract. Sender is the // drop, caller's Ask times out" contract. Sender is the temporary Ask actor (or the
// temporary Ask actor (or the debug-bridge actor) and is preserved for reply routing. // debug-bridge actor) and is preserved for reply routing.
_transport.Send(envelope, Sender); _transport.Send(envelope, Sender);
} }
/// <summary>
/// Chooses the transport from <c>ScadaBridge:Communication:SiteTransport</c> (default
/// <see cref="SiteTransportKind.Akka"/>). For Akka, wraps the injected
/// <see cref="ISiteClientFactory"/> in an <see cref="AkkaSiteTransport"/> bound to this actor's
/// context; for gRPC, resolves the shared <see cref="Grpc.SitePairChannelProvider"/> and options.
/// Runs in the constructor body, where <see cref="ActorBase.Context"/> is available.
/// </summary>
/// <param name="serviceProvider">The DI provider carrying options and (for gRPC) the channel provider.</param>
/// <param name="siteClientFactory">The ClusterClient factory used by the Akka transport.</param>
/// <returns>The selected transport.</returns>
private ISiteCommandTransport SelectTransport(
IServiceProvider serviceProvider, ISiteClientFactory siteClientFactory)
{
var options = serviceProvider.GetService<IOptions<CommunicationOptions>>()?.Value;
var kind = options?.SiteTransport ?? SiteTransportKind.Akka;
if (kind == SiteTransportKind.Grpc)
{
var channelProvider = serviceProvider.GetRequiredService<Grpc.SitePairChannelProvider>();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
_log.Info("central→site command transport: gRPC (SiteCommandService)");
return new Grpc.GrpcSiteTransport(
channelProvider,
options!,
loggerFactory.CreateLogger<Grpc.GrpcSiteTransport>());
}
_log.Info("central→site command transport: Akka ClusterClient");
return new AkkaSiteTransport(siteClientFactory, Context, _log);
}
private void LoadSiteAddressesFromDb() private void LoadSiteAddressesFromDb()
{ {
var self = Self; var self = Self;
@@ -13,21 +13,20 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The actor's receive handlers no longer own the wire plumbing — they capture the current /// The actor's receive handlers no longer own the wire plumbing — they capture the current
/// <c>Sender</c> and hand it to the transport as <paramref name="replyTo"/>. Two implementations /// <c>Sender</c> and hand it to the transport as <paramref name="replyTo"/>. The production
/// exist behind the <c>ScadaBridge:Communication:CentralTransport</c> flag: the default /// implementation is <see cref="Grpc.GrpcCentralTransport"/> (a gRPC dial of
/// <see cref="AkkaCentralTransport"/> (verbatim of the old <c>ClusterClient.Send</c> path, /// <c>CentralControlService</c> with sticky central-a→central-b failover) — the only site→central
/// including the exact sender-forwarding that routes central's reply straight back to the waiting /// transport since the ClusterClient→gRPC migration removed the Akka path in Phase 4.
/// Ask) and <see cref="Grpc.GrpcCentralTransport"/> (a gRPC dial of <c>CentralControlService</c>). /// <see cref="NoOpCentralTransport"/> is the fail-loud placeholder used only when the Host injects
/// nothing (a wiring bug, and in TestKit suites that only exercise command dispatch).
/// </para> /// </para>
/// <para> /// <para>
/// <b>Reply/fault contract, identical on both transports.</b> Each Ask-returning method (all but /// <b>Reply/fault contract.</b> Each Ask-returning method (all but the heartbeat) guarantees exactly
/// the heartbeat) guarantees exactly one reply eventually lands at <paramref name="replyTo"/>: /// one reply eventually lands at <paramref name="replyTo"/>: either the real reply type the caller
/// either the real reply type the caller Asks for, or a transient-failure signal. The Akka path /// Asks for, or a transient-failure signal. The gRPC path sends <see cref="Status.Failure"/> on any
/// sends a not-accepted ack / <see cref="Status.Failure"/> when no ClusterClient is registered; /// non-OK status (a timeout or an <c>Unavailable</c> that could not be failed over) — what the
/// the gRPC path sends <see cref="Status.Failure"/> on any non-OK status (a timeout or an /// S&amp;F / audit / health layers above the seam already treat as transient (rows stay buffered,
/// <c>Unavailable</c> that could not be failed over). Both are what the S&amp;F / audit / health /// counters restore, the pass re-runs).
/// layers above the seam already treat as transient — rows stay buffered, counters restore, the
/// pass re-runs.
/// </para> /// </para>
/// <para> /// <para>
/// <b>The heartbeat stays fire-and-forget end-to-end.</b> <see cref="SendHeartbeat"/> takes no /// <b>The heartbeat stays fire-and-forget end-to-end.</b> <see cref="SendHeartbeat"/> takes no
@@ -0,0 +1,62 @@
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <summary>
/// The <see cref="ICentralTransport"/> used only when the Host injects none — a fail-loud placeholder,
/// not a working transport. Production always injects <see cref="Grpc.GrpcCentralTransport"/>; a null
/// here is a wiring bug, so every send warns and each Ask-returning method answers a
/// <see cref="Status.Failure"/> so the caller sees the same transient failure the old ClusterClient
/// path produced when no client was registered (rows stay buffered, the pass re-runs). The heartbeat
/// stays fire-and-forget: swallowed and logged, never faulting the heartbeat timer.
/// </summary>
/// <remarks>
/// This replaced the previous default (<c>AkkaCentralTransport</c> with no registered ClusterClient),
/// deleted when the site→central path became gRPC-only in the ClusterClient→gRPC migration's Phase 4.
/// It exists mostly so TestKit suites that only exercise central→site command dispatch — and never
/// inject a transport — construct the actor without wiring a real channel.
/// </remarks>
public sealed class NoOpCentralTransport : ICentralTransport
{
private readonly ILoggingAdapter _log;
/// <summary>Creates the placeholder transport with the owning actor's logging adapter.</summary>
/// <param name="log">The actor's logging adapter, used for the "no transport configured" warnings.</param>
public NoOpCentralTransport(ILoggingAdapter log) => _log = log;
private void Fail(string what, IActorRef replyTo)
{
_log.Warning(
"No site→central transport is configured; dropping {0} as a transient failure. This is a "
+ "wiring bug — the Host must inject a GrpcCentralTransport.", what);
replyTo.Tell(new Status.Failure(new InvalidOperationException(
$"No site→central transport configured (dropping {what}).")));
}
/// <inheritdoc />
public void SubmitNotification(NotificationSubmit message, IActorRef replyTo) => Fail(nameof(SubmitNotification), replyTo);
/// <inheritdoc />
public void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo) => Fail(nameof(QueryNotificationStatus), replyTo);
/// <inheritdoc />
public void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo) => Fail(nameof(IngestAuditEvents), replyTo);
/// <inheritdoc />
public void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo) => Fail(nameof(IngestCachedTelemetry), replyTo);
/// <inheritdoc />
public void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo) => Fail(nameof(ReconcileSite), replyTo);
/// <inheritdoc />
public void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo) => Fail(nameof(ReportSiteHealth), replyTo);
/// <inheritdoc />
public void SendHeartbeat(HeartbeatMessage message, IActorRef self) =>
_log.Warning("No site→central transport is configured; heartbeat dropped (wiring bug).");
}
@@ -35,11 +35,6 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// singleton proxy (design §7.3). This is the same target the actor used; the /// singleton proxy (design §7.3). This is the same target the actor used; the
/// extraction does not "fix" it. /// extraction does not "fix" it.
/// </para> /// </para>
/// <para>
/// <b>Excluded by design:</b> <c>IntegrationCallRequest</c> — the 29th command, dead at
/// both ends. It never enters this dispatcher; the actor keeps its own vestigial handler
/// for it (28 of 29 migrate).
/// </para>
/// </remarks> /// </remarks>
public sealed class SiteCommandDispatcher public sealed class SiteCommandDispatcher
{ {
@@ -51,21 +51,16 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
private readonly SiteCommandDispatcher _dispatcher; private readonly SiteCommandDispatcher _dispatcher;
/// <summary> /// <summary>
/// The site→central transport. Finalized in <see cref="PreStart"/> to the injected instance, /// The site→central transport. Finalized in <see cref="PreStart"/> to the injected instance, or a
/// or a default <see cref="AkkaCentralTransport"/> (ClusterClient) when none is supplied — so /// fail-loud <see cref="NoOpCentralTransport"/> when none is supplied — so the seven site→central
/// the seven site→central sends delegate here rather than owning the wire plumbing inline. /// sends delegate here rather than owning the wire plumbing inline. Production injects the gRPC
/// <see cref="Grpc.GrpcCentralTransport"/>.
/// </summary> /// </summary>
private ICentralTransport _transport; private ICentralTransport _transport;
/// <summary>The transport supplied by the Host (null selects the default Akka transport).</summary> /// <summary>The transport supplied by the Host (null falls back to <see cref="NoOpCentralTransport"/>).</summary>
private readonly ICentralTransport? _injectedTransport; private readonly ICentralTransport? _injectedTransport;
/// <summary>
/// Handler for the vestigial <see cref="IntegrationCallRequest"/> — the one command NOT
/// migrated to the dispatcher (dead at both ends), so it still routes on the actor.
/// </summary>
private IActorRef? _integrationHandler;
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary> /// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
public ITimerScheduler Timers { get; set; } = null!; public ITimerScheduler Timers { get; set; } = null!;
@@ -81,10 +76,9 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
/// ActorSystem. /// ActorSystem.
/// </param> /// </param>
/// <param name="transport"> /// <param name="transport">
/// The site→central transport. <c>null</c> (the default, used by every existing test and by /// The site→central transport. Production always passes the Host-built gRPC
/// the Host's Akka path) selects an <see cref="AkkaCentralTransport"/> over ClusterClient; the /// <see cref="Grpc.GrpcCentralTransport"/>. <c>null</c> (used by TestKit suites that only
/// Host injects a <see cref="Grpc.GrpcCentralTransport"/> when /// exercise command dispatch) falls back to a fail-loud <see cref="NoOpCentralTransport"/>.
/// <c>ScadaBridge:Communication:CentralTransport</c> is <c>Grpc</c>.
/// </param> /// </param>
/// <param name="dispatcher"> /// <param name="dispatcher">
/// The shared <see cref="SiteCommandDispatcher"/> (production: created by the Host and also /// The shared <see cref="SiteCommandDispatcher"/> (production: created by the Host and also
@@ -121,13 +115,6 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
ClusterState.ClusterFailoverCoordinator.FailOverOldest(system, role, dryRun)?.ToString(); ClusterState.ClusterFailoverCoordinator.FailOverOldest(system, role, dryRun)?.ToString();
_dispatcher = dispatcher ?? new SiteCommandDispatcher(siteId, deploymentManagerProxy, resolveFailover); _dispatcher = dispatcher ?? new SiteCommandDispatcher(siteId, deploymentManagerProxy, resolveFailover);
// Registration. Feeding the ClusterClient into the transport is a no-op unless the
// default/Akka transport is in use — the gRPC transport dials configured endpoints and
// never receives this message (the Host does not create a ClusterClient for it).
Receive<RegisterCentralClient>(msg =>
{
(_transport as AkkaCentralTransport)?.SetCentralClient(msg.Client);
});
Receive<RegisterLocalHandler>(HandleRegisterLocalHandler); Receive<RegisterLocalHandler>(HandleRegisterLocalHandler);
// ── The 27 migrated central→site commands (28th is failover, below) all route // ── The 27 migrated central→site commands (28th is failover, below) all route
@@ -165,20 +152,6 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
Receive<RetryParkedOperation>(cmd => DispatchCommand(cmd)); Receive<RetryParkedOperation>(cmd => DispatchCommand(cmd));
Receive<DiscardParkedOperation>(cmd => DispatchCommand(cmd)); Receive<DiscardParkedOperation>(cmd => DispatchCommand(cmd));
// Integration Routing — the 29th command, NOT migrated to the dispatcher (dead at
// both ends; no production code registers the handler). Kept on the actor so the
// dispatcher's command surface stays the 28 that actually migrate.
Receive<IntegrationCallRequest>(msg =>
{
if (_integrationHandler != null)
_integrationHandler.Forward(msg);
else
{
Sender.Tell(new IntegrationCallResponse(
msg.CorrelationId, _siteId, false, null, "Integration handler not available", DateTimeOffset.UtcNow));
}
});
// Central→site manual failover relay. Central and the site are separate clusters, // 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 // 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 // the SITE-SPECIFIC role, because that is what site singletons (the Deployment
@@ -234,11 +207,11 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
/// <inheritdoc /> /// <inheritdoc />
protected override void PreStart() protected override void PreStart()
{ {
// Finalize the transport now that the actor context (and _log) exist. The default Akka // Finalize the transport now that the actor context (and _log) exist. When the Host injects
// transport is given this actor's logging adapter so the "no ClusterClient registered" // none, fall back to a fail-loud NoOpCentralTransport (given this actor's logging adapter so
// warnings are preserved exactly. PreStart always runs before any message, so the Receive // "no transport configured" warnings surface). PreStart always runs before any message, so
// closures above see a non-null _transport. // the Receive closures above see a non-null _transport.
_transport = _injectedTransport ?? new AkkaCentralTransport(_log); _transport = _injectedTransport ?? new NoOpCentralTransport(_log);
_log.Info("SiteCommunicationActor started for site {0}", _siteId); _log.Info("SiteCommunicationActor started for site {0}", _siteId);
@@ -288,9 +261,6 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
case LocalHandlerType.ParkedMessages: case LocalHandlerType.ParkedMessages:
_dispatcher.RegisterParkedMessageHandler(msg.Handler); _dispatcher.RegisterParkedMessageHandler(msg.Handler);
break; break;
case LocalHandlerType.Integration:
_integrationHandler = msg.Handler;
break;
case LocalHandlerType.Artifacts: case LocalHandlerType.Artifacts:
_dispatcher.RegisterArtifactHandler(msg.Handler); _dispatcher.RegisterArtifactHandler(msg.Handler);
break; break;
@@ -362,11 +332,6 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
internal record SendHeartbeat; internal record SendHeartbeat;
} }
/// <summary>
/// Command to register a ClusterClient for communicating with the central cluster.
/// </summary>
public record RegisterCentralClient(IActorRef Client);
/// <summary> /// <summary>
/// Command to register a local actor as a handler for a specific message pattern. /// Command to register a local actor as a handler for a specific message pattern.
/// </summary> /// </summary>
@@ -376,6 +341,5 @@ public enum LocalHandlerType
{ {
EventLog, EventLog,
ParkedMessages, ParkedMessages,
Integration,
Artifacts Artifacts
} }
@@ -1,51 +1,16 @@
namespace ZB.MOM.WW.ScadaBridge.Communication; namespace ZB.MOM.WW.ScadaBridge.Communication;
/// Which transport carries the seven site→central control messages. Selected per node by
/// <c>ScadaBridge:Communication:CentralTransport</c>; the migration ships with
/// <see cref="Akka"/> as the default so nothing flips until a node opts in.
/// </summary>
public enum CentralTransportMode
{
/// <summary>Akka <c>ClusterClient</c> — the transport in production today, and the default.</summary>
Akka = 0,
/// <summary>gRPC dial of the central <c>CentralControlService</c> (Phase 1A migration target).</summary>
Grpc = 1,
}
/// <summary>
/// Selects the transport the central→site command plane rides on. The Akka
/// per-site <c>ClusterClient</c> path is the default until the gRPC cutover
/// (ClusterClient→gRPC migration, Phase 1B); flipping to <see cref="Grpc"/> is the
/// rollback-by-flag switch.
/// </summary>
public enum SiteTransportKind
{
/// <summary>Route <c>SiteEnvelope</c>s through the per-site Akka <c>ClusterClient</c> (today's default).</summary>
Akka,
/// <summary>Route <c>SiteEnvelope</c>s over the site <c>SiteCommandService</c> gRPC plane.</summary>
Grpc
}
/// <summary> /// <summary>
/// Configuration options for central-site communication, including per-pattern /// Configuration options for central-site communication, including per-pattern
/// timeouts and transport heartbeat settings. /// timeouts and transport heartbeat settings.
/// </summary> /// </summary>
public class CommunicationOptions public class CommunicationOptions
{ {
/// <summary>
/// Which transport carries the site→central control messages. Default <see cref="CentralTransportMode.Akka"/>
/// (ClusterClient) — coexistence rule: a node flips to gRPC only by setting this to <c>Grpc</c>,
/// and rollback is flipping it back. Selecting <c>Grpc</c> requires <see cref="CentralGrpcEndpoints"/>.
/// </summary>
public CentralTransportMode CentralTransport { get; set; } = CentralTransportMode.Akka;
/// <summary> /// <summary>
/// Central control-plane gRPC endpoints (preferred first), e.g. /// Central control-plane gRPC endpoints (preferred first), e.g.
/// <c>["http://scadabridge-central-a:8083", "http://scadabridge-central-b:8083"]</c>. Dialled by /// <c>["http://scadabridge-central-a:8083", "http://scadabridge-central-b:8083"]</c>. Dialled by
/// <see cref="Grpc.CentralChannelProvider"/> with sticky failover/failback. Required when /// <see cref="Grpc.CentralChannelProvider"/> with sticky failover/failback. Required on every site
/// <see cref="CentralTransport"/> is <see cref="CentralTransportMode.Grpc"/>, ignored otherwise. /// node — gRPC (<c>CentralControlService</c>) is the only site→central transport.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Sites reach central by container/host name, NOT via Traefik (which is HTTP/1 only; gRPC is /// Sites reach central by container/host name, NOT via Traefik (which is HTTP/1 only; gRPC is
@@ -53,15 +18,6 @@ public class CommunicationOptions
/// </remarks> /// </remarks>
public List<string> CentralGrpcEndpoints { get; set; } = new(); public List<string> CentralGrpcEndpoints { get; set; } = new();
/// <summary>
/// Which transport the central→site command plane uses. Default <see cref="SiteTransportKind.Akka"/>
/// (the per-site ClusterClient path) — flipping to <see cref="SiteTransportKind.Grpc"/> moves
/// every <c>SiteEnvelope</c> onto the site <c>SiteCommandService</c> gRPC plane. Selected inside
/// <c>CentralCommunicationActor</c>; <c>CommunicationService</c> and <c>SiteCallAuditActor</c>
/// are unchanged either way. Rollback at any point = flip this back to <c>Akka</c>.
/// </summary>
public SiteTransportKind SiteTransport { get; set; } = SiteTransportKind.Akka;
/// <summary>Timeout for deployment commands (typically longest due to apply logic).</summary> /// <summary>Timeout for deployment commands (typically longest due to apply logic).</summary>
public TimeSpan DeploymentTimeout { get; set; } = TimeSpan.FromMinutes(2); public TimeSpan DeploymentTimeout { get; set; } = TimeSpan.FromMinutes(2);
@@ -91,12 +47,6 @@ public class CommunicationOptions
/// </summary> /// </summary>
public TimeSpan NotificationForwardTimeout { get; set; } = TimeSpan.FromSeconds(30); public TimeSpan NotificationForwardTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Contact point addresses for the central cluster (e.g. "akka.tcp://scadabridge@central-a:8081").
/// Used by site nodes to create a ClusterClient for reaching central.
/// </summary>
public List<string> CentralContactPoints { get; set; } = new();
/// <summary> /// <summary>
/// Preshared key authenticating this node's gRPC control plane — the site↔central /// Preshared key authenticating this node's gRPC control plane — the site↔central
/// boundary. On a site node this is the key its inbound gate /// boundary. On a site node this is the key its inbound gate
@@ -66,18 +66,15 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase<Communi
builder.RequireThat(options.GrpcMaxConcurrentStreams > 0, builder.RequireThat(options.GrpcMaxConcurrentStreams > 0,
$"ScadaBridge:Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams})."); $"ScadaBridge:Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams}).");
// The gRPC site→central transport needs at least one central endpoint to dial. Only // The gRPC site→central transport needs at least one central endpoint to dial. gRPC is now
// enforced when that transport is selected the default Akka path ignores the list, so a // the only site→central transport (ClusterClient was removed in the migration's Phase 4), so
// node on ClusterClient must not be forced to declare gRPC endpoints it never uses. // every site node must declare its central endpoints — there is no Akka fallback to ignore
if (options.CentralTransport == CentralTransportMode.Grpc) // the list. Central nodes leave it empty (they host CentralControlService, they don't dial it).
{ builder.RequireThat(
builder.RequireThat( options.CentralGrpcEndpoints.All(e => !string.IsNullOrWhiteSpace(e)),
options.CentralGrpcEndpoints.Count > 0 "ScadaBridge:Communication:CentralGrpcEndpoints must not contain empty or whitespace "
&& options.CentralGrpcEndpoints.All(e => !string.IsNullOrWhiteSpace(e)), + "entries (a site node must list at least one central gRPC endpoint; central nodes "
"ScadaBridge:Communication:CentralGrpcEndpoints must list at least one non-empty " + "leave it empty).");
+ "central gRPC endpoint when CentralTransport is Grpc "
+ $"(was {options.CentralGrpcEndpoints.Count} entr{(options.CentralGrpcEndpoints.Count == 1 ? "y" : "ies")}).");
}
// ── Aggregated live alarm cache (plan #10, Task 6) ─────────────────────── // ── Aggregated live alarm cache (plan #10, Task 6) ───────────────────────
// Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator // Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator
@@ -224,21 +224,13 @@ public class CommunicationService
} }
// ── Pattern 4: Integration Routing ── // ── Pattern 4: Integration Routing ──
// The brokered "External System → Central → Site → Central → External System"
/// <summary> // round-trip (design §4) is served by the Inbound API's routed-site-script
/// Routes an integration call to a site. // path — the RouteTo* verbs below (RouteToCall / RouteToGetAttributes /
/// </summary> // RouteToSetAttributes / RouteToWaitForAttribute), driven from
/// <param name="siteId">The target site identifier.</param> // InboundAPI/CommunicationServiceInstanceRouter and sharing IntegrationTimeout.
/// <param name="request">The integration call request.</param> // The earlier generic IntegrationCallRequest primitive was never wired to a
/// <param name="cancellationToken">Cancellation token.</param> // producer or a site handler and was removed as dead code (Gitea #32).
/// <returns>The integration call response.</returns>
public async Task<IntegrationCallResponse> RouteIntegrationCallAsync(
string siteId, IntegrationCallRequest request, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, request);
return await GetActor().Ask<IntegrationCallResponse>(
envelope, _options.IntegrationTimeout, cancellationToken);
}
// ── Pattern 5: Debug View ── // ── Pattern 5: Debug View ──
@@ -34,13 +34,6 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// server (T1B.2) and the central transport (T1B.3) can share one implementation /// server (T1B.2) and the central transport (T1B.3) can share one implementation
/// without a project-reference cycle. /// without a project-reference cycle.
/// </para> /// </para>
/// <para>
/// <b>Excluded by design:</b> <c>IntegrationCallRequest</c>. It is the 29th
/// command on <c>SiteCommunicationActor</c>'s receive table but is dead at both
/// ends — no production code registers the integration handler, so the site
/// always answers "Integration handler not available". See
/// <c>docs/known-issues/2026-07-22-integration-call-routing-is-dead-code.md</c>.
/// </para>
/// <para><b>Null conventions.</b> proto3 has no presence for scalars, so:</para> /// <para><b>Null conventions.</b> proto3 has no presence for scalars, so:</para>
/// <list type="bullet"> /// <list type="bullet">
/// <item> /// <item>
@@ -17,10 +17,6 @@ import "google/protobuf/wrappers.proto";
// place on the client and one dispatch switch on the server. A `oneof` request // place on the client and one dispatch switch on the server. A `oneof` request
// / reply envelope preserves per-command typing inside the group. // / reply envelope preserves per-command typing inside the group.
// //
// IntegrationCallRequest (the 29th command) is deliberately NOT here it is
// dead code at both ends; see
// docs/known-issues/2026-07-22-integration-call-routing-is-dead-code.md.
//
// EVOLUTION RULE (same as sitestream.proto): field numbers are never reused and // EVOLUTION RULE (same as sitestream.proto): field numbers are never reused and
// changes are additive only. New commands take the next free oneof tag; an // changes are additive only. New commands take the next free oneof tag; an
// older peer simply sees an unset oneof and answers with a clean error. // older peer simply sees an unset oneof and answers with a clean error.
@@ -12,7 +12,8 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// Maps IDataConnection methods to OPC UA concepts via IOpcUaClient abstraction. /// Maps IDataConnection methods to OPC UA concepts via IOpcUaClient abstraction.
/// ///
/// OPC UA mapping: /// OPC UA mapping:
/// - TagPath → NodeId (e.g., "ns=2;s=MyDevice.Temperature") /// - TagPath → NodeId (durable form, e.g. "nsu=https://server/ns;s=MyDevice.Temperature";
/// the legacy "ns=2;s=..." index form is still accepted — see OpcUaNodeReference)
/// - Subscribe → MonitoredItem with DataChangeNotification /// - Subscribe → MonitoredItem with DataChangeNotification
/// - Read/Write → Read/Write service calls /// - Read/Write → Read/Write service calls
/// - Quality → OPC UA StatusCode mapping /// - Quality → OPC UA StatusCode mapping
@@ -123,6 +123,11 @@ public class RealOpcUaClient : IOpcUaClient
endpoint = new EndpointDescription(endpointUrl); endpoint = new EndpointDescription(endpointUrl);
} }
// The server advertises its own base address, which is frequently unroutable from here
// (a 0.0.0.0 wildcard bind, or an internal container/NAT hostname). Swap it back to the
// host the operator actually reached, or the session channel dials an unreachable address.
RewriteEndpointHostForReachability(endpoint, endpointUrl);
var endpointConfig = EndpointConfiguration.Create(appConfig); var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
@@ -157,6 +162,52 @@ public class RealOpcUaClient : IOpcUaClient
await _subscription.CreateAsync(cancellationToken); await _subscription.CreateAsync(cancellationToken);
} }
/// <summary>
/// Rewrites a discovered endpoint's host/port to match the reachable URL the operator
/// configured, when the server advertised a different (unroutable) one.
/// </summary>
/// <remarks>
/// An OPC UA server advertises its base address from its OWN configuration, not the route the
/// client took to reach it — so <c>GetEndpoints</c> routinely returns a wildcard bind
/// (<c>opc.tcp://0.0.0.0:4840/…</c>) or an internal container/NAT hostname the client cannot
/// dial. The OPC Foundation session opens its channel to <see cref="EndpointDescription.EndpointUrl"/>
/// verbatim, so a 0.0.0.0 advertisement resolves to the client's OWN loopback and the connect
/// fails. This swaps only the authority (host + port) to the reachable one, preserving the
/// advertised scheme and path (e.g. <c>/OtOpcUa</c>). Mirrors <c>CoreClientUtils.SelectEndpoint</c>.
/// A no-op when the advertised host/port is already reachable (so an opc-plc server that
/// advertises its real container name is left untouched).
/// </remarks>
internal static void RewriteEndpointHostForReachability(EndpointDescription? endpoint, string discoveryUrl)
{
if (endpoint is null || string.IsNullOrWhiteSpace(endpoint.EndpointUrl))
return;
if (!Uri.TryCreate(discoveryUrl, UriKind.Absolute, out var reachable))
return;
if (!Uri.TryCreate(endpoint.EndpointUrl, UriKind.Absolute, out var advertised))
return;
// Already reachable — leave it exactly as advertised.
if (string.Equals(advertised.Host, reachable.Host, StringComparison.OrdinalIgnoreCase)
&& advertised.Port == reachable.Port)
return;
// An IPv6 literal must keep its brackets, or the reconstructed authority (`::1:4840`) is
// ambiguous and unparseable. Uri.Host may or may not include them depending on the scheme,
// so bracket only when missing (never double-wrap).
var host = reachable.Host;
if (reachable.HostNameType == UriHostNameType.IPv6 && !host.StartsWith('['))
host = $"[{host}]";
// opc.tcp has no default port known to Uri, so Uri.Port is -1 when the text omits one.
// Prefer the reachable URL's port, fall back to the advertised one, and emit no ":port"
// at all when neither carries one (rather than a malformed ":-1").
var port = reachable.Port >= 0 ? reachable.Port : advertised.Port;
var authority = port >= 0 ? $"{host}:{port}" : host;
// Authority-only advertisements parse to AbsolutePath "/" — drop it so we don't append a
// spurious trailing slash the server never advertised.
var path = advertised.AbsolutePath == "/" ? string.Empty : advertised.AbsolutePath;
endpoint.EndpointUrl = $"{advertised.Scheme}://{authority}{path}";
}
/// <summary> /// <summary>
/// Probes an OPC UA endpoint configuration WITHOUT persisting it or creating a /// Probes an OPC UA endpoint configuration WITHOUT persisting it or creating a
/// long-lived connection — connect, capture the server certificate if it is untrusted, /// long-lived connection — connect, capture the server certificate if it is untrusted,
@@ -258,6 +309,10 @@ public class RealOpcUaClient : IOpcUaClient
endpoint = new EndpointDescription(endpointUrl); endpoint = new EndpointDescription(endpointUrl);
} }
// Same reachability rewrite as ConnectAsync — a probe against a server advertising a
// 0.0.0.0 / NAT base address must dial the reachable host, not the advertised one.
RewriteEndpointHostForReachability(endpoint, endpointUrl);
var endpointConfig = EndpointConfiguration.Create(appConfig); var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
@@ -1,7 +1,5 @@
using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Akka.Cluster; using Akka.Cluster;
using Akka.Cluster.Tools.Client;
using Akka.Cluster.Tools.Singleton; using Akka.Cluster.Tools.Singleton;
using Akka.Configuration; using Akka.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -263,8 +261,16 @@ public class AkkaHostedService : IHostedService
TimeSpan transportHeartbeat, TimeSpan transportHeartbeat,
TimeSpan transportFailure) TimeSpan transportFailure)
{ {
var seedNodesStr = string.Join(",", // Simultaneous-cold-start split-brain guard (Gitea #33): when enabled the node must NOT
clusterOptions.SeedNodes.Select(QuoteHocon)); // auto-join from its config seeds — Akka's FirstSeedNodeProcess is exactly what races the
// peer and forms two 1-node clusters. Emit an EMPTY seed list so the ActorSystem comes up
// unjoined, and ClusterBootstrapCoordinator issues the single reachability-gated
// JoinSeedNodes (founder self-first / joiner peer-first). Default OFF, so guard-off configs
// render byte-identical to before.
var effectiveSeeds = clusterOptions.BootstrapGuard.Enabled
? Enumerable.Empty<string>()
: clusterOptions.SeedNodes;
var seedNodesStr = string.Join(",", effectiveSeeds.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 // auto-down (default): AutoDowning provider — the leader among the reachable
@@ -428,15 +434,21 @@ akka {{
var centralHealthCollector = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.HealthMonitoring.ISiteHealthCollector>(); var centralHealthCollector = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.HealthMonitoring.ISiteHealthCollector>();
centralHealthCollector?.SetNodeHostname(_nodeOptions.NodeHostname); centralHealthCollector?.SetNodeHostname(_nodeOptions.NodeHostname);
var siteClientFactory = new DefaultSiteClientFactory(); // Central→site command transport: gRPC SiteCommandService. This is the ONLY transport since
// the ClusterClient→gRPC migration's Phase 4 removed the Akka path; the actor no longer
// chooses a transport, the Host builds it. Constructed once from the DI
// SitePairChannelProvider and shared across CentralCommunicationActor incarnations (it holds
// only the shared provider + options); the actor reconciles its per-site channel pairs from
// the DB refresh loop.
var siteCommandTransport = new GrpcSiteTransport(
_serviceProvider.GetRequiredService<SitePairChannelProvider>(),
_communicationOptions,
_serviceProvider.GetRequiredService<ILoggerFactory>().CreateLogger<GrpcSiteTransport>());
_logger.LogInformation("central→site command transport: gRPC (SiteCommandService)");
var centralCommActor = _actorSystem!.ActorOf( var centralCommActor = _actorSystem!.ActorOf(
Props.Create(() => new CentralCommunicationActor(_serviceProvider, siteClientFactory)), Props.Create(() => new CentralCommunicationActor(_serviceProvider, siteCommandTransport)),
"central-communication"); "central-communication");
// Register CentralCommunicationActor with ClusterClientReceptionist so site ClusterClients can reach it
ClusterClientReceptionist.Get(_actorSystem).RegisterService(centralCommActor);
_logger.LogInformation("CentralCommunicationActor registered with ClusterClientReceptionist");
// Hand the same actor to the central-hosted gRPC control plane (T1A.2) and open its // Hand the same actor to the central-hosted gRPC control plane (T1A.2) and open its
// readiness gate — the gRPC face Asks this exact actor, so both transports resolve to // readiness gate — the gRPC face Asks this exact actor, so both transports resolve to
// one handler implementation. Mirrors SiteStreamGrpcServer.SetReady on the site side: // one handler implementation. Mirrors SiteStreamGrpcServer.SetReady on the site side:
@@ -833,35 +845,25 @@ akka {{
_logger, role: siteRole); _logger, role: siteRole);
var dmProxy = dm.Proxy; var dmProxy = dm.Proxy;
// Select the site→central transport behind the coexistence flag (default Akka // Site→central transport: a gRPC dial of CentralControlService with a sticky
// ClusterClient). When gRPC is chosen the site dials CentralControlService directly with // central-a→central-b channel pair, presenting this site's preshared key. This is the ONLY
// a sticky-failover channel pair, presenting its own preshared key; the ClusterClient // transport since the ClusterClient→gRPC migration's Phase 4 removed the Akka path;
// below is then not created at all. // StartupValidator guarantees a Site node lists at least one CentralGrpcEndpoint.
ICentralTransport? centralTransport = null; var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
if (_communicationOptions.CentralTransport == CentralTransportMode.Grpc) var channelProvider = new CentralChannelProvider(
{ _communicationOptions.CentralGrpcEndpoints,
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>(); new StaticSitePskProvider(_communicationOptions.GrpcPsk),
var channelProvider = new CentralChannelProvider( _nodeOptions.SiteId!,
_communicationOptions.CentralGrpcEndpoints, _communicationOptions,
new StaticSitePskProvider(_communicationOptions.GrpcPsk), loggerFactory.CreateLogger<CentralChannelProvider>());
_nodeOptions.SiteId!, _trackedDisposables.Add(channelProvider);
_communicationOptions, ICentralTransport centralTransport = new GrpcCentralTransport(
loggerFactory.CreateLogger<CentralChannelProvider>()); channelProvider,
_trackedDisposables.Add(channelProvider); _communicationOptions,
centralTransport = new GrpcCentralTransport( loggerFactory.CreateLogger<GrpcCentralTransport>());
channelProvider, _logger.LogInformation(
_communicationOptions, "Site→central transport: gRPC to {Count} central endpoint(s) for site {SiteId}",
loggerFactory.CreateLogger<GrpcCentralTransport>()); _communicationOptions.CentralGrpcEndpoints.Count, _nodeOptions.SiteId);
_logger.LogInformation(
"Site→central transport: gRPC to {Count} central endpoint(s) for site {SiteId}",
_communicationOptions.CentralGrpcEndpoints.Count, _nodeOptions.SiteId);
}
else
{
_logger.LogInformation(
"Site→central transport: Akka ClusterClient (default) for site {SiteId}",
_nodeOptions.SiteId);
}
// The ONE routing table for central→site commands, shared by the Akka // The ONE routing table for central→site commands, shared by the Akka
// SiteCommunicationActor (below) and the gRPC SiteCommandGrpcService (SetReady at the end // SiteCommunicationActor (below) and the gRPC SiteCommandGrpcService (SetReady at the end
@@ -997,36 +999,10 @@ akka {{
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.ParkedMessages, parkedMessageHandler)); siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.ParkedMessages, parkedMessageHandler));
} }
// Register SiteCommunicationActor with ClusterClientReceptionist so central ClusterClients can reach it
ClusterClientReceptionist.Get(_actorSystem).RegisterService(siteCommActor);
_logger.LogInformation( _logger.LogInformation(
"Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.", "Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.",
siteRole); siteRole);
// Create ClusterClient to central if contact points are configured — but only on the Akka
// transport. On the gRPC transport the SiteCommunicationActor already holds a
// GrpcCentralTransport and never receives RegisterCentralClient, so a ClusterClient here
// would be dead weight (and keep an unwanted cross-cluster Akka association alive).
if (_communicationOptions.CentralTransport == CentralTransportMode.Akka
&& _communicationOptions.CentralContactPoints.Count > 0)
{
var contacts = _communicationOptions.CentralContactPoints
.Select(cp => ActorPath.Parse($"{cp}/system/receptionist"))
.ToImmutableHashSet();
var clientSettings = ClusterClientSettings.Create(_actorSystem)
.WithInitialContacts(contacts);
var centralClient = _actorSystem.ActorOf(
ClusterClient.Props(clientSettings), "central-cluster-client");
var siteCommSelection = _actorSystem.ActorSelection("/user/site-communication");
siteCommSelection.Tell(new RegisterCentralClient(centralClient));
_logger.LogInformation(
"Created ClusterClient to central with {Count} contact point(s) for site {SiteId}",
contacts.Count, _nodeOptions.SiteId);
}
// Per-node startup reconciliation. Created on EVERY site node (NOT a // Per-node startup reconciliation. Created on EVERY site node (NOT a
// singleton) so a standby that was DOWN during a deploy self-heals on its next // singleton) so a standby that was DOWN during a deploy self-heals on its next
// restart: it reports its local deployed inventory to central via the // restart: it reports its local deployed inventory to central via the
@@ -0,0 +1,211 @@
using System.Diagnostics;
using System.Net.Sockets;
using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
namespace ZB.MOM.WW.ScadaBridge.Host.Actors;
/// <summary>
/// Runs the simultaneous-cold-start split-brain guard (Gitea #33): when
/// <c>ScadaBridge:Cluster:BootstrapGuard:Enabled</c>, the node starts with NO config seed nodes
/// (<c>AkkaHostedService.BuildHocon</c> emits an empty seed list), and this coordinator picks the join
/// order and issues the single <see cref="Akka.Cluster.Cluster.JoinSeedNodes"/> once the ActorSystem is
/// up. See <see cref="ClusterBootstrapGuard"/> for the decision logic and the split-brain rationale.
/// </summary>
/// <remarks>
/// <para>
/// The founder (lower canonical address) joins its self-first order immediately. The higher
/// node probes its partner's Akka endpoint (a plain TCP connect — the partner has bound its
/// port well before it forms) up to <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/>:
/// reachable ⇒ peer-first (join the founder); unreachable ⇒ self-first (partner is dead, form
/// alone). The join runs on a background task so it never blocks host startup, and it is issued
/// exactly once — the coordinator never re-forms a node mid-handshake, the failure mode of the
/// rejected self-form-watchdog design (see <c>SelfFirstSeedBootstrapTests</c>).
/// </para>
/// <para>
/// Registered unconditionally in both the Central and Site composition roots but a no-op unless
/// the guard is enabled, so guard-off deployments keep Akka's config-driven self-first
/// auto-join byte-identical.
/// </para>
/// </remarks>
public sealed class ClusterBootstrapCoordinator : IHostedService
{
private readonly Func<ActorSystem> _system;
private readonly ClusterOptions _clusterOptions;
private readonly NodeOptions _nodeOptions;
private readonly ILogger<ClusterBootstrapCoordinator> _log;
private readonly CancellationTokenSource _cts = new();
private Task? _joinTask;
/// <summary>Creates the coordinator.</summary>
/// <param name="system">Lazy accessor for the node's ActorSystem (resolved after the Akka hosted service creates it).</param>
/// <param name="clusterOptions">The bound cluster options (seed list + guard settings).</param>
/// <param name="nodeOptions">The bound node identity (advertised hostname + remoting port).</param>
/// <param name="log">Logger for the bootstrap decision.</param>
public ClusterBootstrapCoordinator(
Func<ActorSystem> system,
IOptions<ClusterOptions> clusterOptions,
IOptions<NodeOptions> nodeOptions,
ILogger<ClusterBootstrapCoordinator> log)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
_clusterOptions = clusterOptions?.Value ?? throw new ArgumentNullException(nameof(clusterOptions));
_nodeOptions = nodeOptions?.Value ?? throw new ArgumentNullException(nameof(nodeOptions));
_log = log ?? throw new ArgumentNullException(nameof(log));
}
/// <inheritdoc/>
public Task StartAsync(CancellationToken cancellationToken)
{
if (!_clusterOptions.BootstrapGuard.Enabled)
return Task.CompletedTask; // dark switch off — Akka auto-joined from config seeds already.
// Fire-and-forget so a higher node with a dead partner (which waits the full probe window)
// never blocks host startup. Exceptions are logged; a failed join leaves the node unjoined,
// which is visible and recoverable, never a silent split.
_joinTask = Task.Run(() => RunAsync(_cts.Token), _cts.Token);
return Task.CompletedTask;
}
/// <inheritdoc/>
public async Task StopAsync(CancellationToken cancellationToken)
{
await _cts.CancelAsync().ConfigureAwait(false);
if (_joinTask is not null)
{
try { await _joinTask.ConfigureAwait(false); }
catch (OperationCanceledException) { /* expected on shutdown */ }
}
}
private async Task RunAsync(CancellationToken ct)
{
try
{
var seeds = _clusterOptions.SeedNodes ?? new List<string>();
var role = ClusterBootstrapGuard.Analyze(seeds, _nodeOptions.NodeHostname, _nodeOptions.RemotingPort);
string[] order;
var committedPeerFirst = false;
if (!role.Applies)
{
// Not a pair seed (single-node install, self absent, malformed, or 3+ seeds): join the
// configured seeds unchanged — the guard arbitrates only the 2-node pair race.
order = seeds.ToArray();
_log.LogInformation(
"Bootstrap guard: not a pair seed ({SeedCount} seed(s)); joining configured seeds unchanged.",
seeds.Count);
}
else if (role.IsFounder)
{
order = role.SelfFirstOrder;
_log.LogInformation(
"Bootstrap guard: this node is the preferred founder (lower address); joining self-first, forming immediately if no peer answers.");
}
else
{
var reachable = await ProbePartnerAsync(role.PartnerHost!, role.PartnerPort, ct).ConfigureAwait(false);
order = ClusterBootstrapGuard.HigherNodeOrder(role, reachable);
committedPeerFirst = reachable;
_log.LogInformation(
"Bootstrap guard: this node is the higher address; partner {PartnerHost}:{PartnerPort} was {Reachability} within the probe window; joining {Order}.",
role.PartnerHost, role.PartnerPort, reachable ? "REACHABLE" : "UNREACHABLE",
reachable ? "peer-first (join the founder)" : "self-first (partner down, forming alone)");
}
if (order.Length == 0)
{
_log.LogWarning("Bootstrap guard: no seed nodes to join; node stays unjoined until a seed is configured.");
return;
}
var cluster = Akka.Cluster.Cluster.Get(_system());
var addresses = order.Select(Address.Parse).ToArray();
cluster.JoinSeedNodes(addresses);
// Peer-first is a one-way commitment: JoinSeedNodeProcess never self-forms. If the founder
// died in the probe→join window, this node hangs unjoined forever. We do NOT re-form here
// (the rejected mid-handshake failure mode) — we make the hang operator-visible so a
// restart, which re-runs the guard against the now-dead founder and self-forms, recovers
// it. Nothing is done on the founder / self-first paths, which always self-form on their own.
if (committedPeerFirst)
await WarnIfNotUpAsync(cluster, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Host shutting down before the join completed — nothing to do.
}
catch (Exception ex)
{
_log.LogError(ex, "Bootstrap guard: failed to issue JoinSeedNodes; node remains unjoined.");
}
}
/// <summary>
/// After committing peer-first, waits a bounded time for this node to reach <c>Up</c> and logs a
/// clear warning if it does not — the founder must have died in the probe→join window, leaving this
/// node hung in <c>JoinSeedNodeProcess</c>. Read-only: it never re-forms or re-joins (that is the
/// rejected mid-handshake failure mode); it only surfaces the condition so an operator restarts the node.
/// </summary>
private async Task WarnIfNotUpAsync(Akka.Cluster.Cluster cluster, CancellationToken ct)
{
// Give the join a generous grace: the founder's own self-form (seed-node-timeout) plus this
// node's join round-trip. Two probe windows is comfortably beyond both.
var grace = TimeSpan.FromSeconds(Math.Max(10, _clusterOptions.BootstrapGuard.PartnerProbeSeconds * 2));
var sw = Stopwatch.StartNew();
while (!ct.IsCancellationRequested && sw.Elapsed < grace)
{
if (cluster.SelfMember.Status == MemberStatus.Up) return; // joined — all good
try { await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
if (!ct.IsCancellationRequested && cluster.SelfMember.Status != MemberStatus.Up)
_log.LogWarning(
"Bootstrap guard: committed peer-first but this node is still {Status} after {Grace}s — its founder likely died in the probe→join window. This node will NOT self-form on its own (peer-first never does). RESTART it to recover: the guard will re-run, find the founder down, and form alone.",
cluster.SelfMember.Status, (int)grace.TotalSeconds);
}
/// <summary>
/// Polls the partner's Akka endpoint with a plain TCP connect until it is reachable or the probe
/// window elapses. Reachable-at-TCP is a sound proxy for "the partner is coming up": a node binds
/// its Akka port near the start of startup, well before it forms or joins a cluster.
/// </summary>
private async Task<bool> ProbePartnerAsync(string host, int port, CancellationToken ct)
{
var window = TimeSpan.FromSeconds(Math.Max(0, _clusterOptions.BootstrapGuard.PartnerProbeSeconds));
var interval = TimeSpan.FromMilliseconds(Math.Max(50, _clusterOptions.BootstrapGuard.PartnerProbeIntervalMs));
var sw = Stopwatch.StartNew();
while (!ct.IsCancellationRequested)
{
if (await TryConnectAsync(host, port, ct).ConfigureAwait(false)) return true;
if (sw.Elapsed >= window) return false;
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return false; }
}
return false;
}
private async Task<bool> TryConnectAsync(string host, int port, CancellationToken ct)
{
try
{
using var client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(Math.Max(100, _clusterOptions.BootstrapGuard.ProbeConnectTimeoutMs));
await client.ConnectAsync(host, port, cts.Token).ConfigureAwait(false);
return client.Connected;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // host shutdown — propagate
}
catch
{
return false; // connect refused / timed out / DNS not resolvable yet — partner not up
}
}
}
+14
View File
@@ -394,6 +394,20 @@ try
builder.Services.AddSingleton<Akka.Actor.ActorSystem>(sp => builder.Services.AddSingleton<Akka.Actor.ActorSystem>(sp =>
sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem()); sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem());
// Simultaneous-cold-start split-brain guard (Gitea #33, dark switch
// ScadaBridge:Cluster:BootstrapGuard:Enabled, default off). Registered AFTER the
// AkkaHostedService/ActorSystem bridge so its StartAsync resolves the system the main path
// built; when the guard is off it no-ops (Akka auto-joined from config seeds), so registering
// it unconditionally is safe. When on, the node started unjoined (empty HOCON seed list, see
// AkkaHostedService.BuildHocon) and this coordinator issues the single reachability-gated
// JoinSeedNodes. It resolves the ActorSystem lazily via GetOrCreateActorSystem so it never
// races startup or caches a null.
builder.Services.AddHostedService(sp => new ClusterBootstrapCoordinator(
() => sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem(),
sp.GetRequiredService<IOptions<ClusterOptions>>(),
sp.GetRequiredService<IOptions<NodeOptions>>(),
sp.GetRequiredService<ILogger<ClusterBootstrapCoordinator>>()));
// Register the production IActiveNodeGate implementation so // Register the production IActiveNodeGate implementation so
// standby-node gating is actually enforced (the InboundApiEndpointFilter // standby-node gating is actually enforced (the InboundApiEndpointFilter
// consults IActiveNodeGate and defaults to "allow" when none is registered, // consults IActiveNodeGate and defaults to "allow" when none is registered,
@@ -168,6 +168,19 @@ public static class SiteServiceRegistration
services.AddSingleton<Akka.Actor.ActorSystem>(sp => services.AddSingleton<Akka.Actor.ActorSystem>(sp =>
sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem()); sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem());
// Simultaneous-cold-start split-brain guard (Gitea #33, dark switch
// ScadaBridge:Cluster:BootstrapGuard:Enabled, default off). This is the case the guard was
// built for — a shared power event that powers up both site VMs together. Registered AFTER
// the AkkaHostedService/ActorSystem bridge (mirrors the Central composition root in
// Program.cs); it no-ops when the guard is off, so registering it unconditionally is safe.
// When on, the node started unjoined (empty HOCON seed list) and this coordinator issues the
// single reachability-gated JoinSeedNodes, resolving the ActorSystem lazily.
services.AddHostedService(sp => new ClusterBootstrapCoordinator(
() => sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem(),
sp.GetRequiredService<IOptions<ClusterOptions>>(),
sp.GetRequiredService<IOptions<NodeOptions>>(),
sp.GetRequiredService<ILogger<ClusterBootstrapCoordinator>>()));
// Cluster node status provider for health reports // Cluster node status provider for health reports
services.AddSingleton<IClusterNodeProvider>(sp => services.AddSingleton<IClusterNodeProvider>(sp =>
{ {
@@ -143,6 +143,22 @@ public static class StartupValidator
+ "production as ${secret:SB-GRPC-PSK-<siteId>}) and under the secret " + "production as ${secret:SB-GRPC-PSK-<siteId>}) and under the secret "
+ "name SB-GRPC-PSK-<siteId> in central's secret store"); + "name SB-GRPC-PSK-<siteId> in central's secret store");
// gRPC (CentralControlService) is the only site→central transport after the
// ClusterClient→gRPC migration's Phase 4 — the Akka ClusterClient path and its
// CentralContactPoints option are gone. A site with no central gRPC endpoint has
// nothing to dial: heartbeats, health reports, notification forwards and audit
// ingest all silently fail. The shared CommunicationOptionsValidator only rejects
// BLANK entries (it is role-agnostic, and central nodes legitimately leave the list
// empty), so the "a Site must have at least one" rule lives here, where the role is
// known. The predicate reads index :0 directly, so its non-empty presence proves the
// list has a usable first endpoint.
p.Require("ScadaBridge:Communication:CentralGrpcEndpoints:0",
value => !string.IsNullOrWhiteSpace(value),
"is required for Site nodes: gRPC (CentralControlService) is the only "
+ "site→central transport, so each site must list at least one central gRPC "
+ "endpoint under ScadaBridge:Communication:CentralGrpcEndpoints "
+ "(e.g. http://scadabridge-central-a:8083). Central nodes leave it empty.");
// ScadaBridge:Database:SiteDbPath was required here until LocalDb // ScadaBridge:Database:SiteDbPath was required here until LocalDb
// Phase 2. The site's tables now live in the consolidated LocalDb // Phase 2. The site's tables now live in the consolidated LocalDb
// database (LocalDb:Path, which SiteServiceRegistration requires), // database (LocalDb:Path, which SiteServiceRegistration requires),
@@ -17,7 +17,14 @@
"StableAfter": "00:00:15", "StableAfter": "00:00:15",
"HeartbeatInterval": "00:00:02", "HeartbeatInterval": "00:00:02",
"FailureDetectionThreshold": "00:00:10", "FailureDetectionThreshold": "00:00:10",
"MinNrOfMembers": 1 "MinNrOfMembers": 1,
"_bootstrapGuard": "Gitea #33 simultaneous-cold-start split-brain guard. Dark switch, default off (Akka's config-driven self-first auto-join, unchanged). Enable ONLY where both pair VMs can power up together (shared power/hypervisor event) with no start-order serialization: then the lower host:port founds self-first and the higher probes-then-joins, so the pair converges to one cluster instead of two 1-node clusters. Timings validated > 0 when enabled.",
"BootstrapGuard": {
"Enabled": false,
"PartnerProbeSeconds": 25,
"PartnerProbeIntervalMs": 500,
"ProbeConnectTimeoutMs": 1000
}
}, },
"_secrets": "Host-003: Secrets are NOT committed in this file. The ${secret:...} references below are resolved at startup by the pre-host secrets expander (before StartupValidator runs) from the encrypted secrets store (SQLite, seeded via the ZB.MOM.WW.Secrets store/CLI/UI). The KEK is supplied out-of-band via the ZB_SECRETS_MASTER_KEY environment variable and never committed; an unseeded reference fails closed (SecretNotFoundException) before any SQL/LDAP/cluster wiring. ROLLBACK: the whole-key environment override still wins — set ScadaBridge__Database__ConfigurationDb / ScadaBridge__Security__Ldap__ServiceAccountPassword / ScadaBridge__Security__JwtSigningKey and .AddEnvironmentVariables() overlays the concrete value before expansion runs, so the ${secret:...} token is never evaluated. NOTE (Task 1.4): the LDAP settings moved into the nested Security:Ldap sub-section (bound to the shared ZB.MOM.WW.Auth LdapOptions) — the service-account-password env var is ScadaBridge__Security__Ldap__ServiceAccountPassword (was ScadaBridge__Security__LdapServiceAccountPassword).", "_secrets": "Host-003: Secrets are NOT committed in this file. The ${secret:...} references below are resolved at startup by the pre-host secrets expander (before StartupValidator runs) from the encrypted secrets store (SQLite, seeded via the ZB.MOM.WW.Secrets store/CLI/UI). The KEK is supplied out-of-band via the ZB_SECRETS_MASTER_KEY environment variable and never committed; an unseeded reference fails closed (SecretNotFoundException) before any SQL/LDAP/cluster wiring. ROLLBACK: the whole-key environment override still wins — set ScadaBridge__Database__ConfigurationDb / ScadaBridge__Security__Ldap__ServiceAccountPassword / ScadaBridge__Security__JwtSigningKey and .AddEnvironmentVariables() overlays the concrete value before expansion runs, so the ${secret:...} token is never evaluated. NOTE (Task 1.4): the LDAP settings moved into the nested Security:Ldap sub-section (bound to the shared ZB.MOM.WW.Auth LdapOptions) — the service-account-password env var is ScadaBridge__Security__Ldap__ServiceAccountPassword (was ScadaBridge__Security__LdapServiceAccountPassword).",
"Database": { "Database": {
@@ -20,7 +20,14 @@
"StableAfter": "00:00:15", "StableAfter": "00:00:15",
"HeartbeatInterval": "00:00:02", "HeartbeatInterval": "00:00:02",
"FailureDetectionThreshold": "00:00:10", "FailureDetectionThreshold": "00:00:10",
"MinNrOfMembers": 1 "MinNrOfMembers": 1,
"_bootstrapGuard": "Gitea #33 simultaneous-cold-start split-brain guard. Dark switch, default off (Akka's config-driven self-first auto-join, unchanged). Enable ONLY where both pair VMs can power up together (shared power/hypervisor event) with no start-order serialization: then the lower host:port founds self-first and the higher probes-then-joins, so the pair converges to one cluster instead of two 1-node clusters. Timings validated > 0 when enabled.",
"BootstrapGuard": {
"Enabled": false,
"PartnerProbeSeconds": 25,
"PartnerProbeIntervalMs": 500,
"ProbeConnectTimeoutMs": 1000
}
}, },
"Database": { "Database": {
// Migration-only as of LocalDb Phase 2. The site config tables now live in the // Migration-only as of LocalDb Phase 2. The site config tables now live in the
@@ -44,9 +51,9 @@
"Communication": { "Communication": {
"_grpcPsk": "REQUIRED on Site nodes (StartupValidator fails the boot without it). The preshared key the gRPC control plane authenticates with: ControlPlaneAuthInterceptor is fail-closed, so an unset key refuses every SiteStream call — live subscriptions, audit pulls, cached-telemetry ingest — while the node still reports healthy. Supply it as ${secret:SB-GRPC-PSK-<siteId>} so the plaintext never sits in this file, and seed the SAME value on central: either as the secret SB-GRPC-PSK-<siteId> in its store, or as ScadaBridge:Communication:SitePsks:<siteId>. BOTH nodes of the pair carry the same key. Distinct from LocalDb:Replication:ApiKey, which authenticates the pair partner, not central — never share the two.", "_grpcPsk": "REQUIRED on Site nodes (StartupValidator fails the boot without it). The preshared key the gRPC control plane authenticates with: ControlPlaneAuthInterceptor is fail-closed, so an unset key refuses every SiteStream call — live subscriptions, audit pulls, cached-telemetry ingest — while the node still reports healthy. Supply it as ${secret:SB-GRPC-PSK-<siteId>} so the plaintext never sits in this file, and seed the SAME value on central: either as the secret SB-GRPC-PSK-<siteId> in its store, or as ScadaBridge:Communication:SitePsks:<siteId>. BOTH nodes of the pair carry the same key. Distinct from LocalDb:Replication:ApiKey, which authenticates the pair partner, not central — never share the two.",
"GrpcPsk": "${secret:SB-GRPC-PSK-site-1}", "GrpcPsk": "${secret:SB-GRPC-PSK-site-1}",
"_centralContactPoints": "Host-016: each entry MUST be a central node's remoting endpoint, NOT this site's own remoting port. The single dev-loopback default below points only at central-a (localhost:8081). In a multi-central deployment add the second central node here (e.g. 'akka.tcp://scadabridge@central-b-host:8081') so ClusterClient can fail over when central-a is down. The previous template listed localhost:8082 as the second contact — that is THIS site's own RemotingPort and is a permanent failure in the initial-contact rotation.", "_centralGrpcEndpoints": "gRPC (CentralControlService) is the only site→central transport since the ClusterClient→gRPC migration's Phase 4. Each entry MUST be a central node's gRPC (h2c) endpoint on its CentralGrpcPort (default 8083) — NOT this site's own gRPC port, and NOT via Traefik (HTTP/1 only). The single dev-loopback default below points only at central-a (localhost:8083). In a multi-central deployment add the second central node here (e.g. 'http://central-b-host:8083') so the channel pair can fail over when central-a is down. StartupValidator requires a Site node to list at least one endpoint.",
"CentralContactPoints": [ "CentralGrpcEndpoints": [
"akka.tcp://scadabridge@localhost:8081" "http://localhost:8083"
], ],
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
@@ -1272,265 +1272,96 @@ public sealed class BundleImporter : IBundleImporter
} }
var bundleImportId = Guid.NewGuid(); var bundleImportId = Guid.NewGuid();
var resolutionMap = resolutions.ToDictionary(
r => (r.EntityType, r.Name),
r => r);
var summary = new ImportSummary();
// Inbound API keys are not transported between environments.
// A legacy bundle may still contain a keys section; we ignore those keys
// entirely (never re-create them) but count them so the result can tell
// the operator to re-issue keys on this environment.
var apiKeysIgnored = content.ApiKeys?.Count ?? 0;
// Set the correlation BEFORE the transaction so any audit writes // Set the correlation BEFORE the transaction so any audit writes
// triggered during the apply pick up the BundleImportId — AuditService // triggered during the apply pick up the BundleImportId — AuditService
// reads the scoped context at the moment LogAsync is called. // reads the scoped context at the moment LogAsync is called.
_correlationContext.BundleImportId = bundleImportId; _correlationContext.BundleImportId = bundleImportId;
// BeginTransactionAsync is a no-op on the in-memory EF provider (which // The central ConfigurationDb context is configured with
// logs an InMemoryEventId.TransactionIgnoredWarning by default). To keep // EnableRetryOnFailure (ServiceCollectionExtensions), and
// rollback semantics testable on in-memory AND correct on relational // SqlServerRetryingExecutionStrategy REJECTS a user-initiated
// providers, we defer the SINGLE SaveChangesAsync call until just before // transaction opened outside the strategy ("The configured execution
// CommitAsync — every Add*Async + LogAsync call only stages on the // strategy 'SqlServerRetryingExecutionStrategy' does not support
// change tracker, so throwing before SaveChangesAsync naturally undoes // user-initiated transactions."), so opening BeginTransactionAsync
// the entire apply on both providers. // directly here throws on any real SQL Server. Run the whole
await using var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false); // BeginTransaction -> apply -> Commit unit INSIDE the execution strategy
// so the strategy owns it and re-executes it as one retriable unit on a
// transient fault. On the in-memory provider CreateExecutionStrategy()
// returns a non-retrying strategy, so this stays a single straight-
// through call there (behaviour unchanged).
//
// Retry idempotency: the strategy re-runs the WHOLE delegate on a
// transient fault, so the change tracker is cleared at the top of the
// delegate (adds staged by the failed attempt, whose transaction has
// already rolled back, must not leak into the retry) and ApplyMergeAsync
// rebuilds its own summary/resolution accumulators per call — otherwise
// a retry would double-apply. A rollback failure captured inside the
// delegate is surfaced to the outer catch via rollbackFailureMessage so
// the BundleImportFailed row still records both faults.
var strategy = _dbContext.Database.CreateExecutionStrategy();
string? rollbackFailureMessage = null;
ImportResult result;
try try
{ {
// Run semantic validation FIRST — before any writes are staged. result = await strategy.ExecuteAsync(async () =>
// This is purely a name-resolution scan over the in-memory DTOs +
// pre-existing target reads, so it has no ordering dependency on
// the Apply* helpers. Failing here means the change tracker is
// still empty, which keeps the rollback contract simple on both
// the in-memory and relational providers (intermediate
// SaveChangesAsync between Apply* and the second-pass rewire
// would otherwise prevent the in-memory provider from undoing
// already-flushed template rows on validation failure — the
// in-memory transaction is a no-op, so the only safe pattern is
// ONE deferred SaveChangesAsync at the very end of try-block).
//
// Skip-resolved DTOs are excluded from the in-bundle name set so
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
var (validationErrors, validationWarnings) =
await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
// Validate every site / connection / template reference the
// site-instance payload depends on BEFORE any row is staged. Running
// this in the validation phase (not as an apply-pass guard) preserves
// the rollback contract: a structurally-unresolvable bundle fails with
// an empty change tracker, so nothing is half-written on the in-memory
// provider (where the intermediate site/connection flush can't be
// undone by ChangeTracker.Clear). The apply passes keep defensive
// guards, but in normal operation those never fire.
validationErrors = validationErrors.Count > 0
? validationErrors
: await ValidateSiteInstanceReferencesAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false);
if (validationErrors.Count > 0)
{ {
throw new SemanticValidationException(validationErrors); // Reset per-attempt state so a retried delegate re-runs clean.
} _dbContext.ChangeTracker.Clear();
rollbackFailureMessage = null;
// Advisory template-script reference findings never block the import; // BeginTransactionAsync is a no-op on the in-memory EF provider
// log them and surface them on the ImportResult so the operator sees // (which logs an InMemoryEventId.TransactionIgnoredWarning by
// the same advisory the preview showed. The deploy-time gate is the // default). To keep rollback semantics testable on in-memory AND
// authoritative re-validation. // correct on relational providers, ApplyMergeAsync defers the
if (validationWarnings.Count > 0) // SINGLE final SaveChangesAsync until the very end — every
{ // Add*Async + LogAsync call only stages on the change tracker,
_logger?.LogWarning( // so throwing before that naturally undoes the whole apply on
"Bundle import {BundleImportId}: {Count} advisory import warning(s): {Warnings}", // both providers.
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings)); var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
} try
// ---- Site/instance-scoped apply: sites + connections FIRST ----
// Sites are the FK target for both data connections (SiteId) and
// instances (SiteId); data connections are the FK target for instance
// connection bindings (DataConnectionId) and the rewrite target for
// native-alarm-source ConnectionNameOverride. Resolve-or-create both
// BEFORE the central-config apply so the maps the instance pass needs
// are fully populated, then flush so newly-created site/connection ids
// materialise on the relational provider. (The instance pass itself
// runs AFTER the central-config flush so it can also resolve template
// ids by name.)
var siteBySourceIdentifier = await ApplySitesAsync(
content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
var connectionMaps = await ApplyDataConnectionsAsync(
content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Flush so site + connection surrogate ids are assigned (relational
// provider) before the instance pass wires its FKs. In-memory assigns
// ids on AddAsync, so this is mostly a no-op there, but it keeps the
// ordering correct on a real DB. Rides the same outer transaction.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyExternalSystemsAsync(content.ExternalSystems, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmsConfigsAsync(content.SmsConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Inbound API keys are NOT applied from a bundle — any keys
// in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Second-pass rewire of name-keyed
// FKs that can only be resolved AFTER every template's scripts and
// child rows have been staged. We flush here so Pass A can look up
// the just-persisted scripts by name and Pass B can look up the
// just-persisted templates by name; both passes stage further
// mutations that ride the SAME outer transaction (committed below).
//
// EF tracking note: AddAsync on the in-memory provider assigns
// synthetic ids eagerly, so this intermediate flush mostly
// materialises identity values on a relational provider. The
// rollback contract is preserved because semantic validation
// already ran above — any throw from this point onward represents
// a successful merge that the user wants to keep, and the only
// remaining failure surface is the final BundleImported audit
// write itself.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
// Folder parent edges are name-keyed too — resolve them AFTER the flush
// above so every imported folder has a materialised id to point at
// (the Add pass created the folders parent-less). Mirrors the template
// inheritance/composition rewires.
await ResolveFolderParentEdgesAsync(content.TemplateFolders, resolutionMap, user, ct).ConfigureAwait(false);
// External-system methods have no navigation on the parent definition,
// so an Add can't cascade them — persist them here, once the parent
// system has a real id from the flush above. (Overwrite already syncs
// its methods inline against the existing parent id.)
await PersistAddedExternalSystemMethodsAsync(content.ExternalSystems, resolutionMap, user, ct).ConfigureAwait(false);
// ---- Import-time acyclicity guard over the merged template graph ----
// The two rewire passes above have STAGED (not yet saved) the bundle's
// inheritance (ParentTemplateId) and composition edges onto the tracked
// target templates. A bundle can still close a loop through a pre-existing
// target template — e.g. Overwrite a base so it now inherits from (or
// composes) one of its own descendants. The design-time save-gate enforces
// acyclicity on individual edits, but an import merges many edges at once,
// so re-check the whole merged graph here. On a cycle we throw and the
// catch below rolls the whole import back (ChangeTracker.Clear on the
// in-memory provider) BEFORE any of it is persisted; deliberately no
// SaveChanges precedes this check so nothing survives that rollback.
EnsureNoTemplateGraphCycles();
// ---- Site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
// • template id — resolved by name against the just-flushed central
// config (Templates were flushed above for the alarm/composition
// rewire), plus pre-existing target templates;
// • site id — from the site map built by ApplySitesAsync;
// • connection id — from the connection map built by
// ApplyDataConnectionsAsync.
// It writes the instance row + its four child collections (attribute /
// alarm / native-alarm-source overrides + connection bindings) and
// rewires every name-keyed FK to the target environment's ids.
await ApplyInstancesAsync(
content, resolutionMap, siteBySourceIdentifier, connectionMaps,
user, summary, ct).ConfigureAwait(false);
// ---- Pre-commit point: stale-instance computation ----
// Everything the import will write is staged on the change tracker at
// this point but NOT yet committed. This computes StaleInstanceIds here
// — target instances whose template was overwritten by this import and
// therefore now carry a stale flattened-config revision — and threads
// the resulting list into the ImportResult below (replacing the
// Array.Empty<int>() placeholder). The single deferred SaveChangesAsync
// is the next statement, so a read against the change tracker here sees
// the full post-apply graph before commit.
var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
content, resolutionMap, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user: user,
action: "BundleImported",
entityType: "Bundle",
entityId: bundleImportId.ToString(),
entityName: session.Manifest.SourceEnvironment,
afterState: new
{ {
BundleImportId = bundleImportId, // ApplyMergeAsync stages the whole merge and issues the
session.Manifest.SourceEnvironment, // single deferred final SaveChangesAsync itself; here we only
session.Manifest.ContentHash, // commit the transaction it ran under.
Summary = new var applied = await ApplyMergeAsync(
content, resolutions, nameMap, session, bundleImportId, user, ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
await tx.DisposeAsync().ConfigureAwait(false);
return applied;
}
catch
{
// Roll back explicitly (rather than leaning on Dispose) so a
// rollback that itself throws does not mask the ORIGINAL
// exception — capture it and let the original propagate to
// the strategy, which decides whether to retry.
try
{ {
summary.Added, await tx.RollbackAsync(ct).ConfigureAwait(false);
summary.Overwritten, }
summary.Skipped, catch (Exception rbEx)
summary.Renamed, {
}, rollbackFailureMessage = rbEx.Message;
// Legacy inbound API keys present in the bundle that }
// were ignored (not transported / not re-created here). try { await tx.DisposeAsync().ConfigureAwait(false); }
ApiKeysIgnored = apiKeysIgnored, catch { /* dispose-after-throw must not mask the original cause */ }
},
cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); // Clear the change tracker — on the in-memory provider the
await tx.CommitAsync(ct).ConfigureAwait(false); // rollback is a no-op and the staged adds would otherwise
// persist on the next SaveChangesAsync (a retry or the
PublishScriptArtifactChanges(resolutions); // failure-row write below).
_dbContext.ChangeTracker.Clear();
// T-007: zero out the decrypted plaintext BEFORE remove so any throw;
// caller-held reference (e.g. the Razor page that built the }
// ImportPreview) sees the cleared buffer too. Remove drops the }).ConfigureAwait(false);
// dictionary entry; together they release the secrets immediately
// instead of leaving them in process memory for the full TTL.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
return new ImportResult(
BundleImportId: bundleImportId,
Added: summary.Added,
Overwritten: summary.Overwritten,
Skipped: summary.Skipped,
Renamed: summary.Renamed,
StaleInstanceIds: staleInstanceIds,
AuditEventCorrelation: bundleImportId.ToString(),
ApiKeysIgnored: apiKeysIgnored,
Warnings: validationWarnings);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Rollback can itself throw (connection drop mid-rollback, provider // The strategy exhausted its retries (or threw a non-transient
// bug, etc). If it does, we must STILL write the BundleImportFailed // error). The failed attempt already rolled back and cleared the
// audit row — otherwise a rollback-failure path silently swallows // change tracker inside the delegate, so all that remains here is
// the import's audit trail. Capture the rollback exception (if any) // the top-level BundleImportFailed audit row.
// and surface it on the failure row alongside the original cause. //
Exception? rollbackFailure = null;
try
{
await tx.RollbackAsync(ct).ConfigureAwait(false);
}
catch (Exception rbEx)
{
rollbackFailure = rbEx;
}
// If rollback threw the IDbContextTransaction is in an indeterminate
// state and still associated with the DbContext — a subsequent
// SaveChangesAsync would attempt to enlist in (or commit to) that
// broken transaction, and the failure-row would itself be rolled
// back when the transaction is finally disposed. Dispose it now so
// the audit-row write below uses a fresh implicit transaction. On
// the happy rollback path Dispose is a benign no-op (the using
// would call it on scope exit anyway).
if (rollbackFailure is not null)
{
try { await tx.DisposeAsync().ConfigureAwait(false); }
catch { /* dispose-after-throw must not mask the original cause */ }
}
// Clear the change tracker before writing the failure row — on the
// in-memory provider the rollback is a no-op and the staged adds
// would otherwise persist when the next SaveChangesAsync runs. This
// also matters when rollback threw: the change tracker is in an
// ambiguous state and we don't want the failure-write to sweep up
// any of the staged apply mutations.
_dbContext.ChangeTracker.Clear();
// Clear correlation FIRST so the failure row doesn't carry the now- // Clear correlation FIRST so the failure row doesn't carry the now-
// rolled-back BundleImportId. The contract is: BundleImportFailed // rolled-back BundleImportId. The contract is: BundleImportFailed
// exists at top level (no correlation) so audit consumers can see // exists at top level (no correlation) so audit consumers can see
@@ -1549,7 +1380,7 @@ public sealed class BundleImporter : IBundleImporter
BundleImportId = bundleImportId, BundleImportId = bundleImportId,
Reason = ex.Message, Reason = ex.Message,
ExceptionType = ex.GetType().FullName, ExceptionType = ex.GetType().FullName,
RollbackException = rollbackFailure?.Message, RollbackException = rollbackFailureMessage,
}, },
cancellationToken: ct).ConfigureAwait(false); cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
@@ -1580,6 +1411,236 @@ public sealed class BundleImporter : IBundleImporter
// not inherit the import id. // not inherit the import id.
_correlationContext.BundleImportId = null; _correlationContext.BundleImportId = null;
} }
// Post-commit side effects — run ONCE, only after a durable commit, so a
// retried attempt neither publishes twice nor releases the session early.
PublishScriptArtifactChanges(resolutions);
// T-007: zero out the decrypted plaintext BEFORE remove so any
// caller-held reference (e.g. the Razor page that built the
// ImportPreview) sees the cleared buffer too. Remove drops the
// dictionary entry; together they release the secrets immediately
// instead of leaving them in process memory for the full TTL.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
return result;
}
/// <summary>
/// Applies one bundle's merge inside the caller's transaction: semantic
/// validation, every Apply* pass, the name-keyed FK rewires, the stale-
/// instance computation and the BundleImported audit row, ending with the
/// deferred final SaveChangesAsync staged by the caller. Does NOT begin or
/// commit the transaction — <see cref="ApplyAsync"/> owns it so the whole
/// unit can run inside the context's execution strategy (EnableRetryOnFailure
/// rejects user-initiated transactions opened outside the strategy). Safe to
/// re-run: the caller clears the change tracker before each attempt, and
/// every accumulator here (summary counts, resolution map) is rebuilt per
/// call.
/// </summary>
private async Task<ImportResult> ApplyMergeAsync(
BundleContentDto content,
IReadOnlyList<ImportResolution> resolutions,
BundleNameMap nameMap,
BundleSession session,
Guid bundleImportId,
string user,
CancellationToken ct)
{
var resolutionMap = resolutions.ToDictionary(
r => (r.EntityType, r.Name),
r => r);
var summary = new ImportSummary();
// Inbound API keys are not transported between environments.
// A legacy bundle may still contain a keys section; we ignore those keys
// entirely (never re-create them) but count them so the result can tell
// the operator to re-issue keys on this environment.
var apiKeysIgnored = content.ApiKeys?.Count ?? 0;
// Run semantic validation FIRST — before any writes are staged.
// This is purely a name-resolution scan over the in-memory DTOs +
// pre-existing target reads, so it has no ordering dependency on
// the Apply* helpers. Failing here means the change tracker is
// still empty, which keeps the rollback contract simple on both
// the in-memory and relational providers (intermediate
// SaveChangesAsync between Apply* and the second-pass rewire
// would otherwise prevent the in-memory provider from undoing
// already-flushed template rows on validation failure — the
// in-memory transaction is a no-op, so the only safe pattern is
// ONE deferred SaveChangesAsync at the very end of try-block).
//
// Skip-resolved DTOs are excluded from the in-bundle name set so
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
var (validationErrors, validationWarnings) =
await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
// Validate every site / connection / template reference the
// site-instance payload depends on BEFORE any row is staged. Running
// this in the validation phase (not as an apply-pass guard) preserves
// the rollback contract: a structurally-unresolvable bundle fails with
// an empty change tracker, so nothing is half-written on the in-memory
// provider (where the intermediate site/connection flush can't be
// undone by ChangeTracker.Clear). The apply passes keep defensive
// guards, but in normal operation those never fire.
validationErrors = validationErrors.Count > 0
? validationErrors
: await ValidateSiteInstanceReferencesAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false);
if (validationErrors.Count > 0)
{
throw new SemanticValidationException(validationErrors);
}
// Advisory template-script reference findings never block the import;
// log them and surface them on the ImportResult so the operator sees
// the same advisory the preview showed. The deploy-time gate is the
// authoritative re-validation.
if (validationWarnings.Count > 0)
{
_logger?.LogWarning(
"Bundle import {BundleImportId}: {Count} advisory import warning(s): {Warnings}",
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
}
// ---- Site/instance-scoped apply: sites + connections FIRST ----
// Sites are the FK target for both data connections (SiteId) and
// instances (SiteId); data connections are the FK target for instance
// connection bindings (DataConnectionId) and the rewrite target for
// native-alarm-source ConnectionNameOverride. Resolve-or-create both
// BEFORE the central-config apply so the maps the instance pass needs
// are fully populated, then flush so newly-created site/connection ids
// materialise on the relational provider. (The instance pass itself
// runs AFTER the central-config flush so it can also resolve template
// ids by name.)
var siteBySourceIdentifier = await ApplySitesAsync(
content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
var connectionMaps = await ApplyDataConnectionsAsync(
content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Flush so site + connection surrogate ids are assigned (relational
// provider) before the instance pass wires its FKs. In-memory assigns
// ids on AddAsync, so this is mostly a no-op there, but it keeps the
// ordering correct on a real DB. Rides the same outer transaction.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyExternalSystemsAsync(content.ExternalSystems, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmsConfigsAsync(content.SmsConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Inbound API keys are NOT applied from a bundle — any keys
// in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Second-pass rewire of name-keyed
// FKs that can only be resolved AFTER every template's scripts and
// child rows have been staged. We flush here so Pass A can look up
// the just-persisted scripts by name and Pass B can look up the
// just-persisted templates by name; both passes stage further
// mutations that ride the SAME outer transaction (committed below).
//
// EF tracking note: AddAsync on the in-memory provider assigns
// synthetic ids eagerly, so this intermediate flush mostly
// materialises identity values on a relational provider. The
// rollback contract is preserved because semantic validation
// already ran above — any throw from this point onward represents
// a successful merge that the user wants to keep, and the only
// remaining failure surface is the final BundleImported audit
// write itself.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
// Folder parent edges are name-keyed too — resolve them AFTER the flush
// above so every imported folder has a materialised id to point at
// (the Add pass created the folders parent-less). Mirrors the template
// inheritance/composition rewires.
await ResolveFolderParentEdgesAsync(content.TemplateFolders, resolutionMap, user, ct).ConfigureAwait(false);
// External-system methods have no navigation on the parent definition,
// so an Add can't cascade them — persist them here, once the parent
// system has a real id from the flush above. (Overwrite already syncs
// its methods inline against the existing parent id.)
await PersistAddedExternalSystemMethodsAsync(content.ExternalSystems, resolutionMap, user, ct).ConfigureAwait(false);
// ---- Import-time acyclicity guard over the merged template graph ----
// The two rewire passes above have STAGED (not yet saved) the bundle's
// inheritance (ParentTemplateId) and composition edges onto the tracked
// target templates. A bundle can still close a loop through a pre-existing
// target template — e.g. Overwrite a base so it now inherits from (or
// composes) one of its own descendants. The design-time save-gate enforces
// acyclicity on individual edits, but an import merges many edges at once,
// so re-check the whole merged graph here. On a cycle we throw and the
// catch below rolls the whole import back (ChangeTracker.Clear on the
// in-memory provider) BEFORE any of it is persisted; deliberately no
// SaveChanges precedes this check so nothing survives that rollback.
EnsureNoTemplateGraphCycles();
// ---- Site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
// • template id — resolved by name against the just-flushed central
// config (Templates were flushed above for the alarm/composition
// rewire), plus pre-existing target templates;
// • site id — from the site map built by ApplySitesAsync;
// • connection id — from the connection map built by
// ApplyDataConnectionsAsync.
// It writes the instance row + its four child collections (attribute /
// alarm / native-alarm-source overrides + connection bindings) and
// rewires every name-keyed FK to the target environment's ids.
await ApplyInstancesAsync(
content, resolutionMap, siteBySourceIdentifier, connectionMaps,
user, summary, ct).ConfigureAwait(false);
// ---- Pre-commit point: stale-instance computation ----
// Everything the import will write is staged on the change tracker at
// this point but NOT yet committed. This computes StaleInstanceIds here
// — target instances whose template was overwritten by this import and
// therefore now carry a stale flattened-config revision — and threads
// the resulting list into the ImportResult below (replacing the
// Array.Empty<int>() placeholder). The single deferred SaveChangesAsync
// is the next statement, so a read against the change tracker here sees
// the full post-apply graph before commit.
var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
content, resolutionMap, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user: user,
action: "BundleImported",
entityType: "Bundle",
entityId: bundleImportId.ToString(),
entityName: session.Manifest.SourceEnvironment,
afterState: new
{
BundleImportId = bundleImportId,
session.Manifest.SourceEnvironment,
session.Manifest.ContentHash,
Summary = new
{
summary.Added,
summary.Overwritten,
summary.Skipped,
summary.Renamed,
},
// Legacy inbound API keys present in the bundle that
// were ignored (not transported / not re-created here).
ApiKeysIgnored = apiKeysIgnored,
},
cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
return new ImportResult(
BundleImportId: bundleImportId,
Added: summary.Added,
Overwritten: summary.Overwritten,
Skipped: summary.Skipped,
Renamed: summary.Renamed,
StaleInstanceIds: staleInstanceIds,
AuditEventCorrelation: bundleImportId.ToString(),
ApiKeysIgnored: apiKeysIgnored,
Warnings: validationWarnings);
} }
/// <summary> /// <summary>
@@ -24,7 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
/// </list> /// </list>
/// ///
/// <para> /// <para>
/// Idempotency: the SMS-config fact uses a STABLE Account SID (<c>ACtest123</c>) and probes /// Idempotency: the SMS-config fact uses a STABLE Account SID (<c>AC000…0001</c>) and probes
/// <c>notification sms list</c> first — if a prior run already created it, the fact SKIPS the /// <c>notification sms list</c> first — if a prior run already created it, the fact SKIPS the
/// create and verifies the existing row instead (the page exposes no delete verb — neither UI /// create and verifies the existing row instead (the page exposes no delete verb — neither UI
/// nor CLI — so we never create a second copy we could not clean up; the render-crash + /// nor CLI — so we never create a second copy we could not clean up; the render-crash +
@@ -41,7 +41,9 @@ public class SmsNotificationE2ETests
// Stable test fixture values. The Account SID is stable (not random) so reruns find the // Stable test fixture values. The Account SID is stable (not random) so reruns find the
// prior config via 'notification sms list' and skip re-creation — the SMS config page has // prior config via 'notification sms list' and skip re-creation — the SMS config page has
// no delete verb, so a unique-per-run SID would leak a config on every run. // no delete verb, so a unique-per-run SID would leak a config on every run.
private const string TestAccountSid = "ACtest123"; // Must match the ManagementActor create guard ^AC[0-9a-fA-F]{32}$ (added 2026-07-10) or
// the create is rejected and the config-card / secret-non-leak assertions never run.
private const string TestAccountSid = "AC00000000000000000000000000000001";
private const string TestFromNumber = "+15551230000"; private const string TestFromNumber = "+15551230000";
// The Auth Token VALUE that must NEVER be echoed back to the page (presence flag only). // The Auth Token VALUE that must NEVER be echoed back to the page (presence flag only).
private const string TestAuthTokenValue = "e2e-secret-token-xyz"; private const string TestAuthTokenValue = "e2e-secret-token-xyz";
@@ -0,0 +1,147 @@
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests;
/// <summary>
/// Unit tests for <see cref="ClusterBootstrapGuard"/> — the pure decision core of the
/// simultaneous-cold-start split-brain guard (Gitea #33). The runtime probe + JoinSeedNodes wiring
/// lives in <c>ClusterBootstrapCoordinator</c> and is covered by the real-ActorSystem
/// <c>ClusterBootstrapCoordinatorTests</c> in the integration suite.
/// </summary>
public class ClusterBootstrapGuardTests
{
private const string A = "akka.tcp://scadabridge@scadabridge-site-a-a:8082";
private const string B = "akka.tcp://scadabridge@scadabridge-site-a-b:8082";
[Fact]
public void Lower_address_node_is_the_founder_and_needs_no_probe()
{
// scadabridge-site-a-a < scadabridge-site-a-b, so on node-a the guard makes it the founder.
var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "scadabridge-site-a-a", 8082);
Assert.True(role.Applies);
Assert.True(role.IsFounder);
Assert.Equal(new[] { A, B }, role.SelfFirstOrder);
}
[Fact]
public void Higher_address_node_is_not_the_founder_and_targets_the_partner_for_probing()
{
// On node-b the partner is node-a (the lower/founder).
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "scadabridge-site-a-b", 8082);
Assert.True(role.Applies);
Assert.False(role.IsFounder);
Assert.Equal("scadabridge-site-a-a", role.PartnerHost);
Assert.Equal(8082, role.PartnerPort);
}
[Fact]
public void Tie_break_is_symmetric_regardless_of_seed_order()
{
// Both nodes must agree on who founds no matter how each lists its seeds.
var onLower = ClusterBootstrapGuard.Analyze(new[] { A, B }, "scadabridge-site-a-a", 8082);
var onHigher = ClusterBootstrapGuard.Analyze(new[] { B, A }, "scadabridge-site-a-b", 8082);
Assert.True(onLower.IsFounder);
Assert.False(onHigher.IsFounder);
// Exactly one founder.
Assert.True(onLower.IsFounder ^ onHigher.IsFounder);
}
[Fact]
public void Tie_break_is_case_insensitive_so_a_casing_mismatch_cannot_make_both_founders()
{
// The two sides' seed config differ only in host casing (review note 1): if the tie-break
// were case-sensitive, both could conclude they are the founder and re-open the split.
var lowerSelf = "akka.tcp://scadabridge@SITE-A-A:8082";
var higherSelf = "akka.tcp://scadabridge@site-a-b:8082";
var onLower = ClusterBootstrapGuard.Analyze(new[] { lowerSelf, higherSelf }, "SITE-A-A", 8082);
var onHigher = ClusterBootstrapGuard.Analyze(new[] { higherSelf, lowerSelf }, "site-a-b", 8082);
Assert.True(onLower.IsFounder ^ onHigher.IsFounder);
Assert.True(onLower.IsFounder); // "site-a-a" < "site-a-b" ordinal-ignore-case
}
[Fact]
public void Higher_node_joins_the_founder_when_partner_is_reachable()
{
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "scadabridge-site-a-b", 8082);
// Reachable ⇒ peer-first ⇒ JoinSeedNodeProcess ⇒ joins the founder, never self-forms.
Assert.Equal(new[] { A, B }, ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: true)); // partner (node-a) first
}
[Fact]
public void Higher_node_forms_alone_when_partner_is_unreachable()
{
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "scadabridge-site-a-b", 8082);
// Unreachable after the window ⇒ partner is dead ⇒ self-first ⇒ cold-start-alone preserved.
Assert.Equal(new[] { B, A }, ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: false)); // self (node-b) first
}
[Fact]
public void Guard_does_not_apply_to_a_single_seed_node()
{
// A single-node install lists exactly one seed (itself) — not a pair, guard is inert.
var role = ClusterBootstrapGuard.Analyze(new[] { "akka.tcp://scadabridge@lone:8081" }, "lone", 8081);
Assert.False(role.Applies);
}
[Fact]
public void Guard_does_not_apply_when_self_is_absent_from_the_two_seeds()
{
var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "scadabridge-central-a", 8081);
Assert.False(role.Applies);
}
[Fact]
public void Guard_does_not_apply_to_three_or_more_seeds()
{
var role = ClusterBootstrapGuard.Analyze(
new[] { A, B, "akka.tcp://scadabridge@scadabridge-site-a-c:8082" }, "scadabridge-site-a-a", 8082);
Assert.False(role.Applies);
}
[Fact]
public void Guard_does_not_apply_when_a_seed_is_malformed()
{
var role = ClusterBootstrapGuard.Analyze(new[] { A, "not-a-seed" }, "scadabridge-site-a-a", 8082);
Assert.False(role.Applies);
}
[Theory]
[InlineData("akka.tcp://scadabridge@scadabridge-site-a-a:8082", "scadabridge-site-a-a", 8082)]
[InlineData("akka.tcp://scadabridge@10.0.0.5:8082/", "10.0.0.5", 8082)]
[InlineData(" akka.tcp://scadabridge@host.lan:1234 ", "host.lan", 1234)]
public void TryParse_extracts_host_and_port(string seed, string expectedHost, int expectedPort)
{
Assert.True(ClusterBootstrapGuard.TryParse(seed, out var host, out var port));
Assert.Equal(expectedHost, host);
Assert.Equal(expectedPort, port);
}
[Theory]
[InlineData("")]
[InlineData("garbage")]
[InlineData("akka.tcp://scadabridge@host")] // no port
public void TryParse_rejects_malformed_seeds(string seed)
{
Assert.False(ClusterBootstrapGuard.TryParse(seed, out _, out _));
}
[Fact]
public void Port_participates_in_the_tie_break_when_hosts_are_equal()
{
// Shared-host loopback pair (test rig): same host, different ports — port breaks the tie.
const string low = "akka.tcp://scadabridge@127.0.0.1:8081";
const string high = "akka.tcp://scadabridge@127.0.0.1:8082";
Assert.True(ClusterBootstrapGuard.Analyze(new[] { low, high }, "127.0.0.1", 8081).IsFounder);
Assert.False(ClusterBootstrapGuard.Analyze(new[] { high, low }, "127.0.0.1", 8082).IsFounder);
}
}
@@ -215,4 +215,68 @@ public class ClusterOptionsValidatorTests
Assert.Contains("StableAfter", result.FailureMessage); Assert.Contains("StableAfter", result.FailureMessage);
Assert.Contains("HeartbeatInterval", result.FailureMessage); Assert.Contains("HeartbeatInterval", result.FailureMessage);
} }
// ---- BootstrapGuard timing validation (Gitea #33) ----
[Fact]
public void BootstrapGuard_disabled_with_zero_timings_passes_validation()
{
// The knobs are inert when the guard is off, so a disabled guard must never block a boot —
// even if the timing values are nonsense (0 here).
var options = ValidOptions();
options.BootstrapGuard = new ClusterBootstrapGuardOptions
{
Enabled = false,
PartnerProbeSeconds = 0,
PartnerProbeIntervalMs = 0,
ProbeConnectTimeoutMs = 0,
};
var result = new ClusterOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void BootstrapGuard_enabled_with_default_timings_passes_validation()
{
var options = ValidOptions();
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true };
var result = new ClusterOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void BootstrapGuard_enabled_with_nonpositive_probe_seconds_fails_validation()
{
// A zero PartnerProbeSeconds silently degrades the guard to "never wait, always conclude the
// partner is dead" — the higher node forms alone immediately and re-opens the split. Reject it.
var options = ValidOptions();
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true, PartnerProbeSeconds = 0 };
var result = new ClusterOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains("PartnerProbeSeconds", result.FailureMessage);
}
[Fact]
public void BootstrapGuard_enabled_with_nonpositive_interval_or_timeout_fails_validation()
{
var options = ValidOptions();
options.BootstrapGuard = new ClusterBootstrapGuardOptions
{
Enabled = true,
PartnerProbeIntervalMs = 0,
ProbeConnectTimeoutMs = -1,
};
var result = new ClusterOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains("PartnerProbeIntervalMs", result.FailureMessage);
Assert.Contains("ProbeConnectTimeoutMs", result.FailureMessage);
}
} }
@@ -0,0 +1,27 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
public class OpcUaReferenceFormTests
{
[Theory]
// durable → true
[InlineData("nsu=https://zb.com/otopcua/raw;s=Line1.Pump.Speed", true)]
[InlineData("nsu=https://zb.com/otopcua/uns;s=AreaA/Line1/Pump/Speed", true)]
[InlineData("NSU=https://x;s=y", true)] // case-insensitive prefix
[InlineData("ns=0;i=85", true)] // spec-fixed namespace 0
[InlineData("i=85", true)] // short form, implicit ns0
[InlineData("s=Devices.Pump1", true)] // no namespace prefix
[InlineData("", true)] // nothing typed
[InlineData(" ", true)]
[InlineData(null, true)]
// bare server-namespace index → false (warn)
[InlineData("ns=2;s=MyDevice.Temperature", false)]
[InlineData("ns=1;i=1001", false)]
[InlineData("ns=10;s=x", false)]
[InlineData(" ns=3;s=x ", false)] // trimmed before inspection
public void IsDurable_classifies_reference_forms(string? reference, bool expected)
{
Assert.Equal(expected, OpcUaReferenceForm.IsDurable(reference));
}
}
@@ -1,67 +0,0 @@
using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Actors;
/// <summary>
/// T1A.3: the regression-prone bit of the Akka transport — that a forwarded
/// <see cref="ClusterClient.Send"/> carries the caller's sender, so central's reply routes
/// straight back to the waiting Ask rather than to the site communication actor — plus the
/// no-ClusterClient-yet fallback that keeps the S&amp;F layer treating the send as transient.
/// </summary>
public class AkkaCentralTransportTests : TestKit
{
[Fact]
public void SubmitNotification_ForwardsToClusterClient_WithReplyToAsSender()
{
var transport = new AkkaCentralTransport();
var clusterClient = CreateTestProbe();
var replyTo = CreateTestProbe();
transport.SetCentralClient(clusterClient.Ref);
var submit = new NotificationSubmit(
"notif-1", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow);
transport.SubmitNotification(submit, replyTo.Ref);
// The ClusterClient receives a Send addressed to the central actor...
var send = clusterClient.ExpectMsg<ClusterClient.Send>();
Assert.Equal("/user/central-communication", send.Path);
Assert.IsType<NotificationSubmit>(send.Message);
// ...and replying to it lands at replyTo, proving the sender was forwarded (not the
// transport / actor). This is the routing the waiting Ask relies on.
clusterClient.Reply(new NotificationSubmitAck("notif-1", Accepted: true, Error: null));
replyTo.ExpectMsg<NotificationSubmitAck>(ack => ack.NotificationId == "notif-1" && ack.Accepted);
}
[Fact]
public void SubmitNotification_WithNoClusterClient_RepliesNonAcceptedToReplyTo()
{
var transport = new AkkaCentralTransport();
var replyTo = CreateTestProbe();
transport.SubmitNotification(
new NotificationSubmit("notif-2", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow),
replyTo.Ref);
replyTo.ExpectMsg<NotificationSubmitAck>(ack => ack.NotificationId == "notif-2" && !ack.Accepted);
}
[Fact]
public void IngestAuditEvents_WithNoClusterClient_FaultsTheReplyTo()
{
// The audit drain treats a faulted Ask as transient and keeps rows Pending — so the
// no-client path must be a Status.Failure, not a silent drop.
var transport = new AkkaCentralTransport();
var replyTo = CreateTestProbe();
transport.IngestAuditEvents(
new Commons.Messages.Audit.IngestAuditEventsCommand(new List<ZB.MOM.WW.Audit.AuditEvent>()),
replyTo.Ref);
replyTo.ExpectMsg<Status.Failure>();
}
}
@@ -36,9 +36,9 @@ public class CentralCommunicationActorAuditTests : TestKit
services.AddScoped(_ => mockRepo); services.AddScoped(_ => mockRepo);
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
var mockFactory = Substitute.For<ISiteClientFactory>(); var transport = Substitute.For<ISiteCommandTransport>();
return Sys.ActorOf(Props.Create(() => return Sys.ActorOf(Props.Create(() =>
new CentralCommunicationActor(sp, mockFactory, auditIngestAskTimeout))); new CentralCommunicationActor(sp, transport, auditIngestAskTimeout)));
} }
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory. // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
@@ -1,47 +1,25 @@
using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.Configuration;
using Akka.TestKit.Xunit2; using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
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;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Actors; using ZB.MOM.WW.ScadaBridge.Communication.Actors;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Transport-agnostic refresh behaviour of <see cref="CentralCommunicationActor"/>: the periodic
/// DB refresh evicts deleted sites from the health aggregator. The two former tests here —
/// per-site ClusterClient recreate-without-restart-loop and factory-throw resilience — asserted
/// Akka <c>AkkaSiteTransport</c> internals (InvalidActorNameException, "No ClusterClient for site")
/// and were removed with that transport in the ClusterClient→gRPC migration's Phase 4; the
/// equivalent gRPC channel reconcile is covered by the <c>GrpcSiteTransport</c> suites.
/// </summary>
public class CentralCommunicationActorClientLifecycleTests : TestKit public class CentralCommunicationActorClientLifecycleTests : TestKit
{ {
private static readonly Config TestConfig = ConfigurationFactory.ParseString(@"
akka.actor.provider = cluster
akka.remote.dot-netty.tcp.port = 0
akka.remote.dot-netty.tcp.hostname = localhost")
.WithFallback(ClusterClientReceptionist.DefaultConfig());
public CentralCommunicationActorClientLifecycleTests() : base(TestConfig) { }
// Empty ServiceProvider with a no-op ISiteRepository so the actor's periodic
// db-refresh (fired at PreStart) resolves and returns no sites, keeping the
// logs clean; the tests drive SiteAddressCacheLoaded directly.
private static IServiceProvider EmptyProvider()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>());
var services = new ServiceCollection();
services.AddScoped(_ => siteRepo);
return services.BuildServiceProvider();
}
private static SiteAddressCacheLoaded Load(string siteId, params string[] addrs) =>
new(new Dictionary<string, IReadOnlyList<string>>
{ [siteId] = addrs.ToList().AsReadOnly() },
new[] { siteId },
new Dictionary<string, SiteGrpcEndpoints>());
[Fact] [Fact]
public void PeriodicRefresh_PrunesDeletedSites_FromHealthAggregator() public void PeriodicRefresh_PrunesDeletedSites_FromHealthAggregator()
{ {
@@ -57,8 +35,9 @@ public class CentralCommunicationActorClientLifecycleTests : TestKit
services.AddSingleton(aggregator); services.AddSingleton(aggregator);
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
var transport = Substitute.For<ISiteCommandTransport>();
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor( var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
provider, new DefaultSiteClientFactory(), null))); provider, transport, (TimeSpan?)null)));
// Trigger the refresh (also fires at PreStart, but drive it explicitly so // Trigger the refresh (also fires at PreStart, but drive it explicitly so
// the assertion is deterministic). The load runs on a detached task and // the assertion is deterministic). The load runs on a detached task and
@@ -70,45 +49,4 @@ public class CentralCommunicationActorClientLifecycleTests : TestKit
Arg.Is<IReadOnlyCollection<string>>(ids => ids.Contains("site-a"))), Arg.Is<IReadOnlyCollection<string>>(ids => ids.Contains("site-a"))),
TimeSpan.FromSeconds(3)); TimeSpan.FromSeconds(3));
} }
[Fact]
public void AddressEdit_RecreatesClient_WithoutRestartLoop()
{
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
EmptyProvider(), new DefaultSiteClientFactory(), null)));
// First load creates the client; second load (edited NodeA address) stops
// the old one and creates a replacement in the same message handling.
// Pre-fix this throws InvalidActorNameException and restarts the actor.
EventFilter.Exception<InvalidActorNameException>().Expect(0, () =>
{
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081"));
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a-edited:8081"));
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081")); // and back — third generation
});
// The actor must still be alive and routing (not crash-looping):
// an envelope for an unknown site produces the "No ClusterClient" warning,
// proving the Receive pipeline is healthy.
EventFilter.Warning(contains: "No ClusterClient for site").ExpectOne(() =>
actor.Tell(new SiteEnvelope("unknown-site", new object())));
}
[Fact]
public void FactoryThrow_SkipsSite_DoesNotCrashActor()
{
var throwingFactory = Substitute.For<ISiteClientFactory>();
throwingFactory.Create(Arg.Any<ActorSystem>(), Arg.Any<string>(), Arg.Any<ImmutableHashSet<ActorPath>>())
.Returns(_ => throw new InvalidOperationException("boom"));
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
EmptyProvider(), throwingFactory, null)));
EventFilter.Error(contains: "Failed to create ClusterClient").ExpectOne(() =>
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081")));
// Actor survived — a subsequent envelope for that (unrouted) site still
// produces the healthy "No ClusterClient" warning rather than a dead actor.
EventFilter.Warning(contains: "No ClusterClient for site").ExpectOne(() =>
actor.Tell(new SiteEnvelope("site-a", new object())));
}
} }
@@ -62,8 +62,8 @@ public class CentralCommunicationActorReconcileTests : TestKit
services.AddScoped<ReconcileService>(); services.AddScoped<ReconcileService>();
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
var factory = Substitute.For<ISiteClientFactory>(); var transport = Substitute.For<ISiteCommandTransport>();
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, factory, null))); var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null)));
// Node B is missing inst-B entirely → it should come back as a gap item. // Node B is missing inst-B entirely → it should come back as a gap item.
actor.Tell(new ReconcileSiteRequest( actor.Tell(new ReconcileSiteRequest(
@@ -1,6 +1,4 @@
using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.TestKit.Xunit2; using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NSubstitute; using NSubstitute;
@@ -18,15 +16,19 @@ using Akka.TestKit;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary> /// <summary>
/// Tests for CentralCommunicationActor with per-site ClusterClient routing. /// Tests for <see cref="CentralCommunicationActor"/> behaviour that is independent of the
/// WP-4: Message routing via ClusterClient instances created per site. /// central→site command transport: heartbeat/replica aggregation, notification-outbox proxying,
/// WP-5: Connection failure and failover handling. /// and the DB-refresh failure path. The per-site routing itself (envelope → transport → the right
/// site) is the transport's job and is covered by
/// <see cref="CentralCommunicationActorTransportTests"/> and the <c>GrpcSiteTransport</c> suites;
/// the old ClusterClient-routing tests here were removed with the Akka transport in the
/// ClusterClient→gRPC migration's Phase 4.
/// </summary> /// </summary>
public class CentralCommunicationActorTests : TestKit public class CentralCommunicationActorTests : TestKit
{ {
public CentralCommunicationActorTests() : base(@"akka.loglevel = DEBUG") { } public CentralCommunicationActorTests() : base(@"akka.loglevel = DEBUG") { }
private (IActorRef actor, ISiteRepository mockRepo, Dictionary<string, TestProbe> siteProbes) CreateActorWithMockRepo( private (IActorRef actor, ISiteRepository mockRepo) CreateActorWithMockRepo(
IEnumerable<Site>? sites = null) IEnumerable<Site>? sites = null)
{ {
var mockRepo = Substitute.For<ISiteRepository>(); var mockRepo = Substitute.For<ISiteRepository>();
@@ -37,93 +39,11 @@ public class CentralCommunicationActorTests : TestKit
services.AddScoped(_ => mockRepo); services.AddScoped(_ => mockRepo);
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
var siteProbes = new Dictionary<string, TestProbe>(); var transport = Substitute.For<ISiteCommandTransport>();
var mockFactory = Substitute.For<ISiteClientFactory>(); var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null)));
mockFactory.Create(Arg.Any<ActorSystem>(), Arg.Any<string>(), Arg.Any<ImmutableHashSet<ActorPath>>()) return (actor, mockRepo);
.Returns(callInfo =>
{
var siteId = callInfo.ArgAt<string>(1);
var probe = CreateTestProbe();
siteProbes[siteId] = probe;
return probe.Ref;
});
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, mockFactory)));
return (actor, mockRepo, siteProbes);
} }
private (IActorRef actor, ISiteRepository mockRepo, Dictionary<string, TestProbe> siteProbes, ISiteClientFactory mockFactory) CreateActorWithFactory(
IEnumerable<Site>? sites = null)
{
var mockRepo = Substitute.For<ISiteRepository>();
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(sites?.ToList() ?? new List<Site>());
var services = new ServiceCollection();
services.AddScoped(_ => mockRepo);
var sp = services.BuildServiceProvider();
var siteProbes = new Dictionary<string, TestProbe>();
var mockFactory = Substitute.For<ISiteClientFactory>();
mockFactory.Create(Arg.Any<ActorSystem>(), Arg.Any<string>(), Arg.Any<ImmutableHashSet<ActorPath>>())
.Returns(callInfo =>
{
var siteId = callInfo.ArgAt<string>(1);
var probe = CreateTestProbe();
siteProbes[siteId] = probe;
return probe.Ref;
});
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, mockFactory)));
return (actor, mockRepo, siteProbes, mockFactory);
}
private Site CreateSite(string identifier, string? nodeAAddress, string? nodeBAddress = null) =>
new("Test Site", identifier) { NodeAAddress = nodeAAddress, NodeBAddress = nodeBAddress };
[Fact]
public void ClusterClientRouting_RefreshDeploymentCommand_RoutesToSite()
{
var site = CreateSite("site1", "akka.tcp://scadabridge@host:8082");
var (actor, _, siteProbes) = CreateActorWithMockRepo(new[] { site });
Thread.Sleep(1000);
var command = new RefreshDeploymentCommand(
"dep1", "inst1", "rev1", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok1");
actor.Tell(new SiteEnvelope("site1", command));
var msg = siteProbes["site1"].ExpectMsg<ClusterClient.Send>();
Assert.Equal("/user/site-communication", msg.Path);
Assert.IsType<RefreshDeploymentCommand>(msg.Message);
Assert.Equal("dep1", ((RefreshDeploymentCommand)msg.Message).DeploymentId);
}
[Fact]
public void UnconfiguredSite_MessageIsDropped()
{
var (actor, _, _) = CreateActorWithMockRepo();
// Wait for auto-refresh
Thread.Sleep(1000);
var command = new RefreshDeploymentCommand(
"dep1", "inst1", "hash1", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok1");
actor.Tell(new SiteEnvelope("unknown-site", command));
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
// Communication-016: the prior `ConnectionLost_DebugStreamsKilled` test was
// removed alongside the dead HandleConnectionStateChanged handler. No
// production code ever emitted ConnectionStateChanged, so the test was
// exercising a workflow that never ran. Disconnect detection is owned by
// the gRPC keepalive (DebugStreamBridgeActor self-terminates) and by the
// Ask-timeout path at the CommunicationService layer (deploy callers see
// a failure).
[Fact] [Fact]
public void Heartbeat_BumpsAggregatorTimestamp() public void Heartbeat_BumpsAggregatorTimestamp()
{ {
@@ -138,9 +58,9 @@ public class CentralCommunicationActorTests : TestKit
services.AddSingleton(aggregator); services.AddSingleton(aggregator);
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
var siteClientFactory = Substitute.For<ISiteClientFactory>(); var transport = Substitute.For<ISiteCommandTransport>();
var centralActor = Sys.ActorOf( var centralActor = Sys.ActorOf(
Props.Create(() => new CentralCommunicationActor(sp, siteClientFactory))); Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null)));
var timestamp = DateTimeOffset.UtcNow; var timestamp = DateTimeOffset.UtcNow;
centralActor.Tell(new HeartbeatMessage("site1", "host1", true, timestamp)); centralActor.Tell(new HeartbeatMessage("site1", "host1", true, timestamp));
@@ -165,9 +85,9 @@ public class CentralCommunicationActorTests : TestKit
services.AddSingleton(aggregator); services.AddSingleton(aggregator);
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
var siteClientFactory = Substitute.For<ISiteClientFactory>(); var transport = Substitute.For<ISiteCommandTransport>();
var centralActor = Sys.ActorOf( var centralActor = Sys.ActorOf(
Props.Create(() => new CentralCommunicationActor(sp, siteClientFactory))); Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null)));
var ts = DateTimeOffset.UtcNow; var ts = DateTimeOffset.UtcNow;
centralActor.Tell(new SiteHeartbeatReplica(new HeartbeatMessage("site-1", "host-a", true, ts))); centralActor.Tell(new SiteHeartbeatReplica(new HeartbeatMessage("site-1", "host-a", true, ts)));
@@ -175,41 +95,6 @@ public class CentralCommunicationActorTests : TestKit
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", ts)); AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", ts));
} }
[Fact]
public void RefreshSiteAddresses_UpdatesCache()
{
var site1 = CreateSite("site1", "akka.tcp://scadabridge@host1:8082");
var (actor, mockRepo, siteProbes) = CreateActorWithMockRepo(new[] { site1 });
// Wait for initial load
Thread.Sleep(1000);
// Verify routing to site1 works
var cmd1 = new RefreshDeploymentCommand(
"dep1", "inst1", "hash1", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok1");
actor.Tell(new SiteEnvelope("site1", cmd1));
var msg1 = siteProbes["site1"].ExpectMsg<ClusterClient.Send>();
Assert.Equal("dep1", ((RefreshDeploymentCommand)msg1.Message).DeploymentId);
// Update mock repo to return both sites
var site2 = CreateSite("site2", "akka.tcp://scadabridge@host2:8082");
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Site> { site1, site2 });
// Refresh again
actor.Tell(new RefreshSiteAddresses());
Thread.Sleep(1000);
// Verify routing to site2 now works
var cmd2 = new RefreshDeploymentCommand(
"dep2", "inst2", "hash2", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok2");
actor.Tell(new SiteEnvelope("site2", cmd2));
var msg2 = siteProbes["site2"].ExpectMsg<ClusterClient.Send>();
Assert.Equal("dep2", ((RefreshDeploymentCommand)msg2.Message).DeploymentId);
}
[Fact] [Fact]
public void LoadSiteAddressesFailure_IsLoggedNotSilentlySwallowed() public void LoadSiteAddressesFailure_IsLoggedNotSilentlySwallowed()
{ {
@@ -226,56 +111,26 @@ public class CentralCommunicationActorTests : TestKit
services.AddScoped(_ => mockRepo); services.AddScoped(_ => mockRepo);
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
var mockFactory = Substitute.For<ISiteClientFactory>(); var transport = Substitute.For<ISiteCommandTransport>();
// The fix logs a Warning carrying the InvalidOperationException as the cause. // The fix logs a Warning carrying the InvalidOperationException as the cause.
EventFilter.Warning(contains: "Failed to load site addresses from the database").ExpectOne(() => EventFilter.Warning(contains: "Failed to load site addresses from the database").ExpectOne(() =>
{ {
Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, mockFactory))); Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null)));
}); });
} }
[Fact]
public void MalformedSiteAddress_DoesNotAbortRefresh_OtherSitesStillRegistered()
{
// Regression test for Communication-009. HandleSiteAddressCacheLoaded calls
// ActorPath.Parse for every site in a single loop. A malformed NodeAAddress
// throws inside that loop; before the fix the whole refresh aborted partway
// through, leaving the cache half-updated (some sites registered, others not).
// The fix wraps the parse in a try/catch that logs and skips the bad site so
// a single garbage row cannot starve every other site of its ClusterClient.
var goodSite = CreateSite("good-site", "akka.tcp://scadabridge@host1:8082");
// A garbage address that ActorPath.Parse rejects.
var badSite = CreateSite("bad-site", "this is not a valid actor path !!!");
// Order the bad site first so a non-resilient loop aborts before reaching good-site.
var (actor, _, siteProbes) = CreateActorWithMockRepo(new[] { badSite, goodSite });
Thread.Sleep(1000);
// good-site must still be registered and routable despite bad-site failing to parse.
var cmd = new RefreshDeploymentCommand(
"dep1", "inst1", "hash1", "admin", DateTimeOffset.UtcNow,
"https://central:9000", "tok1");
actor.Tell(new SiteEnvelope("good-site", cmd));
Assert.True(siteProbes.ContainsKey("good-site"),
"good-site should have a ClusterClient even though bad-site's address is malformed");
var msg = siteProbes["good-site"].ExpectMsg<ClusterClient.Send>();
Assert.Equal("dep1", ((RefreshDeploymentCommand)msg.Message).DeploymentId);
}
private NotificationSubmit CreateSubmit(string id = "notif1") => private NotificationSubmit CreateSubmit(string id = "notif1") =>
new(id, "ops-list", "Subject", "Body", "site1", "inst1", "script.cs", DateTimeOffset.UtcNow); new(id, "ops-list", "Subject", "Body", "site1", "inst1", "script.cs", DateTimeOffset.UtcNow);
[Fact] [Fact]
public void NotificationSubmit_ForwardedToOutboxProxy_AckRoutesBackToSite() public void NotificationSubmit_ForwardedToOutboxProxy_AckRoutesBackToSite()
{ {
var (actor, _, _) = CreateActorWithMockRepo(); var (actor, _) = CreateActorWithMockRepo();
var outboxProbe = CreateTestProbe(); var outboxProbe = CreateTestProbe();
actor.Tell(new RegisterNotificationOutbox(outboxProbe.Ref)); actor.Tell(new RegisterNotificationOutbox(outboxProbe.Ref));
// A second probe stands in for the site's ClusterClient (the original Sender). // A second probe stands in for the site (the original Sender).
var siteProbe = CreateTestProbe(); var siteProbe = CreateTestProbe();
var submit = CreateSubmit(); var submit = CreateSubmit();
actor.Tell(submit, siteProbe.Ref); actor.Tell(submit, siteProbe.Ref);
@@ -290,7 +145,7 @@ public class CentralCommunicationActorTests : TestKit
[Fact] [Fact]
public void NotificationStatusQuery_ForwardedToOutboxProxy_ResponseRoutesBackToSite() public void NotificationStatusQuery_ForwardedToOutboxProxy_ResponseRoutesBackToSite()
{ {
var (actor, _, _) = CreateActorWithMockRepo(); var (actor, _) = CreateActorWithMockRepo();
var outboxProbe = CreateTestProbe(); var outboxProbe = CreateTestProbe();
actor.Tell(new RegisterNotificationOutbox(outboxProbe.Ref)); actor.Tell(new RegisterNotificationOutbox(outboxProbe.Ref));
@@ -308,7 +163,7 @@ public class CentralCommunicationActorTests : TestKit
[Fact] [Fact]
public void NotificationSubmit_NoOutboxConfigured_RepliesNonAccepted() public void NotificationSubmit_NoOutboxConfigured_RepliesNonAccepted()
{ {
var (actor, _, _) = CreateActorWithMockRepo(); var (actor, _) = CreateActorWithMockRepo();
// No RegisterNotificationOutbox sent — the proxy is null. // No RegisterNotificationOutbox sent — the proxy is null.
var submit = CreateSubmit(); var submit = CreateSubmit();
@@ -323,7 +178,7 @@ public class CentralCommunicationActorTests : TestKit
[Fact] [Fact]
public void NotificationStatusQuery_NoOutboxConfigured_RepliesNotFound() public void NotificationStatusQuery_NoOutboxConfigured_RepliesNotFound()
{ {
var (actor, _, _) = CreateActorWithMockRepo(); var (actor, _) = CreateActorWithMockRepo();
// No RegisterNotificationOutbox sent — the proxy is null. // No RegisterNotificationOutbox sent — the proxy is null.
var query = new NotificationStatusQuery("corr1", "notif1"); var query = new NotificationStatusQuery("corr1", "notif1");
@@ -333,23 +188,4 @@ public class CentralCommunicationActorTests : TestKit
Assert.Equal("corr1", response.CorrelationId); Assert.Equal("corr1", response.CorrelationId);
Assert.False(response.Found); Assert.False(response.Found);
} }
[Fact]
public void BothContactPoints_UsedInSingleClient()
{
var site = CreateSite("site1",
"akka.tcp://scadabridge@host1:8082",
"akka.tcp://scadabridge@host2:8082");
var (actor, _, siteProbes, mockFactory) = CreateActorWithFactory(new[] { site });
// Wait for auto-refresh
Thread.Sleep(1000);
// Verify the factory was called with 2 contact paths
mockFactory.Received(1).Create(
Arg.Any<ActorSystem>(),
Arg.Is("site1"),
Arg.Is<ImmutableHashSet<ActorPath>>(paths => paths.Count == 2));
}
} }
@@ -105,26 +105,28 @@ public class CommunicationOptionsValidatorTests
Assert.Contains("LiveAlarmCachePublishCoalesce", result.FailureMessage); Assert.Contains("LiveAlarmCachePublishCoalesce", result.FailureMessage);
} }
// ── T1A.3: gRPC central transport endpoints (required only when selected) ──── // ── gRPC central transport endpoints ─────────────────────────────────────────
// gRPC (CentralControlService) is the only site→central transport after the
// ClusterClient→gRPC migration's Phase 4. This role-agnostic validator only rejects BLANK
// entries; an EMPTY list is valid (central nodes legitimately declare none). The role-aware
// "a Site must list at least one endpoint" rule lives in StartupValidator, tested there.
[Fact] [Fact]
public void GrpcTransport_WithNoEndpoints_IsRejected() public void EmptyEndpoints_IsValid()
{ {
// A central node hosts CentralControlService; it does not dial it, so it declares none.
var result = Validate(new CommunicationOptions var result = Validate(new CommunicationOptions
{ {
CentralTransport = CentralTransportMode.Grpc,
CentralGrpcEndpoints = new List<string>(), CentralGrpcEndpoints = new List<string>(),
}); });
Assert.True(result.Failed); Assert.True(result.Succeeded, result.FailureMessage);
Assert.Contains("CentralGrpcEndpoints", result.FailureMessage);
} }
[Fact] [Fact]
public void GrpcTransport_WithBlankEndpoint_IsRejected() public void BlankEndpoint_IsRejected()
{ {
var result = Validate(new CommunicationOptions var result = Validate(new CommunicationOptions
{ {
CentralTransport = CentralTransportMode.Grpc,
CentralGrpcEndpoints = new List<string> { " " }, CentralGrpcEndpoints = new List<string> { " " },
}); });
Assert.True(result.Failed); Assert.True(result.Failed);
@@ -132,11 +134,10 @@ public class CommunicationOptionsValidatorTests
} }
[Fact] [Fact]
public void GrpcTransport_WithEndpoints_IsValid() public void Endpoints_IsValid()
{ {
var result = Validate(new CommunicationOptions var result = Validate(new CommunicationOptions
{ {
CentralTransport = CentralTransportMode.Grpc,
CentralGrpcEndpoints = new List<string> CentralGrpcEndpoints = new List<string>
{ {
"http://scadabridge-central-a:8083", "http://scadabridge-central-a:8083",
@@ -145,16 +146,4 @@ public class CommunicationOptionsValidatorTests
}); });
Assert.True(result.Succeeded, result.FailureMessage); Assert.True(result.Succeeded, result.FailureMessage);
} }
[Fact]
public void AkkaTransport_IgnoresMissingGrpcEndpoints()
{
// The default transport must not be forced to declare gRPC endpoints it never dials.
var result = Validate(new CommunicationOptions
{
CentralTransport = CentralTransportMode.Akka,
CentralGrpcEndpoints = new List<string>(),
});
Assert.True(result.Succeeded, result.FailureMessage);
}
} }
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// explicit <c>Resume</c> supervision strategy per the CLAUDE.md decision /// explicit <c>Resume</c> supervision strategy per the CLAUDE.md decision
/// ("Resume for coordinator actors"). A child fault under the default /// ("Resume for coordinator actors"). A child fault under the default
/// (Restart) strategy would wipe a child's in-memory state; the long-lived /// (Restart) strategy would wipe a child's in-memory state; the long-lived
/// coordinators own per-site ClusterClients and must not silently discard /// coordinators own per-site transport resources and must not silently discard
/// their children on a transient fault. /// their children on a transient fault.
/// </summary> /// </summary>
public class CoordinatorSupervisionTests : TestKit public class CoordinatorSupervisionTests : TestKit
@@ -23,8 +23,8 @@ public class CoordinatorSupervisionTests : TestKit
/// </summary> /// </summary>
private sealed class CentralCommunicationActorProbe : CentralCommunicationActor private sealed class CentralCommunicationActorProbe : CentralCommunicationActor
{ {
public CentralCommunicationActorProbe(IServiceProvider sp, ISiteClientFactory factory) public CentralCommunicationActorProbe(IServiceProvider sp, ISiteCommandTransport transport)
: base(sp, factory) { } : base(sp, transport) { }
public SupervisorStrategy GetSupervisorStrategy() => SupervisorStrategy(); public SupervisorStrategy GetSupervisorStrategy() => SupervisorStrategy();
} }
@@ -54,10 +54,10 @@ public class CoordinatorSupervisionTests : TestKit
public void CentralCommunicationActor_SupervisorStrategy_IsResume() public void CentralCommunicationActor_SupervisorStrategy_IsResume()
{ {
var sp = EmptyServiceProvider(); var sp = EmptyServiceProvider();
var factory = Substitute.For<ISiteClientFactory>(); var transport = Substitute.For<ISiteCommandTransport>();
var actorRef = new Akka.TestKit.TestActorRef<CentralCommunicationActorProbe>( var actorRef = new Akka.TestKit.TestActorRef<CentralCommunicationActorProbe>(
Sys, Props.Create(() => new CentralCommunicationActorProbe(sp, factory))); Sys, Props.Create(() => new CentralCommunicationActorProbe(sp, transport)));
var strategy = actorRef.UnderlyingActor.GetSupervisorStrategy(); var strategy = actorRef.UnderlyingActor.GetSupervisorStrategy();
@@ -1,54 +0,0 @@
using System.Collections.Immutable;
using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.Configuration;
using Akka.TestKit.Xunit2;
using Xunit;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
public class DefaultSiteClientFactoryTests : TestKit
{
private static readonly Config TestConfig = ConfigurationFactory.ParseString(@"
akka.actor.provider = cluster
akka.remote.dot-netty.tcp.port = 0
akka.remote.dot-netty.tcp.hostname = localhost")
.WithFallback(ClusterClientReceptionist.DefaultConfig());
public DefaultSiteClientFactoryTests() : base(TestConfig) { }
private static ImmutableHashSet<ActorPath> Contacts() =>
ImmutableHashSet.Create(ActorPath.Parse("akka.tcp://other@localhost:2552/system/receptionist"));
[Fact]
public void Create_TwiceForSameSite_DoesNotCollide()
{
var factory = new DefaultSiteClientFactory();
var first = factory.Create(Sys, "site-a", Contacts());
var second = factory.Create(Sys, "site-a", Contacts()); // pre-fix: InvalidActorNameException
Assert.NotEqual(first.Path, second.Path);
}
[Theory]
[InlineData("site/with/slashes")]
[InlineData("site with spaces")]
[InlineData("näme#!")]
public void Create_WithUnsafeSiteId_SanitizesAndSucceeds(string siteId)
{
var factory = new DefaultSiteClientFactory();
var client = factory.Create(Sys, siteId, Contacts()); // pre-fix: InvalidActorNameException
Assert.NotNull(client);
}
[Theory]
[InlineData("plant-01", "plant-01")]
[InlineData("a/b c", "a_b_c")]
[InlineData("", "site")]
public void SanitizeForActorName_ProducesValidPathElement(string input, string expected)
{
Assert.Equal(expected, DefaultSiteClientFactory.SanitizeForActorName(input));
Assert.True(ActorPath.IsValidPathElement(
DefaultSiteClientFactory.SanitizeForActorName(input)));
}
}
@@ -14,9 +14,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary> /// <summary>
/// Review 01 [Medium]: site→central health reports were fire-and-forget, so a lost /// Review 01 [Medium]: site→central health reports were fire-and-forget, so a lost
/// report was invisible to the sender. These tests pin the new end-to-end ack: /// report was invisible to the sender. These tests pin the end-to-end ack: the site actor
/// the site actor replies not-accepted when it has no central ClusterClient, and the /// always replies to the sender (even when the transport cannot forward — a fail-loud failure,
/// central actor processes + acks a report it receives. /// not silence), and the central actor processes + acks a report it receives.
/// </summary> /// </summary>
public class HealthReportAckTests : TestKit public class HealthReportAckTests : TestKit
{ {
@@ -29,17 +29,17 @@ public class HealthReportAckTests : TestKit
0, 0, new Dictionary<string, int>(), 0, 0, 0, 0); 0, 0, new Dictionary<string, int>(), 0, 0, 0, 0);
[Fact] [Fact]
public void SiteCommunicationActor_NoCentralClient_RepliesNotAccepted() public void SiteCommunicationActor_NoTransport_RepliesFailure_NotSilence()
{ {
// With no transport injected the actor falls back to the fail-loud
// NoOpCentralTransport, which answers a Status.Failure so the health transport's
// Ask sees a transient failure rather than hanging. (Production always injects the
// gRPC GrpcCentralTransport; this pins the wiring-guard fallback.)
var dmProbe = CreateTestProbe(); var dmProbe = CreateTestProbe();
var siteComm = Sys.ActorOf(Props.Create(() => var siteComm = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site-a", _options, dmProbe.Ref))); new SiteCommunicationActor("site-a", _options, dmProbe.Ref)));
// No RegisterCentralClient sent => _centralClient is null.
siteComm.Tell(SampleReport(seq: 7)); siteComm.Tell(SampleReport(seq: 7));
var ack = ExpectMsg<SiteHealthReportAck>(); ExpectMsg<Status.Failure>();
Assert.False(ack.Accepted);
Assert.Equal(7, ack.SequenceNumber);
Assert.Equal("site-a", ack.SiteId);
} }
[Fact] [Fact]
@@ -54,8 +54,8 @@ public class HealthReportAckTests : TestKit
services.AddSingleton(aggregator); services.AddSingleton(aggregator);
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
var siteClientFactory = Substitute.For<ISiteClientFactory>(); var transport = Substitute.For<ISiteCommandTransport>();
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, siteClientFactory))); var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport, (TimeSpan?)null)));
actor.Tell(SampleReport(seq: 3)); actor.Tell(SampleReport(seq: 3));
var ack = ExpectMsg<SiteHealthReportAck>(); var ack = ExpectMsg<SiteHealthReportAck>();
@@ -1,4 +1,3 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
@@ -8,25 +7,6 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// </summary> /// </summary>
public class MessageContractTests public class MessageContractTests
{ {
[Fact]
public void IntegrationCallRequest_HasCorrelationId()
{
var msg = new IntegrationCallRequest(
"corr-123", "site1", "inst1", "ExtSys1", "GetData",
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
Assert.Equal("corr-123", msg.CorrelationId);
}
[Fact]
public void IntegrationCallResponse_HasCorrelationId()
{
var msg = new IntegrationCallResponse(
"corr-123", "site1", true, "{}", null, DateTimeOffset.UtcNow);
Assert.Equal("corr-123", msg.CorrelationId);
}
[Fact] [Fact]
public void EventLogQueryRequest_HasCorrelationId() public void EventLogQueryRequest_HasCorrelationId()
{ {
@@ -79,10 +59,6 @@ public class MessageContractTests
Assert.True(typeof(Commons.Messages.Artifacts.DeployArtifactsCommand).IsValueType == false); Assert.True(typeof(Commons.Messages.Artifacts.DeployArtifactsCommand).IsValueType == false);
Assert.True(typeof(Commons.Messages.Artifacts.ArtifactDeploymentResponse).IsValueType == false); Assert.True(typeof(Commons.Messages.Artifacts.ArtifactDeploymentResponse).IsValueType == false);
// Pattern 4: Integration
Assert.True(typeof(IntegrationCallRequest).IsValueType == false);
Assert.True(typeof(IntegrationCallResponse).IsValueType == false);
// Pattern 5: Debug View // Pattern 5: Debug View
Assert.True(typeof(Commons.Messages.DebugView.SubscribeDebugViewRequest).IsValueType == false); Assert.True(typeof(Commons.Messages.DebugView.SubscribeDebugViewRequest).IsValueType == false);
Assert.True(typeof(Commons.Messages.DebugView.DebugViewSnapshot).IsValueType == false); Assert.True(typeof(Commons.Messages.DebugView.DebugViewSnapshot).IsValueType == false);
@@ -1,7 +1,6 @@
using Akka.Actor; using Akka.Actor;
using Akka.TestKit.Xunit2; using Akka.TestKit.Xunit2;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Communication.Actors; using ZB.MOM.WW.ScadaBridge.Communication.Actors;
@@ -243,18 +242,6 @@ public class SiteCommandDispatcherTests : TestKit
dispatcher.ResolveRoute(new TriggerSiteFailover("c", SiteId))); dispatcher.ResolveRoute(new TriggerSiteFailover("c", SiteId)));
} }
[Fact]
public void ResolveRoute_RejectsTheExcludedIntegrationCommand()
{
// IntegrationCallRequest is the 29th command, dead at both ends and deliberately excluded
// (28 of 29 migrate). It never enters the dispatcher.
var dispatcher = Build(CreateTestProbe().Ref);
var command = new IntegrationCallRequest(
"c", SiteId, "inst", "es", "m", new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
Assert.Throws<ArgumentException>(() => dispatcher.ResolveRoute(command));
}
// ── Failover (local path) ── // ── Failover (local path) ──
[Fact] [Fact]
@@ -94,10 +94,8 @@ public class SiteCommandDtoMapperGoldenTests
} }
/// <summary> /// <summary>
/// The contract carries exactly 28 commands — 29 on /// The contract carries exactly 28 commands across six domain groups. If the
/// <c>SiteCommunicationActor</c>'s receive table minus the dead /// site gains a command, this count moves deliberately, not silently.
/// <c>IntegrationCallRequest</c>. If the site gains a command, this count
/// moves deliberately, not silently.
/// </summary> /// </summary>
[Fact] [Fact]
public void CommandInventory_Is28_AcrossSixGroups() public void CommandInventory_Is28_AcrossSixGroups()
@@ -431,17 +429,6 @@ public class SiteCommandDtoMapperGoldenTests
// Group classification + rejection // Group classification + rejection
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary><c>IntegrationCallRequest</c> is excluded by design and must be rejected, not silently dropped.</summary>
[Fact]
public void IntegrationCallRequest_IsRejected()
{
var dead = new Commons.Messages.Integration.IntegrationCallRequest(
"corr", "site-a", "Instance", "System", "Method",
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
Assert.Throws<ArgumentException>(() => SiteCommandDtoMapper.GroupOf(dead));
}
/// <summary>Packing a command into the wrong group's envelope is a hard error, not a silent no-op.</summary> /// <summary>Packing a command into the wrong group's envelope is a hard error, not a silent no-op.</summary>
[Fact] [Fact]
public void PackingIntoTheWrongGroup_Throws() => public void PackingIntoTheWrongGroup_Throws() =>
@@ -1,11 +1,10 @@
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.TestKit.Xunit2; using Akka.TestKit.Xunit2;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
@@ -73,136 +72,11 @@ public class SiteCommunicationActorTests : TestKit
dmProbe.ExpectMsg<DeploymentStateQueryRequest>(msg => msg.CorrelationId == "corr-q"); dmProbe.ExpectMsg<DeploymentStateQueryRequest>(msg => msg.CorrelationId == "corr-q");
} }
[Fact] // The site→central send-forwarding tests that used to live here (NotificationSubmit /
public void IntegrationCall_WithoutHandler_ReturnsFailure() // NotificationStatusQuery, with and without a registered central ClusterClient) were removed
{ // with the Akka transport in the ClusterClient→gRPC migration's Phase 4: the actor no longer
var dmProbe = CreateTestProbe(); // owns the wire, it delegates to an injected ICentralTransport, and those seven sends (plus the
var siteActor = Sys.ActorOf(Props.Create(() => // reply routing and the fail-loud fallback) are now covered by SiteCommunicationActorTransportTests.
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
var request = new IntegrationCallRequest(
"corr1", "site1", "inst1", "ExtSys1", "GetData",
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
siteActor.Tell(request);
ExpectMsg<IntegrationCallResponse>(msg =>
!msg.Success && msg.ErrorMessage == "Integration handler not available");
}
[Fact]
public void IntegrationCall_WithHandler_ForwardedToHandler()
{
var dmProbe = CreateTestProbe();
var handlerProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
// Register integration handler
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.Integration, handlerProbe.Ref));
var request = new IntegrationCallRequest(
"corr1", "site1", "inst1", "ExtSys1", "GetData",
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
siteActor.Tell(request);
handlerProbe.ExpectMsg<IntegrationCallRequest>(msg => msg.CorrelationId == "corr1");
}
[Fact]
public void NotificationSubmit_WithCentralClient_ForwardedToCentralAndAckRoutedBack()
{
// The site forwards a buffered notification to central over the ClusterClient
// command/control transport; the central ack must route back to the original
// sender (the S&F forwarder's Ask), not to the SiteCommunicationActor.
var dmProbe = CreateTestProbe();
var centralClientProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
siteActor.Tell(new RegisterCentralClient(centralClientProbe.Ref));
var submit = new NotificationSubmit(
"notif-1", "Operators", "Subj", "Body", "site1", "inst1", "alarmScript",
DateTimeOffset.UtcNow);
siteActor.Tell(submit);
// Central client (acting as ClusterClient) receives a ClusterClient.Send wrapping
// the NotificationSubmit, addressed to the central communication actor. Fish past
// any periodic HeartbeatMessage the actor's timer may interleave.
var send = centralClientProbe.FishForMessage<ClusterClient.Send>(
s => s.Message is NotificationSubmit);
Assert.Equal("/user/central-communication", send.Path);
var forwarded = Assert.IsType<NotificationSubmit>(send.Message);
Assert.Equal("notif-1", forwarded.NotificationId);
// The ack is sent to the ClusterClient.Send's Sender — replying as that probe
// must land back at the test actor (the original Tell sender).
centralClientProbe.Reply(new NotificationSubmitAck("notif-1", Accepted: true, Error: null));
ExpectMsg<NotificationSubmitAck>(ack => ack.NotificationId == "notif-1" && ack.Accepted);
}
[Fact]
public void NotificationSubmit_WithoutCentralClient_RepliesWithNonAccepted()
{
// No ClusterClient registered yet: the submit cannot be forwarded, so the actor
// replies with a non-accepted ack and the S&F forwarder treats it as transient.
var dmProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
var submit = new NotificationSubmit(
"notif-2", "Operators", "Subj", "Body", "site1", null, null,
DateTimeOffset.UtcNow);
siteActor.Tell(submit);
ExpectMsg<NotificationSubmitAck>(ack => ack.NotificationId == "notif-2" && !ack.Accepted);
}
[Fact]
public void NotificationStatusQuery_WithCentralClient_ForwardedToCentralAndResponseRoutedBack()
{
// Notify.Status(id) issues a NotificationStatusQuery; the site actor forwards it
// to central over the ClusterClient command/control transport and the central
// response must route back to the original sender (the helper's Ask).
var dmProbe = CreateTestProbe();
var centralClientProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
siteActor.Tell(new RegisterCentralClient(centralClientProbe.Ref));
var query = new NotificationStatusQuery("corr-99", "notif-1");
siteActor.Tell(query);
var send = centralClientProbe.FishForMessage<ClusterClient.Send>(
s => s.Message is NotificationStatusQuery);
Assert.Equal("/user/central-communication", send.Path);
var forwarded = Assert.IsType<NotificationStatusQuery>(send.Message);
Assert.Equal("notif-1", forwarded.NotificationId);
// The response is sent to the ClusterClient.Send's Sender — replying as that
// probe must land back at the test actor (the original Tell sender).
centralClientProbe.Reply(new NotificationStatusResponse(
"corr-99", Found: true, Status: "Delivered", RetryCount: 0,
LastError: null, DeliveredAt: DateTimeOffset.UtcNow));
ExpectMsg<NotificationStatusResponse>(r => r.CorrelationId == "corr-99" && r.Found);
}
[Fact]
public void NotificationStatusQuery_WithoutCentralClient_RepliesWithNotFound()
{
// No ClusterClient registered yet: the query cannot reach central, so the actor
// replies Found: false. Notify.Status then falls back to the site S&F buffer.
var dmProbe = CreateTestProbe();
var siteActor = Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
siteActor.Tell(new NotificationStatusQuery("corr-100", "notif-2"));
ExpectMsg<NotificationStatusResponse>(
r => r.CorrelationId == "corr-100" && !r.Found);
}
[Fact] [Fact]
public void EventLogQuery_WithoutHandler_ReturnsFailure() public void EventLogQuery_WithoutHandler_ReturnsFailure()
@@ -355,22 +229,22 @@ public class SiteCommunicationActorTests : TestKit
// leader check in production); tests inject a stub so they do not need // leader check in production); tests inject a stub so they do not need
// to bring up a full cluster in the TestKit ActorSystem. // to bring up a full cluster in the TestKit ActorSystem.
var dmProbe = CreateTestProbe(); var dmProbe = CreateTestProbe();
var centralClientProbe = CreateTestProbe(); var transport = Substitute.For<ICentralTransport>();
var fastHeartbeatOptions = new CommunicationOptions var fastHeartbeatOptions = new CommunicationOptions
{ {
TransportHeartbeatInterval = TimeSpan.FromMilliseconds(50) ApplicationHeartbeatInterval = TimeSpan.FromMilliseconds(100)
}; };
var siteActor = Sys.ActorOf(Props.Create(() => Sys.ActorOf(Props.Create(() =>
new SiteCommunicationActor("site1", fastHeartbeatOptions, dmProbe.Ref, () => isActive))); new SiteCommunicationActor("site1", fastHeartbeatOptions, dmProbe.Ref, () => isActive, null, transport)));
siteActor.Tell(new RegisterCentralClient(centralClientProbe.Ref)); // The heartbeat now rides the injected ICentralTransport (SendHeartbeat), not a
// ClusterClient. The captured message must carry the injected active/standby flag.
var send = centralClientProbe.FishForMessage<ClusterClient.Send>( AwaitAssert(
s => s.Message is HeartbeatMessage, TimeSpan.FromSeconds(3)); () => transport.Received().SendHeartbeat(
var heartbeat = Assert.IsType<HeartbeatMessage>(send.Message); Arg.Is<HeartbeatMessage>(h => h.IsActive == isActive && h.SiteId == "site1"),
Assert.Equal(isActive, heartbeat.IsActive); Arg.Any<IActorRef>()),
Assert.Equal("site1", heartbeat.SiteId); TimeSpan.FromSeconds(3));
} }
// ---- Central→site failover relay (Task 10) ---------------------------------- // ---- Central→site failover relay (Task 10) ----------------------------------
@@ -0,0 +1,102 @@
using Opc.Ua;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// Covers <see cref="RealOpcUaClient.RewriteEndpointHostForReachability"/> — the fix for OPC UA
/// servers (e.g. OtOpcUa v3) that advertise an unroutable base address (a 0.0.0.0 wildcard bind or
/// an internal container/NAT hostname). The session must dial the host the operator reached, not
/// the advertised one, while preserving the advertised scheme + path.
/// </summary>
public class RealOpcUaClientEndpointRewriteTests
{
private static string? Rewrite(string advertised, string discovery)
{
var ep = new EndpointDescription(advertised);
RealOpcUaClient.RewriteEndpointHostForReachability(ep, discovery);
return ep.EndpointUrl;
}
[Fact]
public void Rewrites_0_0_0_0_advertisement_to_the_reachable_host_preserving_path()
{
Assert.Equal(
"opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa",
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa"));
}
[Fact]
public void Rewrites_internal_nat_hostname_to_the_reachable_host()
{
Assert.Equal(
"opc.tcp://plc.example.com:4840/OtOpcUa",
Rewrite("opc.tcp://be5667fab9da:4840/OtOpcUa", "opc.tcp://plc.example.com:4840/OtOpcUa"));
}
[Fact]
public void Rewrites_port_too_when_the_advertised_port_differs()
{
Assert.Equal(
"opc.tcp://gateway:51210/OtOpcUa",
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://gateway:51210/OtOpcUa"));
}
[Fact]
public void Leaves_an_already_reachable_advertisement_untouched()
{
// opc-plc advertises its real container name — must not be rewritten.
Assert.Equal(
"opc.tcp://scadabridge-opcua:50000",
Rewrite("opc.tcp://scadabridge-opcua:50000", "opc.tcp://scadabridge-opcua:50000"));
}
[Fact]
public void Handles_authority_only_advertisement_without_appending_a_trailing_slash()
{
Assert.Equal(
"opc.tcp://host-b:4840",
Rewrite("opc.tcp://0.0.0.0:4840", "opc.tcp://host-b:4840"));
}
[Fact]
public void Re_wraps_an_ipv6_reachable_host_in_brackets()
{
Assert.Equal(
"opc.tcp://[::1]:4840/OtOpcUa",
Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://[::1]:4840/OtOpcUa"));
}
[Fact]
public void Emits_no_port_when_neither_url_carries_one()
{
// opc.tcp has no default port, so Uri.Port is -1 on both — must not emit ":-1".
Assert.Equal(
"opc.tcp://myhost/UaServer",
Rewrite("opc.tcp://0.0.0.0/UaServer", "opc.tcp://myhost/UaServer"));
}
[Fact]
public void Uses_the_reachable_port_when_the_advertisement_omits_one()
{
Assert.Equal(
"opc.tcp://myhost:4840/UaServer",
Rewrite("opc.tcp://0.0.0.0/UaServer", "opc.tcp://myhost:4840/UaServer"));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-a-uri")]
public void Leaves_endpoint_unchanged_when_discovery_url_is_unusable(string discovery)
{
Assert.Equal("opc.tcp://0.0.0.0:4840/OtOpcUa", Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", discovery));
}
[Fact]
public void Null_endpoint_is_a_no_op()
{
// Must not throw.
RealOpcUaClient.RewriteEndpointHostForReachability(null, "opc.tcp://host:4840");
}
}
@@ -189,8 +189,9 @@ public class SiteActorPathTests : IAsyncLifetime
// fixture actually starts the host — a site node with no configured // fixture actually starts the host — a site node with no configured
// consolidated-database path fails fast rather than booting half-working. // consolidated-database path fails fast rather than booting half-working.
["LocalDb:Path"] = _tempLocalDbPath, ["LocalDb:Path"] = _tempLocalDbPath,
// Configure a dummy central contact point to trigger ClusterClient creation // A central gRPC endpoint so the Site branch builds its GrpcCentralTransport
["ScadaBridge:Communication:CentralContactPoints:0"] = "akka.tcp://scadabridge@localhost:25510", // (the only site→central transport since Phase 4). Never dialled by this test.
["ScadaBridge:Communication:CentralGrpcEndpoints:0"] = "http://localhost:8083",
}); });
}); });
builder.ConfigureServices((context, services) => builder.ConfigureServices((context, services) =>
@@ -236,9 +237,10 @@ public class SiteActorPathTests : IAsyncLifetime
public async Task SiteActors_SiteCommunication_Exists() public async Task SiteActors_SiteCommunication_Exists()
=> await AssertActorExists("/user/site-communication"); => await AssertActorExists("/user/site-communication");
[Fact] // SiteActors_CentralClusterClient_Exists was removed with the site→central Akka ClusterClient
public async Task SiteActors_CentralClusterClient_Exists() // (the `central-cluster-client` actor) in the ClusterClient→gRPC migration's Phase 4. The site
=> await AssertActorExists("/user/central-cluster-client"); // now reaches central over gRPC (GrpcCentralTransport dialling CentralControlService), which is
// not an actor in the local system, so there is no actor path to assert here.
private async Task AssertActorExists(string path) private async Task AssertActorExists(string path)
{ {
@@ -41,6 +41,9 @@ public class StartupValidatorTests
// T0.3: the gRPC control plane is fail-closed, so a Site node without a preshared // T0.3: the gRPC control plane is fail-closed, so a Site node without a preshared
// key serves nothing while still looking healthy. Required at boot for that reason. // key serves nothing while still looking healthy. Required at boot for that reason.
["ScadaBridge:Communication:GrpcPsk"] = "test-site-control-plane-key", ["ScadaBridge:Communication:GrpcPsk"] = "test-site-control-plane-key",
// Phase 4: gRPC (CentralControlService) is the only site→central transport, so a Site
// node must list at least one central gRPC endpoint to dial (no Akka fallback remains).
["ScadaBridge:Communication:CentralGrpcEndpoints:0"] = "http://central-a:8083",
}; };
[Fact] [Fact]
@@ -97,6 +100,30 @@ public class StartupValidatorTests
Assert.Null(Record.Exception(() => StartupValidator.Validate(config))); Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
} }
[Fact]
public void SiteWithoutCentralGrpcEndpoints_FailsValidation()
{
// Phase 4: gRPC (CentralControlService) is the only site→central transport, so a Site
// node with no central gRPC endpoint has nothing to dial — heartbeats, health, notification
// forwards and audit ingest all silently fail. Fail fast at boot instead.
var values = ValidSiteConfig();
values.Remove("ScadaBridge:Communication:CentralGrpcEndpoints:0");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("CentralGrpcEndpoints", ex.Message);
}
[Fact]
public void CentralWithoutCentralGrpcEndpoints_PassesValidation()
{
// A central node hosts CentralControlService; it does not dial it, so it declares no
// endpoints. The site-only requirement must not fire for Central.
var config = BuildConfig(ValidCentralConfig());
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
[Fact] [Fact]
public void MissingRole_FailsValidation() public void MissingRole_FailsValidation()
{ {
@@ -1,7 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.Client;
using Akka.TestKit.Xunit2; using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -12,6 +10,10 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.Audit; using ZB.MOM.WW.Audit;
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;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -32,10 +34,10 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.AuditLog;
/// <see cref="ClusterClientSiteAuditClient"/>, the real /// <see cref="ClusterClientSiteAuditClient"/>, the real
/// <see cref="SiteCommunicationActor"/> forward, the real /// <see cref="SiteCommunicationActor"/> forward, the real
/// <see cref="CentralCommunicationActor"/> routing, and the real /// <see cref="CentralCommunicationActor"/> routing, and the real
/// <c>AuditLogIngestActor</c> ingest — only the cross-cluster ClusterClient /// <c>AuditLogIngestActor</c> ingest — only the cross-cluster gRPC transport itself is
/// transport itself is substituted by an in-process <see cref="ClusterClientRelay"/> /// substituted by an in-process <see cref="BridgeCentralTransport"/> that Tells the central
/// that unwraps <see cref="ClusterClient.Send"/> exactly as a real ClusterClient /// actor exactly as the real <c>GrpcCentralTransport</c> would (a multi-node cluster is out of
/// would (a multi-node cluster is out of scope for an in-process test). /// scope for an in-process test).
/// </para> /// </para>
/// <para> /// <para>
/// The central audit store is an in-memory <see cref="IAuditLogRepository"/> — /// The central audit store is an in-memory <see cref="IAuditLogRepository"/> —
@@ -49,18 +51,23 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.AuditLog;
public class SiteAuditPushFlowTests : TestKit public class SiteAuditPushFlowTests : TestKit
{ {
/// <summary> /// <summary>
/// In-process stand-in for a real Akka ClusterClient: unwraps a /// In-process stand-in for the site→central gRPC transport (<c>GrpcCentralTransport</c>):
/// <see cref="ClusterClient.Send"/> and forwards the inner message to the /// each site→central send is Tell'd straight to the central actor, preserving the reply-to so
/// central actor, preserving the original sender so the reply routes back to /// the central reply routes back to the site's Ask. A real transport does exactly this across
/// the site's Ask. A real ClusterClient does exactly this across the cluster /// the cluster boundary; the in-process bridge keeps the test free of a multi-node/gRPC setup.
/// boundary; the in-process relay keeps the test free of a multi-node setup.
/// </summary> /// </summary>
private sealed class ClusterClientRelay : ReceiveActor private sealed class BridgeCentralTransport : ICentralTransport
{ {
public ClusterClientRelay(IActorRef central) private readonly IActorRef _central;
{ public BridgeCentralTransport(IActorRef central) => _central = central;
Receive<ClusterClient.Send>(send => central.Forward(send.Message));
} public void SubmitNotification(NotificationSubmit message, IActorRef replyTo) => _central.Tell(message, replyTo);
public void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo) => _central.Tell(message, replyTo);
public void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo) => _central.Tell(message, replyTo);
public void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo) => _central.Tell(message, replyTo);
public void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo) => _central.Tell(message, replyTo);
public void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo) => _central.Tell(message, replyTo);
public void SendHeartbeat(HeartbeatMessage message, IActorRef self) => _central.Tell(message, self);
} }
/// <summary> /// <summary>
@@ -148,7 +155,7 @@ public class SiteAuditPushFlowTests : TestKit
var centralCommActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor( var centralCommActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
centralProvider, centralProvider,
new DefaultSiteClientFactory(), Substitute.For<ISiteCommandTransport>(),
TimeSpan.FromSeconds(5)))); TimeSpan.FromSeconds(5))));
centralCommActor.Tell(new RegisterAuditIngest(ingestActor)); centralCommActor.Tell(new RegisterAuditIngest(ingestActor));
@@ -162,14 +169,15 @@ public class SiteAuditPushFlowTests : TestKit
await using var writer = new SqliteAuditWriter( await using var writer = new SqliteAuditWriter(
writerOptions, NullLogger<SqliteAuditWriter>.Instance, nodeIdentity); writerOptions, NullLogger<SqliteAuditWriter>.Instance, nodeIdentity);
// Real SiteCommunicationActor. RegisterCentralClient is given the relay // Real SiteCommunicationActor. Its site→central transport is the in-process bridge that
// standing in for the central ClusterClient. // Tells the central actor (standing in for GrpcCentralTransport dialling CentralControlService).
var siteCommActor = Sys.ActorOf(Props.Create(() => new SiteCommunicationActor( var siteCommActor = Sys.ActorOf(Props.Create(() => new SiteCommunicationActor(
"site-1", "site-1",
new CommunicationOptions(), new CommunicationOptions(),
CreateTestProbe().Ref))); // deployment-manager proxy is unused here CreateTestProbe().Ref, // deployment-manager proxy is unused here
var relay = Sys.ActorOf(Props.Create(() => new ClusterClientRelay(centralCommActor))); null,
siteCommActor.Tell(new RegisterCentralClient(relay)); null,
new BridgeCentralTransport(centralCommActor))));
// The production site audit push client — the unit under integration. // The production site audit push client — the unit under integration.
var auditClient = new ClusterClientSiteAuditClient( var auditClient = new ClusterClientSiteAuditClient(
@@ -0,0 +1,204 @@
using Akka.Actor;
using Akka.Cluster;
using Akka.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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>
/// Real-ActorSystem tests for the simultaneous-cold-start split-brain guard (Gitea #33):
/// <see cref="ClusterBootstrapCoordinator"/> + <see cref="ClusterBootstrapGuard"/>, driven exactly as
/// production drives them — the node's ActorSystem is built from the production <c>BuildHocon</c> output
/// with the guard enabled (which emits an EMPTY seed list, so Akka does not auto-join), then the real
/// coordinator issues the reachability-gated <c>JoinSeedNodes</c>. This subsystem has a history of bugs
/// invisible to pure unit tests, so the load-bearing correctness properties are asserted against running
/// nodes, mirroring <see cref="SelfFirstSeedBootstrapTests"/>.
/// </summary>
/// <remarks>
/// Ephemeral loopback ports share host <c>127.0.0.1</c>, so the port breaks the guard's ordinal
/// <c>host:port</c> tie — <c>Min(port)</c> is the founder, <c>Max(port)</c> is the higher (probing) node.
/// </remarks>
public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime
{
private readonly List<ActorSystem> _systems = new();
private readonly List<ClusterBootstrapCoordinator> _coordinators = new();
/// <summary>Starts a node with the bootstrap guard ENABLED, wired exactly as the composition roots
/// do: the ActorSystem is built with an empty HOCON seed list (guard on) and the real coordinator
/// issues the reachability-gated join. Seeds are listed self-first (as every shipped config is); the
/// guard, not the order, decides founder vs. joiner.</summary>
private async Task<ActorSystem> StartGuardedNodeAsync(int selfPort, int peerPort, int probeSeconds = 4)
{
var self = $"akka.tcp://scadabridge@127.0.0.1:{selfPort}";
var peer = $"akka.tcp://scadabridge@127.0.0.1:{peerPort}";
var nodeOptions = new NodeOptions { Role = "Central", NodeHostname = "127.0.0.1", RemotingPort = selfPort };
var clusterOptions = new ClusterOptions
{
SeedNodes = new List<string> { self, peer },
BootstrapGuard = new ClusterBootstrapGuardOptions
{
Enabled = true,
PartnerProbeSeconds = probeSeconds,
PartnerProbeIntervalMs = 250,
ProbeConnectTimeoutMs = 500,
},
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);
var coordinator = new ClusterBootstrapCoordinator(
() => system, Options.Create(clusterOptions), Options.Create(nodeOptions),
NullLogger<ClusterBootstrapCoordinator>.Instance);
_coordinators.Add(coordinator);
await coordinator.StartAsync(CancellationToken.None);
return system;
}
private static async Task<bool> WaitForUpAsync(ActorSystem system, int expected, TimeSpan timeout)
{
var cluster = Akka.Cluster.Cluster.Get(system);
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected) return true;
await Task.Delay(200);
}
return cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected;
}
/// <summary>
/// The FOUNDER (lower address) with the guard on forms a cluster alone when its partner is dead —
/// exactly as un-guarded self-first does. The guard must not regress the preferred founder.
/// </summary>
[Fact]
public async Task Founder_forms_alone_when_partner_is_dead()
{
var low = TwoNodeClusterFixture.GetFreeTcpPort();
var high = TwoNodeClusterFixture.GetFreeTcpPort();
var founderPort = Math.Min(low, high);
var deadPeerPort = Math.Max(low, high); // nothing listening
var system = await StartGuardedNodeAsync(founderPort, deadPeerPort);
Assert.True(await WaitForUpAsync(system, 1, TimeSpan.FromSeconds(30)),
"the founder (lower address) must self-form immediately when its partner is down");
}
/// <summary>
/// THE LOAD-BEARING CASE. The HIGHER node with the guard on must still cold-start ALONE when its
/// partner is genuinely dead: it probes the (dead) founder, times out, and falls back to self-first.
/// If the guard's peer-first logic were wrong, this node would hang in JoinSeedNodeProcess forever —
/// the very regression a naive "always let the lower node found" rule would introduce.
/// </summary>
[Fact]
public async Task Higher_node_forms_alone_when_partner_is_dead_after_probing()
{
var low = TwoNodeClusterFixture.GetFreeTcpPort();
var high = TwoNodeClusterFixture.GetFreeTcpPort();
var higherPort = Math.Max(low, high);
var deadFounderPort = Math.Min(low, high); // the founder is dead
var system = await StartGuardedNodeAsync(higherPort, deadFounderPort, probeSeconds: 3);
// Must come Up AFTER the probe window (~3s) expires and it falls back to self-first.
Assert.True(await WaitForUpAsync(system, 1, TimeSpan.FromSeconds(30)),
"the higher node must fall back to self-first and form alone once the dead partner never answers the probe");
}
/// <summary>
/// FALSIFIABILITY CONTROL for the test above: the higher node does NOT form alone during the probe
/// window — it is genuinely waiting/probing, not self-forming immediately (which would mean the
/// guard is inert). If this ever starts seeing a member before the window, the probe is not gating.
/// </summary>
[Fact]
public async Task Higher_node_does_not_form_before_the_probe_window_expires()
{
var low = TwoNodeClusterFixture.GetFreeTcpPort();
var high = TwoNodeClusterFixture.GetFreeTcpPort();
var higherPort = Math.Max(low, high);
var deadFounderPort = Math.Min(low, high);
var system = await StartGuardedNodeAsync(higherPort, deadFounderPort, probeSeconds: 10);
// Well within the 10s probe window: still no cluster, because it is probing the dead founder.
Assert.False(await WaitForUpAsync(system, 1, TimeSpan.FromSeconds(3)),
"the higher node must probe for the founder, not self-form immediately");
}
/// <summary>
/// The higher node JOINS a live founder rather than forming a second cluster: start the founder,
/// then the higher node — its probe finds the founder reachable, it commits peer-first, and the
/// pair converges to ONE cluster of two.
/// </summary>
[Fact]
public async Task Higher_node_joins_a_live_founder()
{
var low = TwoNodeClusterFixture.GetFreeTcpPort();
var high = TwoNodeClusterFixture.GetFreeTcpPort();
var founderPort = Math.Min(low, high);
var higherPort = Math.Max(low, high);
var founder = await StartGuardedNodeAsync(founderPort, higherPort);
Assert.True(await WaitForUpAsync(founder, 1, TimeSpan.FromSeconds(30)),
"the founder must be Up before the higher node probes it");
var higher = await StartGuardedNodeAsync(higherPort, founderPort);
Assert.True(await WaitForUpAsync(higher, 2, TimeSpan.FromSeconds(60)),
"the higher node must join the founder, forming one cluster of two");
Assert.True(await WaitForUpAsync(founder, 2, TimeSpan.FromSeconds(60)),
"the founder must see the higher node as a member of ITS cluster");
}
/// <summary>
/// THE HEADLINE #33 CASE. Both nodes cold-start truly simultaneously with the guard on. Without the
/// guard each would run FirstSeedNodeProcess, race, and form its own 1-node cluster (split brain).
/// With it, the founder forms and the higher node probes-then-joins, so the pair converges to
/// EXACTLY ONE cluster of two — no two oldest, no dual singletons.
/// </summary>
[Fact]
public async Task Both_nodes_cold_starting_together_form_exactly_one_cluster()
{
var low = TwoNodeClusterFixture.GetFreeTcpPort();
var high = TwoNodeClusterFixture.GetFreeTcpPort();
var founderPort = Math.Min(low, high);
var higherPort = Math.Max(low, high);
// Start both without serialization — as a shared power event does.
var founderTask = StartGuardedNodeAsync(founderPort, higherPort);
var higherTask = StartGuardedNodeAsync(higherPort, founderPort);
var founder = await founderTask;
var higher = await higherTask;
Assert.True(await WaitForUpAsync(founder, 2, TimeSpan.FromSeconds(60)),
"the pair must converge to one 2-member cluster, not two 1-member clusters");
Assert.True(await WaitForUpAsync(higher, 2, TimeSpan.FromSeconds(60)),
"the higher node must be in the SAME cluster as the founder");
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
foreach (var c in _coordinators)
{
try { await c.StopAsync(CancellationToken.None); } catch { /* teardown */ }
}
foreach (var s in _systems)
{
try { await s.Terminate().WaitAsync(TimeSpan.FromSeconds(10)); } catch { /* teardown */ }
}
}
}
@@ -0,0 +1,219 @@
using System.Data.Common;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// Regression guard for Gitea #28: <see cref="BundleImporter.ApplyAsync"/> must
/// run its multi-<c>SaveChanges</c> apply through the context's execution
/// strategy, because the production central <c>ScadaBridgeDbContext</c> is
/// configured with <c>EnableRetryOnFailure</c> and
/// <c>SqlServerRetryingExecutionStrategy</c> REJECTS a user-initiated
/// transaction opened outside the strategy
/// (<c>CoreStrings.ExecutionStrategyExistingTransaction</c>) — so before the
/// fix every bundle import threw on any real SQL Server.
/// <para>
/// The rest of the Transport suite runs on the in-memory provider, which has NO
/// retrying strategy and where <c>BeginTransactionAsync</c> is a no-op, so it
/// could never surface this. Here we run on SQLite (real relational
/// transactions) AND configure a retrying execution strategy whose only job is
/// to have <c>RetriesOnFailure == true</c>, which arms the exact same guard as
/// the production SQL Server strategy. If <c>ApplyAsync</c> opened the
/// transaction directly, this test would throw before importing anything.
/// </para>
/// <para>
/// Reuses <see cref="SqliteCompatibleScadaBridgeDbContext"/> defined alongside
/// <see cref="BundleImporterRollbackFailureTests"/> in this assembly.
/// </para>
/// </summary>
public sealed class BundleImporterRetryingStrategyTests : IDisposable
{
private readonly ServiceProvider _provider;
private readonly DbConnection _sharedConnection;
public BundleImporterRetryingStrategyTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
// SQLite :memory: is per-connection, so pin one open connection for the
// whole fixture or the schema vanishes between DbContext instances.
_sharedConnection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
_sharedConnection.Open();
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
services.AddSingleton(sp =>
{
var builder = new DbContextOptionsBuilder<ScadaBridgeDbContext>();
// Configure a RETRYING execution strategy — this is the crux of the
// regression. RetriesOnFailure == true (MaxRetryCount > 0) makes EF
// reject any user-initiated BeginTransaction opened outside
// strategy.ExecuteAsync, exactly like the production SQL Server
// EnableRetryOnFailure configuration.
builder.UseSqlite(
_sharedConnection,
sqlite => sqlite.ExecutionStrategy(deps => new TestRetryingExecutionStrategy(deps)));
builder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
return builder.Options;
});
services.AddScoped<ScadaBridgeDbContext>(sp => new SqliteCompatibleScadaBridgeDbContext(
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
// IStaleInstanceProbe (DeploymentManager) + flatten/hash (TemplateEngine)
// so ApplyAsync's StaleInstanceIds computation resolves a real probe.
services.AddTemplateEngine();
services.AddDeploymentManager();
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
_provider.Dispose();
_sharedConnection.Dispose();
}
[Fact]
public async Task ApplyAsync_succeeds_under_a_retrying_execution_strategy()
{
// Arrange: seed → export → wipe → apply(Add). Same shape as
// BundleImporterApplyTests.ApplyAsync_adds_new_artifacts_in_single_transaction,
// but the fixture's context carries a retrying execution strategy.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
// Act: before the fix this threw CoreStrings.ExecutionStrategyExistingTransaction
// ("... does not support user-initiated transactions.") from the direct
// BeginTransactionAsync. With the fix the apply runs inside the strategy.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var resolutions = new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Add, null),
new("SharedScript", "HelperFn", ResolutionAction.Add, null),
};
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
}
// Assert: the whole transactional apply committed.
Assert.Equal(2, result.Added);
Assert.Equal(0, result.Overwritten);
Assert.NotEqual(Guid.Empty, result.BundleImportId);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump"));
Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn"));
// The BundleImported audit row landed inside the committed transaction.
Assert.Equal(1, await ctx.AuditLogEntries.CountAsync(a => a.Action == "BundleImported"));
}
}
// ---- helpers (mirror BundleImporterApplyTests) ----
private async Task<Guid> ExportAndLoadAsync()
{
Stream bundleStream;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: templateIds,
SharedScriptIds: sharedScriptIds,
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
using var ms = new MemoryStream();
await bundleStream.CopyToAsync(ms);
ms.Position = 0;
await using var loadScope = _provider.CreateAsyncScope();
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
private async Task WipeContentAsync()
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.RemoveRange(ctx.Templates);
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
await ctx.SaveChangesAsync();
}
/// <summary>
/// A minimal retrying execution strategy for the test. Its sole purpose is
/// to report <c>RetriesOnFailure == true</c> (inherited from
/// <see cref="ExecutionStrategy"/>, since <c>MaxRetryCount &gt; 0</c>), which
/// arms EF's user-initiated-transaction guard just like the production
/// <c>SqlServerRetryingExecutionStrategy</c>. It never actually retries —
/// <see cref="ShouldRetryOn"/> returns false — so a genuine fault surfaces
/// immediately instead of looping.
/// </summary>
private sealed class TestRetryingExecutionStrategy : ExecutionStrategy
{
public TestRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
: base(dependencies, maxRetryCount: 3, maxRetryDelay: TimeSpan.FromMilliseconds(1))
{
}
protected override bool ShouldRetryOn(Exception exception) => false;
}
}