Files
ScadaBridge/docs/deployment/topology-guide.md
T
Joseph Doherty d641186397 docs(site-secrets): final-review doc fixes — replication prerequisite, upgrade impact, audit shape
I2: topology guide + Component-SiteRuntime + register row 127 state that a site pair using
deployed secrets must run LocalDb replication. I3: row 127 + topology guide state the upgrade
impact (whole artifact deploy fails and reads Out of date until referenced secrets exist
centrally). M6: audit row carries secrets/missing, no removed field; removal-only deploys write
no central row. M2/M3/I1 as-built notes; CLAUDE.md secrets bullet corrected.
2026-09-28 21:08:34 -04:00

41 KiB
Raw Blame History

ScadaBridge Cluster Topology Guide

Architecture Overview

ScadaBridge uses a hub-and-spoke architecture:

  • Central Cluster: Two-node active/standby Akka.NET cluster for management, UI, and coordination.
  • Site Clusters: Two-node active/standby Akka.NET clusters at each remote site for data collection and local processing.
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
    USERS["Users<br/>(HTTPS / LB)"]

    subgraph CENTRAL["Central Cluster"]
        NA["Node A<br/>Active"]
        NB["Node B<br/>Standby"]
        NA <--> NB
    end

    USERS --> NA
    CENTRAL --> SITE01
    CENTRAL --> SITE02
    CENTRAL --> SITE03
    CENTRAL --> SITEN

    subgraph SITE01["Site 01"]
        S01A["A<br/>Active"]
        S01B["B<br/>Standby"]
    end
    subgraph SITE02["Site 02"]
        S02A["A<br/>Active"]
        S02B["B<br/>Standby"]
    end
    subgraph SITE03["Site 03"]
        S03A["A<br/>Active"]
        S03B["B<br/>Standby"]
    end
    subgraph SITEN["Site N"]
        SNA["A<br/>Active"]
        SNB["B<br/>Standby"]
    end

    classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
    classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
    classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
    classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
    classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
    class USERS dec
    class CENTRAL proc
    class NA,S01A,S02A,S03A,SNA start
    class NB,S01B,S02B,S03B,SNB muted
    class SITE01,SITE02,SITE03,SITEN warn

Central Cluster Setup

Cluster Configuration

Both central nodes must be configured as seed nodes for each other:

Node A (central-01.example.com):

{
  "ScadaBridge": {
    "Node": {
      "Role": "Central",
      "NodeHostname": "central-01.example.com",
      "RemotingPort": 8081
    },
    "Cluster": {
      "SeedNodes": [
        "akka.tcp://scadabridge@central-01.example.com:8081",
        "akka.tcp://scadabridge@central-02.example.com:8081"
      ]
    }
  }
}

Node B (central-02.example.com):

{
  "ScadaBridge": {
    "Node": {
      "Role": "Central",
      "NodeHostname": "central-02.example.com",
      "RemotingPort": 8081
    },
    "Cluster": {
      "SeedNodes": [
        "akka.tcp://scadabridge@central-02.example.com:8081",
        "akka.tcp://scadabridge@central-01.example.com:8081"
      ]
    }
  }
}

Seed order is load-bearing — each node lists ITSELF first (decision 2026-07-22). Note Node B's list is the reverse of Node A's. Akka only lets seed-nodes[0] form a new cluster, so a node listing its partner first can never boot while that partner is down. StartupValidator rejects the boot if the ordering is wrong, comparing host and port; use the same spelling of the hostname in NodeHostname and in the seed URI, since Akka does no DNS canonicalisation (central-02 and central-02.example.com are different seed identities). See docs/requirements/Component-ClusterInfrastructure.md → Seed Node Ordering.

Cluster Behavior

  • Split-brain resolver: auto-down (AutoDowning provider, auto-down-unreachable-after = 15s) since the 2026-07-21 availability-over-partition-safety decision — the leader among the reachable members downs the unreachable peer, so a hard crash of either node fails over. Accepted trade: a real partition leaves both sides active until an operator restarts one. keep-oldest (with down-if-alone = on) remains a supported SplitBrainResolverStrategy value, but in a two-node cluster it cannot survive a crash of the oldest node. See docs/plans/2026-07-21-auto-down-availability-decision.md.
  • Minimum members: min-nr-of-members = 1 — a single node can form a cluster.
  • Failure detection: 2-second heartbeat interval, 10-second threshold.
  • Total failover time: ~25 seconds from node failure to singleton migration.
  • Singleton handover: Uses CoordinatedShutdown for graceful migration.

