# LocalDb pair replication — operations runbook

> **Scope.** Every driver-role OtOpcUa node keeps a consolidated
> [`ZB.MOM.WW.LocalDb`](../../CLAUDE.md) SQLite database. It holds two things: the
> **deployed-configuration artifact** (chunked, Phase 1) so a node can **boot from cache when
> central SQL Server is unreachable**, and the **alarm store-and-forward buffer** (Phase 2) so a
> node that dies holding undelivered alarm history does not take it to the grave. Both can
> *optionally* replicate to the node's redundant pair peer over the library's gRPC sync.
> **Replication is default-OFF and fail-closed.** This runbook covers enabling it, the operational
> rules that keep a pair converging, and the DB-inspection safety rules.

## What replicates, and what it buys you

- **Replicated tables:** `deployment_artifacts` (the artifact, split into base64 chunks),
  `deployment_pointer` (one current-deployment pointer per cluster), and `alarm_sf_events` (the
  alarm store-and-forward buffer). Registered — in this order, after the DDL — by
  `LocalDbSetup.OnReady`.
- **The payoff:** in a redundant driver pair, *either* node can be the one that applied the latest
  deploy and wrote the cache. With replication on, the peer holds a byte-identical copy. So a node
  that restarts into a central-SQL outage can boot the current config **even if it never applied
  that deploy itself** — its peer did, and the row replicated.
- **Convergence model:** last-writer-wins per primary key, HLC-stamped. Both nodes also store
  locally after every apply, so the pair converges to identical content regardless of which node
  deployed. Retention (newest-2 deployments per cluster) prunes on one node and the deletes
  replicate as tombstones.

## Alarm store-and-forward — what replication changes

Phase 2 moved the alarm buffer out of its own `alarm-historian.db` and into this database. Three
consequences an operator needs to know:

- **Only the Primary drains.** The buffer replicates, so the Secondary holds a full copy of the
  Primary's queue. Its drain worker is gated on the same Primary decision the inbound-write and
  native-ack gates use, and reports `DrainState = NotPrimary`. **A rising queue depth on a
  Secondary is correct.** A rising queue depth on *both* nodes is not — see below.
- **Delivery is at-least-once across a failover, by design.** Rows the old Primary delivered
  moments before it died may not have had their deletes replicated yet, so the new Primary will
  re-send them. This is accepted, not a defect: a duplicate alarm-history row is recoverable, a
  missing one is not. There is deliberately no dedup layer.
- **The buffer is bounded.** `AlarmHistorian:Capacity` (1,000,000 by default) still evicts the
  oldest undelivered rows when the historian has been unreachable long enough. Watch
  `EvictedCount` — non-zero means accepted alarm events were dropped before reaching the historian.

### If BOTH nodes report `NotPrimary`

Nobody is draining, and the queue is filling toward the capacity ceiling on both. The gate
default-DENIES while the redundancy role is unknown *and* a driver peer exists, so this is what a
redundancy snapshot that never names either node looks like — the identity-mismatch shape
`DriverHostActor` also warns about once ("redundancy snapshot omitted this node"). Check node
identity (`Cluster:PublicHostname` / `Cluster:Port` skew) before suspecting the sink.

### One-time migration from `alarm-historian.db`

On first boot after the upgrade, `AlarmSfLegacyMigrator` copies any pre-existing
`alarm-historian.db` into `alarm_sf_events` and renames the file to `alarm-historian.db.migrated`.

- **Both nodes of a pair migrate independently**, and their files overlap — row ids are derived
  from the event payload, so the same event on both nodes converges to one row rather than
  duplicating. The new Primary drains the union.
- **The rename happens only after the copy commits.** A failure fails host startup with the legacy
  file untouched; the `.migrated` sidecar is what makes a later boot a no-op.
- **A file whose shape is unrecognised is left alone** — not renamed, not copied — so it stays
  available for inspection.
- The `AlarmHistorian:DatabasePath` key is **removed**. It is still *read* by the migrator, to
  find the legacy file; it no longer configures anything. Delete it from appsettings once the
  `.migrated` sidecar exists on every node.

## Enabling replication on a pair

Replication has two knobs, both under `LocalDb`:

| Key | Role | Notes |
|---|---|---|
| `LocalDb:SyncListenPort` | binds the dedicated **h2c** sync listener | `0` (default) = no listener, replication off. Set the **same** non-zero port on **both** nodes of the pair. |
| `LocalDb:Replication:PeerAddress` | the address this node **dials** | Set on **one** node (the initiator); leave empty on the other (passive). The stream is bidirectional — one dial carries both directions. |
| `LocalDb:Replication:ApiKey` | bearer token for the sync stream | **Must be byte-identical on both nodes.** See fail-closed rule below. Supply via `${secret:...}` / env — never a cleartext literal in production config. |
| `LocalDb:Replication:MaxBatchSize` | rows per replication batch | `16` for the artifact cache. Batching is **row-count-only** against gRPC's 4 MB message cap; artifact chunk rows are ≈171 KB, so 16 × 171 KB ≈ 2.7 MB stays under the cap. Raising it risks tripping the cap. |

Example (site-a-1 initiator, site-a-2 passive):

```
site-a-1:  LocalDb__SyncListenPort=9001
           LocalDb__Replication__PeerAddress=http://site-a-2:9001
           LocalDb__Replication__ApiKey=${secret:site-a-localdb-sync-key}
           LocalDb__Replication__MaxBatchSize=16
site-a-2:  LocalDb__SyncListenPort=9001
           LocalDb__Replication__ApiKey=${secret:site-a-localdb-sync-key}   # no PeerAddress
           LocalDb__Replication__MaxBatchSize=16
```