Shared State

Both central nodes share state through:

  • SQL Server: All configuration, deployment records, templates, and audit logs.
  • JWT signing key: Same JwtSigningKey in both nodes' configuration.
  • Data Protection keys: Shared key ring (stored in SQL Server or shared file path).
  • Inbound API keys: shared only when ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer (design 2026-09-02, register row 108) — both nodes then read/write the same zbauth schema in the configuration database, so create/revoke/rotate/scope changes are cluster-wide immediately. The library default, Provider = Sqlite, stores keys in a per-node file instead; running Sqlite on a central pair is a documented misconfiguration — StartupValidator logs a boot-time Warning because a failover then 401s every inbound client until keys are re-issued on the survivor. Both docker rigs run SqlServer; see docs/operations/inbound-api-key-reissue.md → Central pairs to migrate an existing pair.
  • Secrets store: shared only when Secrets:Store:Provider = SqlServer (design 2026-09-28, docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md) — both nodes then read/write the same shared SQL Server secret store, so /admin/secrets create/rotate/delete changes are cluster-wide immediately and both nodes deploy the same secret set to sites regardless of which is active. The library default, Provider = Sqlite, stores secrets in a per-node file instead; running Sqlite on a central pair is a documented misconfiguration — StartupValidator logs a boot-time Warning. See Secrets: shared store, per-site deploy, and the KEK below for the KEK requirement this store does not by itself satisfy.

Load Balancer

A load balancer sits in front of both central nodes for the Blazor Server UI:

  • Health check: GET /health/ready
  • Protocol: HTTPS (TLS termination at LB or pass-through)
  • Sticky sessions: Not required (JWT + shared Data Protection keys)
  • If the active node fails, the LB routes to the standby (which becomes active after singleton migration).

Site Cluster Setup

Cluster Configuration

Each site has its own two-node cluster:

Site Node A (site-01-a.example.com):

{
  "ScadaBridge": {
    "Node": {
      "Role": "Site",
      "NodeHostname": "site-01-a.example.com",
      "SiteId": "plant-north",
      "RemotingPort": 8081
    },
    "Cluster": {
      "SeedNodes": [
        "akka.tcp://scadabridge@site-01-a.example.com:8081",
        "akka.tcp://scadabridge@site-01-b.example.com:8081"
      ]
    }
  }
}

Site Node B reverses this list — site-01-b first, site-01-a second — per the self-first seed rule above. It applies to site pairs exactly as it does to the central pair: without it, site-01-b cannot boot while site-01-a is down.

Site Cluster Behavior

  • Same split-brain resolver as central (auto-down, per the 2026-07-21 decision — see the Central Cluster Behavior note above).
  • Singleton actors: Site Deployment Manager migrates on failover.
  • Staggered instance startup: 50ms delay between Instance Actor creation to prevent reconnection storms.
  • SQLite persistence: each node owns its own consolidated LocalDb database, kept in step by asynchronous CDC replication over a gRPC sync stream (LocalDb Phase 1 + 2). The nodes do NOT share a SQLite file.
  • CDC capture triggers are installed only on a node that has replication configured — LocalDb:Replication:PeerAddress or LocalDb:Replication:ApiKey. Either key counts, because only the initiating half of a pair sets PeerAddress (one bidirectional stream, dialled by one side); the passive half carries the key alone. A deliberately unreplicated node — site-b and site-c on the rig — runs with no triggers at all and stops paying the per-write capture cost.
  • Stale-trigger cleanup is automatic (LocalDb 0.2.0). A node with no replication configured does not merely skip registration — at boot it calls DeregisterReplicated on all ten tables, dropping any capture triggers an earlier build installed and pruning those tables' oplog and row-version rows. It is idempotent, so a file that was never registered reports nothing to clean; when something was cleaned the node logs it once at Information. Recreating the data volume is no longer required to stop an in-place-upgraded node from capturing.

Turning replication ON for a site that has been running without it

Supported as of LocalDb 0.2.0. Set the keys on both nodes and restart both (see the stop-and-start-together rule below — this is a pair-wide change, not a rolling one). Existing rows are carried across:

  • Pre-existing rows are baselined automatically. ScadaBridge registers every replicated table with baselineExistingRows: true. Capture is change-data-capture, so rows written while the node had no triggers appear in neither the oplog nor __localdb_row_version — and LocalDb's snapshot resync streams from that ledger. Baselining seeds the ledger for those rows at the LWW floor (HLC 0, stamped with the node's own id) and flags a snapshot resync, so the peer actually receives them. Copying one node's database onto the other beforehand is no longer necessary.
  • What the floor means for conflicts. Every genuine HLC is a UTC millisecond shifted left 16 bits, so it is strictly greater than 0: a baselined row loses to any real remote write of the same key and wins only where the peer holds no version of that key at all. The one ambiguous case is both nodes baselining the same key (e.g. both were restored from the same legacy file) — both hold HLC 0 and the node-id tie-break decides. That is convergent but arbitrary as to which content survives, so if the two files may disagree on a key, start both nodes from one node's database.
  • Seeding is idempotent (ON CONFLICT DO NOTHING), so a row that already has a genuine version keeps it and no snapshot is flagged. Booting with baselining on every start is free after the first.
  • The flag is per-table as of LocalDb 0.3.0. Turning replication on for the first time still seeds all ten tables at once, so the flagged set is every registered table and the pair exchanges an ordinary full snapshot — that part is unchanged, so a site whose site_events table is at its 1 GB cap should still be purged before the first enable, and one slow first sync expected. What changes is every later edit: adding an eleventh table to SiteLocalDbSetup.ReplicatedTables on a site that is already replicating now snapshots that one table, where through 0.2.x the single per-database flag re-streamed all eleven in full, in both directions at once. Upgrading a replicating pair to 0.3.0 in place is a no-op for this: the ten tables are already ledgered, so nothing seeds and nothing is flagged.
  • Mixed 0.2.x/0.3.0 versions still sync. Scoped snapshots are negotiated per session by capability, and a peer that does not advertise it is sent a full snapshot — so the pin bump does not have to be simultaneous for replication's sake. (It still has to be simultaneous for the stop-and-start-together rule below, which is a separate constraint.)

Turning replication back OFF is likewise a both-nodes change: deregistration must be symmetric, because the sync handshake compares the two nodes' registered-table digests fail-closed — a node that drops a table its peer still replicates stops syncing with a schema-mismatch error rather than diverging silently. Turning it on again later re-baselines, which is what makes the ledger prune on deregistration safe.

Reading the replication backlog — the daily site_events purge burst

The site health report carries LocalDbReplicationConnected and LocalDbOplogBacklog (nullable — null means "no reading", not "disconnected with an empty backlog"), and the same numbers export as the localdb_* Prometheus series (localdb_oplog_depth is the backlog gauge). A healthy pair sits at a backlog of roughly zero, so a sudden spike naturally reads as a replication problem.

One expected spike is not a problem: the daily site_events retention purge. site_events is one of the ten replicated tables, and its retention DELETE is captured by CDC exactly like an ordinary write — by design, since LocalDb Phase 2 there is deliberately no purge-exemption path (the same property that makes a mass DELETE dangerous, which is why ReplaceAllAsync was deleted rather than reinstated). One oplog row is therefore queued per deleted event, and the backlog jumps by the size of the batch the tick deleted.

The spike is bounded per tick (register row 34, measured: docs/plans/2026-08-15-site-events-purge-burst-measurement.md). One purge tick deletes at most ScadaBridge:SiteEventLog:MaxPurgedRowsPerTick rows — default 250 000, across the retention purge and the cap trim combined — so the height of the spike is a property of the configuration, not of how much history had expired. That matters because the burst used to be unbounded: a full 1 GB event log holds ~2.73 million rows, and expiring all of them at once queued 2 731 947 oplog rows, 2.7x LocalDb's MaxOplogRows (default 1 000 000) — enough for the maintenance tick to prune the oplog and flag a full ten-table snapshot resync. A healthy peer blunts that (the oplog drains while the purge runs — measured peak was 391 000, not 2.73 M), which is precisely the problem: the old safety margin was a function of how fast the peer happened to be, and vanished when the peer was down. Set MaxPurgedRowsPerTick to 0 to restore the old unbounded behaviour.

  • When. Every ScadaBridge:SiteEventLog:PurgeInterval (default 24 h), plus once at startup — except after a tick that hit the ceiling, which schedules the next tick after ScadaBridge:SiteEventLog:PurgeCatchUpInterval (default 5 minutes) instead. So a large backlog appears as a run of bounded spikes minutes apart rather than one huge one; a worst-case 2.73 M-row backlog converges in ~11 ticks ≈ 55 minutes. The cadence is anchored to the end of the previous purge, not to a wall-clock hour, so the burst lands at a different time of day after each failover or restart — do not expect it at a fixed hour. The storage-cap trim (default 1 GB) can produce the same shape off-schedule, and draws from the same per-tick budget.
  • Where it shows. Only on a node with replication configured — the rig's site-a. site-b/site-c have no capture triggers at all and report no backlog for a purge.
  • What healthy looks like. LocalDbReplicationConnected stays true across the spike, and the backlog drains back to ~0 as the peer acks the batch. Measured on a loopback pair at ~10 800 tombstones/second, so a full 250 000-row tick drains in well under a minute (delta messages are bounded by LocalDb:Replication:MaxBatchBytes, default 2 MB, and secondarily by MaxBatchSize). No dead letters, no schema-mismatch errors.
  • What is actually wrong. LocalDbReplicationConnected false while the backlog climbs, a backlog that keeps rising across successive readings rather than draining, or a backlog that never returns near zero between bursts. Those point at the sync stream — an ApiKey mismatch (fail-closed: the pair simply stops converging), an unreachable peer, or an asymmetric registered-table set.