> **Kestrel note.** Setting `SyncListenPort` makes the Host add an explicit `Listen*` for the h2c
> sync listener. An explicit `Listen*` makes Kestrel **ignore `ASPNETCORE_URLS` entirely**, so the
> Host also re-binds the primary HTTP port in the same block. If you change the primary port,
> verify both `:<httpPort>` and `:<syncPort>` appear in the startup "Now listening on" lines.

### The ApiKey rule (fail-closed)

The replication library's passive endpoint verifies **no** authentication — the host
`LocalDbSyncAuthInterceptor` is the only gate, and it is **fail-closed**:

- **No key configured ⇒ every sync call is refused** (`PermissionDenied`). "No key" is never
  "no auth required".
- **A key mismatch ⇒ the pair silently stops converging.** The initiator's stream is rejected at
  the peer; nothing errors loudly. A typo in one node's key looks exactly like "replication is
  broken". If a pair is not converging, **check the keys match first.**

## Operational rules

- **Stop / start the pair together where you can.** Each node keeps working (and caching locally)
  while its peer is down; the outage is not a data-loss event — the surviving node accumulates
  writes and the peer catches up on rejoin. But a long-lived solo node drifts further from its
  peer, so avoid leaving a pair split for extended periods.
- **Tombstone-retention resurrection window.** Retention prunes to the newest 2 deployments and
  replicates the prune as tombstones. Tombstones are themselves retained only for a bounded window.
  If a node is offline **longer than the tombstone-retention window**, a delete that happened during
  its outage may no longer be expressible as a tombstone on rejoin — a pruned deployment could
  briefly reappear until the next deploy re-prunes it. Keep pair outages well inside that window.
- **A rebuilt node is back-filled by its peer.** If a node loses its LocalDb file — a wiped volume,
  a re-imaged host, a fresh container — it rejoins with an empty database and its peer snapshots the
  cached configuration back to it, with no new deploy and no central SQL. Two things are worth
  knowing: this needs **`ZB.MOM.WW.LocalDb` ≥ 0.1.3**, and it heals the *cache*, so the node regains
  boot-from-cache for future outages. (On `0.1.1` a converged pair has pruned every oplog row on ack
  and the wiped node was never snapshotted — it stayed empty until the next deploy. On `0.1.2` it is
  back-filled but its OWN writes are silently dropped until its restarted seq counter climbs past the
  peer's stale watermark, so the pair looks converged right up until the moment only the rebuilt node
  applies a deploy.) A node
  wiped **during** a central outage is still repopulated by its peer under 0.1.2; a node with **no**
  peer replication configured self-heals only from central on its next successful apply.

- **What boot-from-cache does NOT cover.** The cache is a *fallback for central-SQL outages at
  boot*, not a replacement for central. A **new deployment still requires central SQL** — the cache
  is only read when the central fetch fails at startup. When central returns, the node resumes
  fresh-config behavior. Boot-from-cache logs a running-from-cache signal; treat a node that stays
  on it as a central-connectivity incident, not steady state.

## Inspecting the LocalDb file — safety rules

The database runs in **WAL mode**. These rules exist because violating them corrupted a live DB in
the 2026-07-20 ScadaBridge incident:

- **Never run `sqlite3` on the live file** (host-side, against a bind-mounted or container path).
  Opening a live WAL DB from a second process across virtiofs poisons the WAL. **Always copy the
  triplet first** and query the copy:

  ```bash
  docker cp <node>:/app/data/otopcua-localdb.db      /tmp/localdb.db
  docker cp <node>:/app/data/otopcua-localdb.db-wal  /tmp/localdb.db-wal
  docker cp <node>:/app/data/otopcua-localdb.db-shm  /tmp/localdb.db-shm
  sqlite3 /tmp/localdb.db 'SELECT cluster_id, deployment_id FROM deployment_pointer;'
  ```

- **Metrics** come from the container, not a host curl (`aspnet:10.0` has no `curl`):

  ```bash
  docker run --rm --network container:<node> curlimages/curl:latest -s localhost:<httpPort>/metrics | grep localdb_
  ```

- **If an anomaly appears within seconds of your own measurement, suspect the measurement.**
  Restart both nodes and re-observe untouched before blaming the library.

## Useful queries (on a copied triplet)

```sql
-- What each node thinks the current deployment is
SELECT cluster_id, deployment_id, revision_hash, applied_at_utc FROM deployment_pointer;

-- Convergence check: the pointer's origin stamp should match on both nodes
SELECT pk_json, hlc, node_id, is_tombstone
FROM __localdb_row_version WHERE table_name = 'deployment_pointer' ORDER BY pk_json;

-- Replication backlog (should drain to 0 when a pair is caught up)
SELECT COUNT(*) FROM __localdb_oplog;

-- Undelivered alarm history, and how much of it has given up
SELECT dead_lettered, COUNT(*) FROM alarm_sf_events GROUP BY dead_lettered;

-- Why the dead-lettered rows died
SELECT alarm_id, attempt_count, last_attempt_utc, last_error
FROM alarm_sf_events WHERE dead_lettered = 1 ORDER BY last_attempt_utc DESC LIMIT 20;

-- Convergence check for the buffer: identical dumps mean the peer holds the ORIGIN-stamped rows
SELECT pk_json, hlc, node_id, is_tombstone
FROM __localdb_row_version WHERE table_name = 'alarm_sf_events' ORDER BY pk_json;
```

## See also

- [`docs/Redundancy.md`](../Redundancy.md) — the pair-local config cache section.
- [`docs/Configuration.md`](../Configuration.md) — the `LocalDb` appsettings section.
- [`docs/AlarmHistorian.md`](../AlarmHistorian.md) — the alarm sink, its knobs, and the drain.
- The two-node convergence harness: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/`.