Correlating it in the log. When a purge on a replication-enabled node actually deletes rows it logs an Information line next to the purge count:

Purged 41230 events older than 30 days
Purged 41230 site_events rows on a replication-enabled node — a transient LocalDb oplog backlog
of up to that many rows is expected while the deletes replicate to the peer. It drains on its own;
LocalDbReplicationConnected staying true with the backlog returning to ~0 is the healthy signature.
A tick deletes at most 250000 rows, so the spike is bounded by that number rather than by how much
history had expired.

An unreplicated node logs only the first line. If a backlog spike has no such line near it in the active node's log, the purge is not the explanation and the spike is worth investigating.

The second line is emitted once per purge tick, and its count covers the retention purge and the storage-cap trim together. If you are comparing it against the Purged N events older than… line above it, expect the two numbers to differ whenever the cap trim also ran — the note reports the tick's whole contribution to the oplog, which is what a backlog spike is made of. (Through 2026-08-16 the note was emitted per phase, so a tick that ran both produced two near-identical lines; register row 84.)

A tick that hit the ceiling adds a Warning, rate-limited to one line per 15 minutes so a converging backlog does not flood the log:

Event log purge stopped at its per-tick ceiling of 250000 rows (250000 deleted); expired events
remain and the next purge runs in 00:05:00 instead of 1.00:00:00.

Every hit — throttled line or not — increments the counter scadabridge_site_event_log_purge_ceiling_hits. Read the counter's shape, not a single hit: a short run of hits that stops is a backlog converging (normal, e.g. the first purge after enabling replication on a long-running site); hits that never stop mean the site writes events faster than one paced tick can purge, i.e. MaxPurgedRowsPerTick or PurgeCatchUpInterval is mis-sized for that site's event rate.

Two dashboard surfaces make this reviewable rather than a log-archaeology exercise (register row 33):

  • The "Replication backlog" badge on the site tile of the Health dashboard. It is deliberately a sustained signal, not a bare threshold: the badge appears only once the backlog has stayed above ScadaBridge:HealthMonitoring:LocalDbBacklogThreshold (default 1000) continuously for ScadaBridge:HealthMonitoring:LocalDbBacklogSustainedFor (default 15 minutes). The daily purge burst described above drains in seconds to a couple of minutes, so it never reaches the dwell and never badges the site — which is the entire reason the dwell exists. The per-tick ceiling reinforces that rather than threatening it: pacing lowers the spike (250 000 rows maximum instead of an unbounded 2.7 M) at the cost of more ticks, and a 250 000-row tick was measured draining in well under a minute — even allowing 10x for a real inter-node link that is ~4 minutes, against a 15-minute dwell and a 5-minute gap to the next tick. (The unpaced 2.73 M burst drained in 6.0 minutes on loopback: inside the dwell, but with only 2.5x of margin, so a materially slower link would have badged.) A bare threshold would fire once a day on every replicated site and be ignored within a week. The dwell is validated to be at least one ReportInterval, since a shorter window could be satisfied by a single sample and would collapse back into a bare threshold.
  • The "LocalDb Replication Backlog" trend chart in the dashboard's per-site Site Health Trends panel, backed by the KPI history store (sampled every minute, retained 90 days raw / 365 days hourly). This is what answers the question a point-in-time reading cannot: did that spike drain? A past purge burst appears as a spike that returns to ~0; a real fault appears as a level that never comes back down.

Both surfaces are silent on an unreplicated site, by design. LocalDbOplogBacklog and LocalDbReplicationConnected are nullable, and null means "no reading", never zero. The KPI sampler therefore writes no sample at all rather than a fabricated 0, so site-b/site-c show an empty chart rather than a flat zero line, and they never accumulate the dwell anchor the badge keys off. A gap in those series means "not replicated / not reporting" — which is the truth. Note that false and null differ for the connected flag: a configured link that is DOWN samples as 0, while no link at all samples as nothing.

Site Pair Upgrades — stop and start BOTH nodes together

A rolling upgrade of a site pair, one node at a time, is no longer supported. It worked while the bespoke replicator kept a legacy SfBufferSnapshot compatibility handler so a new standby could still apply an old active node's monolithic snapshot. LocalDb Phase 2 deleted that handler along with the replicator, so a mixed-version pair has no common replication path: the two nodes will run, but they will not converge, and the divergence is silent.

Stop both nodes of a site pair, upgrade both, then start both.

A release that adds a column to a replicated table is the same constraint, enforced louder. ReplicatedTable.ComputeDigest hashes every column name and declared type, and the sync handshake compares those digests fail-closed — so a mixed-version pair whose builds disagree about a table's columns does not diverge silently, it refuses to sync with a LocalDbSchemaMismatchException. Stopping and starting both nodes together, which is already the rule above, is the whole fix; no manual trigger or oplog migration is needed, because RegisterReplicated re-reads PRAGMA table_info on every boot and rebuilds the capture triggers (DROP + CREATE) over the current column set. The site schema's own AddColumnIfMissing runs before registration on that same boot, so a node picks up the new columns and the matching triggers in one start. (Most recent instance: external_systems gained max_retries / retry_delay_ms for the per-system store-and-forward retry policy.)

Related bound — do not leave one node of a pair offline for long. A node absent for longer than LocalDb:Replication:TombstoneRetention (default 7 days) can resurrect deleted rows when it rejoins: deletes replicate as HLC-ordered tombstones, and once a tombstone is pruned there is nothing left to suppress the stale row the returning node still holds. Within the retention window a rejoin is safe and self-correcting (verified live: a node stopped and restarted mid-load rejoined with both nodes byte-identical and zero duplicates). Beyond it, rebuild the returning node's database from its peer rather than letting it rejoin.

Site LocalDb files created before 2026-08-20 need a one-off offline VACUUM

SQLite honours PRAGMA auto_vacuum only on a table-less database, and until arch-review 2026-08-19 P4-6 the pragma ran AFTER LocalDb had created its bookkeeping tables — so every site file created before that fix is auto_vacuum=NONE, and the event-log cap trim's PRAGMA incremental_vacuum is a silent no-op on it: freed site_events pages are reused, but the file never shrinks (deferred-work register row 104). Fresh files are now created auto_vacuum=INCREMENTAL; existing files need a full VACUUM with the pragma set, which rewrites the file and is therefore deliberately NOT done at boot (minutes of exclusive lock on a ~1 GB file).

Run it in a maintenance window with both nodes of the pair stopped (the rule above):

DRY_RUN=1 bash docker/vacuum-site-localdb.sh --all   # report: 0 = NONE, 2 = INCREMENTAL
bash docker/vacuum-site-localdb.sh a                 # one pair: stop both → VACUUM both → start both

The script integrity-checks each file before and after and leaves the pair stopped if the post-check fails. It is safe for a replicated pair: VACUUM fires no triggers and changes no row content, the LWW ledger keys by declared primary key, and the oplog seq watermark is an INTEGER PRIMARY KEY AUTOINCREMENT — all preserved by VACUUM. Verified on the docker rig 2026-08-20 on all three pairs: site-a (replicated) came back LocalDbReplicationConnected=true with backlog 0 and no schema or watermark change. For a non-docker deployment the recipe is the same three statements against the stopped node's LocalDb:Path file: PRAGMA auto_vacuum = INCREMENTAL; VACUUM; PRAGMA integrity_check;.

Docker-rig data files on macOS/OrbStack — a virtiofs corruption + availability hazard

Status: MITIGATED 2026-08-18 (register row 79) — the rigs' data dirs moved to named docker volumes, taking virtiofs out of the SQLite write path entirely. The hazard description and the proven recovery runbook below are retained because they explain why the volumes exist, and because failure mode 2 (the VM idle-sleep) is unaffected by the move.

Scope: the local docker/ and docker-env2/ development rigs on macOS only. This is a host-level property of the filesystem the rig's data files sit on, not a ScadaBridge defect and not something a production Linux deployment is exposed to. It is documented here because it has already cost two databases and half a session, and because the recovery is non-obvious.

Until 2026-08-18, every ScadaBridge data file on the rig rode a relative bind mount out of the repo working tree — ./site-a-node-a/data:/app/data and its siblings, which is where site-localdb.db, auditlog.db and the secrets store all live. On macOS that path crosses virtiofs to reach the Linux VM, and virtiofs does not give SQLite true fsync durability. SQLite's crash-safety contract assumes a successful fsync means the bytes are on stable storage; when that is not true, an abrupt container kill mid-write can leave a torn file. docker compose up --force-recreate — which deploy.sh uses — is exactly such an abrupt kill. Today the same paths are named volumes (scadabridge-<node>-data, scadabridge-env2-<node>-data) on native ext4 inside the OrbStack VM, where fsync is real.

Two distinct failure modes have been observed live (both 2026-08-15):

1. Silent database corruption. Both site-a LocalDb files were found malformed:

SQLite Error 11: 'database disk image is malformed'

Note how differently the two nodes presented, because this is the part that makes it hard to spot:

  • site-a-b surfaced it as a running fault — every sync call threw, replication reported connected: false with a backlog of 892, and the faulted session retried about once a minute. The node stayed up and otherwise healthy-looking.
  • site-a-a was corrupt too, but latently — nothing on the running build ever read the damaged pages. It only became fatal on the next upgrade, when a new build's boot-time re-registration read pages the old build never touched: SqliteLocalDb construction threw and the container SIGABRT crash-looped (×12).

The lesson to carry: a clean-looking node proves nothing about its file. Corruption can sit latent until an unrelated code change happens to read further into the database.

2. The OrbStack VM idle-sleeping mid-operation. The VM can suspend under its idle policy while work is in flight. ~/.orbstack/log/vmgr.log records it plainly:

vmgr ... msg=sleep

On wake, the virtiofs layer logs repeated errors of the form:

refresh_nodeid: race with new lookup

and docker hangs — commands stop returning, docker ps comes back empty — for as long as it takes to notice. The fix is orb restart docker; data survives and containers auto-restart. Observed cost: roughly 20 minutes of a session lost to diagnosing what looked like a cluster failure but was a host one. If the rig goes unresponsive as a whole, check vmgr.log before investigating ScadaBridge.

Recovery runbook — LocalDb corruption on a site pair (proven)

This is the procedure that recovered site-a. It relies on the pair being able to reseed itself, so it is a replicated pair's recovery; an unreplicated node has no peer to reseed from and must be restored from a backup instead.

  1. Stop BOTH nodes of the site pair, together. Not one at a time — this is the same stop-and-start-together rule as Site Pair Upgrades above, for the same reason.

  2. Quarantine the database and its sidecars — do not delete them. Rename all three, keeping them for forensics. The data dir now lives in the node's named volume, so run the renames through a throwaway container (don't write through the host-side ~/OrbStack view):

    # per affected node — volume name is scadabridge-<node>-data
    docker run --rm -v scadabridge-site-a-node-a-data:/d alpine sh -c '
      cd /d
      mv site-localdb.db     site-localdb.db.corrupt-$(date +%F)
      mv site-localdb.db-wal site-localdb.db-wal.corrupt-$(date +%F)
      mv site-localdb.db-shm site-localdb.db-shm.corrupt-$(date +%F)'
    

    The -wal and -shm files must move with the main file. Leaving a stale WAL beside a fresh database is its own corruption.

  3. Start both nodes together. The fresh files reseed themselves from two independent paths: SiteReconciliationActor's node-startup fetch pulls config back from central when central reports gaps, and LocalDb's CDC snapshot resync merges the peer's rows under LWW.

  4. Verify convergence, do not assume it: LocalDbReplicationConnected should read true and LocalDbOplogBacklog should settle to ~0 on the site health report. (See Reading the replication backlog above for why a transient spike is normal and not a fault.)

On the live recovery, both quarantined files were large (394 MB and 416 MB), the pair converged to connected: true / backlog 0, and a schema change in flight at the time additionally proved the digest handshake green.

Mitigation applied (2026-08-18): named docker volumes

The "accept + runbook" position was retired without waiting for a second corruption. All ten data dirs (8 docker/ nodes + 2 docker-env2/ site nodes) now ride named volumes (scadabridge-<node>-data / scadabridge-env2-<node>-data), declared in each compose file with explicit name:s and seeded from the old bind-mount dirs at migration time. What changed the original trade-off calculus: the assumed cost — losing host-side inspectability — turned out to be much smaller than documented, because OrbStack exposes every volume on the Mac at ~/OrbStack/docker/volumes/<name>/, so site-localdb.db can still be opened from the host for read-only inspection. (Never write through that path: it reintroduces exactly the virtiofs layer the volumes exist to avoid.) The appsettings and logs bind mounts deliberately stayed: config is read-only, and Serilog's append-only log files have no fsync-durability requirement worth losing tail -f from the repo tree for.

Residual exposure: failure mode 2 is availability-only and still real — the VM can still idle-sleep mid-operation and hang docker until orb restart docker; data now survives that on ext4. Disabling OrbStack's idle pause remains an un-version-controllable host setting and was left alone. The old docker/<node>/data/ dirs were left in place as a dormant pre-migration backup; they are no longer mounted and can be deleted once the volumes have proven themselves.

Central-Site Communication

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:

  • gRPC command/control — both directions, on sticky-failover channel pairs, dialled directly (no receptionist, no "active central" to identify — each side dials both of the peer's node endpoints):
    • Site → central to the central-hosted CentralControlService (GrpcCentralTransport): the site lists the central nodes' gRPC endpoints in ScadaBridge:Communication:CentralGrpcEndpoints (e.g. http://scadabridge-central-a:8083, the central's CentralGrpcPort, default 8083 — direct 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.

gRPC control-plane preshared key (required)

Every site node must set ScadaBridge:Communication:GrpcPsk, and central must hold the same value for that site. StartupValidator refuses to boot a site node without it, deliberately: the gate is fail-closed, so an unset key would leave the node joined, healthy-looking and answering heartbeats while refusing every gRPC call — no live subscriptions, no audit pull, no cached-telemetry ingest.

Side Where the key lives
Site node (both nodes of the pair, identical) ScadaBridge:Communication:GrpcPsk, in production ${secret:SB-GRPC-PSK-<siteId>}
Central secret SB-GRPC-PSK-<siteId> in its store — or ScadaBridge:Communication:SitePsks:<siteId>

The store is the source that matters in production, because sites are added at runtime and their keys cannot be enumerated in configuration at boot; SitePsks covers a host running without a master key (the docker rig) and one-off pins.

One key per site, never one for the fleet: a compromised site must not yield another site's key. And never share it with LocalDb:Replication:ApiKey — that authenticates the pair partner for database replication, a different trust relationship on the same listener.

Rotation: set the new value on both sides, then restart the pair (pairs restart together anyway — see above). Upgrading to a build that has this gate requires seeding the key first, including in the on-host deploy/ overlays.

The key is a bearer token over plaintext h2c, so it is readable and replayable by anyone on the path. That is the accepted posture today — the same trusted-network assumption the boundary already made, now with authentication rather than none. TLS on these listeners is follow-on hardening and needs no change to the key design.

Secrets: shared store, per-site deploy, and the KEK

Site secrets ride the artifact deploy (2026-09-28, docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md; see that doc's Implementation notes (as built) for where the shipped code deviates from the original design). This section is the operational summary; component detail lives in Component-Host.md → Secrets, Component-DeploymentManager.md → Site secrets, and Component-SiteRuntime.md → Deployed Secrets.

  • Central: Secrets:Store:Provider = SqlServer on both central nodes — see Shared State above. SiteSecretsCollector resolves each site's referenced secrets from that store at deploy time; a missing/tombstoned secret fails that site's deploy before anything is sent.
  • Every node must share the same KEK (ZB_SECRETS_MASTER_KEY / master-key file) — every central node and every site node, not just the central pair. This is the same requirement the retired gRPC secrets hub had; it did not change. Nothing checks this at boot or at the next reconnect — a mismatch surfaces only at deploy time, on the site: DeployedSecretsMirror's verify step trial-decrypts every applied secret and reports a distinct error naming the secret and its KekId if the local master key cannot open it. Verify the KEK is identical everywhere before turning this on for a site — the rig's docker/docker-compose.yml deliberately runs site-b/site-c on a mismatched KEK precisely to exercise this failure path live.
  • Standby / passive-node convergence. The active site node applies a deploy's secrets synchronously (so the deploy response reflects that apply immediately); the standby node never receives that synchronous call — it converges through ordinary LocalDb replication of the deployed_secrets table plus its own DeployedSecretsMirror running on its own schedule, ScadaBridge:SiteRuntime:DeployedSecretsMirrorInterval (default 15s). A freshly-promoted standby is therefore current on secrets within one interval of the last deploy, not instantly.
  • A site pair that uses deployed secrets MUST run LocalDb replication (LocalDb:Replication:* configured on both nodes — see Reading the replication backlog). The standby's only path to the secrets is the replicated deployed_secrets table. On an unreplicated pair only the node that was active at deploy time holds the secrets; after a failover the new active node has none (or a stale hand-seeded copy) until the next Deploy Artifacts, and a removal applied while one node was active leaves the secret live on the other. The Artifacts badge still reads Current — the stamp is per site, not per node. If a pair cannot be replicated, re-run Deploy Artifacts after every failover.
  • Secret rotation and live connections. An artifact deploy writes and mirrors the shipped secrets before it pushes data-connection definitions to the DCL, so a connection created or reconfigured by the same deploy resolves a newly shipped secret on its first connect. A rotation that leaves the connection's config unchanged does not recreate the connection (the push is hash-guarded): the new value takes effect on that connection's next (re)connect.
  • Rollout is a deployment record, not a code change — see the deferred-work register (row 127) for the vd03/QA prerequisites (shared store provisioning, Provider=SqlServer on both centrals, the KEK alignment check above, LocalDb replication on every site pair that uses secrets, recreating any hand-seeded site secrets centrally, then Deploy Artifacts).
  • Upgrade impact — do the prerequisites in the same window as the upgrade. Once a central runs this build, any site whose data connections carry a secret: reference fails its whole artifact deploy (shared scripts, external systems and DB connections included, not just secrets) and its Artifacts badge reads Out of date, until every referenced secret exists in the central store. Before upgrading an environment, check whether its connection configs use secret: references and whether its site pairs run LocalDb replication.
  • The gRPC secrets hub is retired in ScadaBridge — Secrets:Replication:Enabled now fails startup on either role (Component-Host.md → Secrets). QA and vd03 currently run Replication:Enabled=false, so the boot itself will not fail fast — the deploy-time impact above is what bites.

Scaling Guidelines

Target Scale

  • 10 sites maximum per central cluster
  • 500 machines (instances) total across all sites
  • 75 tags per machine (37,500 total tag subscriptions)

Resource Requirements

Component CPU RAM Disk Notes
Central node 4 cores 8 GB 50 GB SQL Server is separate
Site node 2 cores 4 GB 20 GB SQLite databases grow with S&F
SQL Server 4 cores 16 GB 100 GB Shared across central cluster

Sizing a site's data volume — MaxStorageMb is not the whole story

ScadaBridge:SiteEventLog:MaxStorageMb (default 1 GB) bounds site_events and its four indexes, and nothing else. It is deliberately not a cap on the consolidated LocalDb file (register row 81 — decision record docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md). Before that change it did measure the whole file, which on a replicated node made the cap unenforceable: the trim's own CDC oplog rows and tombstones grew the file as fast as the deletes shrank it, and the trim stopped 2.5x over cap. Tying the event log's budget to the peer's liveness was the defect, not a feature worth preserving.

So budget the volume as the sum of:

  • MaxStorageMb — the event log proper.
  • LocalDb replication bookkeeping — __localdb_oplog and __localdb_row_version, bounded separately by LocalDb:Replication:MaxOplogRows (default 1 000 000) and TombstoneRetention (default 7 days). This is transient: it drains as the peer acks and as tombstones expire, but it is not small during a burst. Measured worst case (register row 34): purging a full 1 GB event log left 707 MB of bookkeeping in the file. Unreplicated sites carry none of this — they have no capture triggers at all.
  • The other eight consolidated tables plus the separate, unreplicated audit database at AuditLog:SiteWriter:DatabasePath.

The 20 GB figure above has ample headroom for all three at default settings. Raising MaxStorageMb on a replicated site should be paired with a look at the oplog settings, not done in isolation.

Network Bandwidth

  • Health reports: ~1 KB per site per 30 seconds = negligible
  • Tag value updates: Depends on data change rate; OPC UA subscription-based
  • Deployment artifacts: One-time burst per deployment (varies by config size)
  • Debug view streaming: ~500 bytes per attribute change per subscriber

Dual-Node Failure Recovery

Scenario: Both Nodes Down

  1. First node starts: Forms a single-node cluster (min-nr-of-members = 1).
  2. Central: Reconnects to SQL Server, reads deployment state, becomes operational.
  3. Site: Opens SQLite databases, rebuilds Instance Actors from persisted configs, resumes S&F retries.
  4. Second node starts: Joins the existing cluster as standby.

Automatic Recovery

No manual intervention required for dual-node failure. The first node to start will:

  • Form the cluster
  • Take over all singletons
  • Begin processing immediately
  • Accept the second node when it joins