Compare commits
24 Commits
58c2e14c38
...
low/T5
| Author | SHA1 | Date | |
|---|---|---|---|
| 41bdc178c2 | |||
| e9dc13689a | |||
| f62b017cc7 | |||
| dbcbc5bd41 | |||
| 1145df682e | |||
| 3e5a5fde81 | |||
| b62981ff80 | |||
| 31a7c8a88a | |||
| 7757b9c6fc | |||
| 0038d46c9a | |||
| cea3930b94 | |||
| f627cee9c0 | |||
| 14cb283172 | |||
| 522cfbfda6 | |||
| 1ce0c5955f | |||
| 9377fa1678 | |||
| 92246c9734 | |||
| 1fee9755d7 | |||
| 205bddb3b8 | |||
| 206c588608 | |||
| bd929d98fc | |||
| 990f4c018d | |||
| 5da4ab3ebd | |||
| 3d2f3f59b2 |
@@ -1,4 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# pack.sh — produce the ZB.MOM.WW.Audit NuGet package into ./artifacts.
|
||||
set -euo pipefail
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# cd to the repo root and name the solution + an absolute output path: `dotnet pack` with
|
||||
# neither a project argument nor a fixed working directory packs whatever solution happens
|
||||
# to sit in the CALLER's directory (the bug behind the LocalDb feed incident, 0038d46).
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet pack ZB.MOM.WW.Audit.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
@@ -16,9 +16,20 @@ set -euo pipefail
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# Named solution, named output, and a cd to the repo root, all for one reason: `dotnet pack`
|
||||
# with neither a project argument nor a fixed working directory packs whatever solution
|
||||
# happens to sit in the CALLER's directory — and then pushes it to a live feed under this
|
||||
# script's name. Invoked by absolute path from another repo's checkout, the relative form
|
||||
# packed and published 25 unrelated packages (LocalDb's copy of this script, fixed in
|
||||
# 0038d46; recovered with `dotnet nuget delete`, but only because the token happened to
|
||||
# carry delete rights). A publish script must be unambiguous about WHAT it publishes no
|
||||
# matter where it is run.
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
dotnet pack ZB.MOM.WW.Audit.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
dotnet nuget push "$REPO_ROOT/artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
|
||||
@@ -6,4 +6,10 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# cd to the repo root and name the solution + an absolute output path: `dotnet pack` with
|
||||
# neither a project argument nor a fixed working directory packs whatever solution happens
|
||||
# to sit in the CALLER's directory (the bug behind the LocalDb feed incident, 0038d46).
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet pack ZB.MOM.WW.Auth.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
@@ -16,9 +16,20 @@ set -euo pipefail
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# Named solution, named output, and a cd to the repo root, all for one reason: `dotnet pack`
|
||||
# with neither a project argument nor a fixed working directory packs whatever solution
|
||||
# happens to sit in the CALLER's directory — and then pushes it to a live feed under this
|
||||
# script's name. Invoked by absolute path from another repo's checkout, the relative form
|
||||
# packed and published 25 unrelated packages (LocalDb's copy of this script, fixed in
|
||||
# 0038d46; recovered with `dotnet nuget delete`, but only because the token happened to
|
||||
# carry delete rights). A publish script must be unambiguous about WHAT it publishes no
|
||||
# matter where it is run.
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
dotnet pack ZB.MOM.WW.Auth.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
dotnet nuget push "$REPO_ROOT/artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.3</Version>
|
||||
<Version>0.3.3</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- NU1510 (package-pruning advice): the Replication and Tests projects carry a
|
||||
|
||||
+188
-18
@@ -10,16 +10,127 @@ Change capture uses SQLite triggers writing a single HLC-stamped oplog inside th
|
||||
consumer's own write transaction (crash-consistent, non-bypassable). Convergence is
|
||||
last-writer-wins over a hybrid logical clock. All timestamps are UTC.
|
||||
|
||||
## Packages (all `0.1.3`)
|
||||
## Packages (all `0.3.3`)
|
||||
|
||||
| Package | Contents |
|
||||
|---|---|
|
||||
| `ZB.MOM.WW.LocalDb` | Embedded DB host: `ILocalDb` SQL surface (`ExecuteAsync` / `QueryAsync` / transactions), `RegisterReplicated(table)` (PK validation + trigger install + oplog schema), `HybridLogicalClock`, `AddZbLocalDb()` DI. |
|
||||
| `ZB.MOM.WW.LocalDb` | Embedded DB host: `ILocalDb` SQL surface (`ExecuteAsync` / `QueryAsync` / transactions), `RegisterReplicated(table[, baselineExistingRows])` (PK validation + trigger install + oplog schema), `DeregisterReplicated(table)` (trigger removal + oplog/ledger prune), `HybridLogicalClock`, `AddZbLocalDb()` DI. |
|
||||
| `ZB.MOM.WW.LocalDb.Contracts` | `localdb_sync.v1` proto + generated gRPC types (packable; a future peer could be non-.NET). |
|
||||
| `ZB.MOM.WW.LocalDb.Replication` | gRPC sync engine: passive `LocalDbSyncService`, initiating `SyncBackgroundService`, `MaintenanceBackgroundService`, LWW apply, watermark acks + pruning, snapshot resync, `ISyncStatus`, `LocalDbMetrics`, `AddZbLocalDbReplication()` / `MapZbLocalDbSync()`. |
|
||||
|
||||
> Status: **published to the Gitea feed; adopted by ScadaBridge (Phases 1+2) and OtOpcUa (Phase 1).**
|
||||
>
|
||||
> - `0.3.3` (2026-08-19) — one Low finding from the ScadaBridge adversarial architecture review
|
||||
> (P4-7), in two halves. Wire-compatible with every earlier peer: no proto change, no
|
||||
> `lib_schema_version` bump, no on-disk schema change.
|
||||
> - **`RegisterReplicated` now refuses a table that another table cascades a DELETE onto.**
|
||||
> Applying a remote row uses `INSERT OR REPLACE`, whose implicit DELETE fires `ON DELETE
|
||||
> CASCADE` / `SET NULL` / `SET DEFAULT` on every child — while the apply path holds the capture
|
||||
> guard raised, so the child rows it touches produce no oplog entry, the peer keeps them, and the
|
||||
> pair diverges with nothing reporting a fault. Registration is the only place that is visible,
|
||||
> so it is refused there with an actionable `LocalDbRegistrationException` naming the referencing
|
||||
> table and its action. `NO ACTION` / `RESTRICT` are still allowed: they mutate no child row (the
|
||||
> implicit delete simply fails, which the applier dead-letters — loud, not silent). No current
|
||||
> consumer has a foreign key on a replicated table, so this is a guard against a future foot-gun
|
||||
> rather than a live fix.
|
||||
> - **Applying one entry is atomic across its row write and its ledger write.** They were two
|
||||
> statements under the poison catch, so a failure landing between them committed a
|
||||
> deleted/replaced row carrying a STALE LIVE ledger version — the shape `SnapshotStreamer` warns
|
||||
> about and skips, meaning that key never travels again. A per-entry `SAVEPOINT` makes the pair
|
||||
> all-or-nothing while keeping the catch's per-ENTRY granularity: entries applied earlier in the
|
||||
> same batch survive, and the poison entry is still dead-lettered and the batch still continues.
|
||||
> - `0.3.2` (2026-08-19) — two Medium findings from the ScadaBridge adversarial architecture review
|
||||
> (P4-1, P4-3). Wire-compatible with every earlier peer: no proto change, no `lib_schema_version`
|
||||
> bump, no on-disk schema change — the keepalive is transport-level and the rest is local policy.
|
||||
> - **A live session now ends itself when a cap prune eats deltas it has not sent.** The pump reads
|
||||
> `seq > sentThruSeq`, which resumes SILENTLY above a pruned hole; the peer acks the later seqs
|
||||
> and the pair is durably divergent for the whole life of that session. `needs_snapshot` (set in
|
||||
> the prune's own transaction) only heals a RECONNECT, and nothing forced one — so on a stable
|
||||
> LAN with a slow-but-alive peer the divergence was unbounded. Every pump iteration now compares
|
||||
> the oplog horizon (`MIN(seq)`, one b-tree seek) against its sent watermark and throws
|
||||
> `LocalDbOplogGapException` when deltas it still owes are gone. The horizon, not the flag,
|
||||
> because the flag is also raised for reasons that do not invalidate the running session.
|
||||
> - **The sync stream has HTTP/2 keepalive, and the server's single-stream guard is newest-wins.**
|
||||
> Neither end ever timed out on silence (a converged pair is legitimately quiet), so a hard crash
|
||||
> or partition of the dialling node left the passive node's handler parked in `MoveNext` on a
|
||||
> half-open stream — holding the guard, reporting itself connected, and rejecting the rebooted
|
||||
> peer's reconnects with `AlreadyExists`, indefinitely on an idle pair. The initiator now dials
|
||||
> with `KeepAlivePingDelay` / `KeepAlivePingTimeout` (new options, 60s/20s, `Always` policy so
|
||||
> idle connections ping too), and a fresh authenticated `Sync` cancels the incumbent and waits
|
||||
> for it to unwind before starting — the guard serializes sessions, it was never a fence, and an
|
||||
> authenticated same-key dialler is by definition the peer. An incumbent that will not yield
|
||||
> within the takeover timeout still gets `AlreadyExists`, because two live sessions over one
|
||||
> database is worse than a refused dial.
|
||||
> - `0.3.1` (2026-08-18) — a faulting session flushes its control lane before cancelling its writer,
|
||||
> so a locally-detected handshake mismatch actually reaches the peer instead of leaving it to
|
||||
> reconnect-loop against a generic disconnect.
|
||||
> - `0.3.0` (2026-08-15) — **the snapshot-resync flag is per-table.** On-disk bookkeeping schema
|
||||
> goes to **v3** (new `__localdb_snapshot_state`, upgraded in place on open); wire-compatible with
|
||||
> `0.1.x`/`0.2.x` peers, and `lib_schema_version` deliberately stays `1` because it is compared
|
||||
> fail-closed for equality.
|
||||
> - **`RegisterReplicated(table, baselineExistingRows: true)` now flags a resync for THAT TABLE.**
|
||||
> Through `0.2.x` the flag was one bit for the whole database, so baselining one table
|
||||
> re-streamed every registered table in full — and on a pair enabling replication late, both
|
||||
> nodes did that to each other at once. A snapshot now streams only the flagged tables.
|
||||
> - **The database-wide flag survives, unchanged, for the cases that genuinely need it**: an oplog
|
||||
> cap prune or an impossible peer ack loses deltas for the oplog as a whole, so nothing short of
|
||||
> every table is sound. What moved is only where a *baseline* records its debt.
|
||||
> - **Additive wire extension, negotiated by capability, not by version.** `Handshake` gains
|
||||
> `supports_partial_snapshot`; `SnapshotBegin` gains a `tables` scope where **empty means "all
|
||||
> tables"** — which is precisely what a pre-`0.3.0` sender emits and what a pre-`0.3.0` receiver
|
||||
> assumes. A peer that does not advertise the capability is served a **full** snapshot, because a
|
||||
> scoped one would make it advance `last_applied_remote_seq` past deltas it never received.
|
||||
> - **A partial snapshot moves no watermark on either side.** The receiver keeps
|
||||
> `last_applied_remote_seq` (it is one number for the whole peer oplog, and the untouched tables'
|
||||
> deltas still sit below the snapshot's as-of); the sender keeps pumping from where it was. The
|
||||
> overlap re-delivers rows the snapshot already carried, which LWW absorbs.
|
||||
> - **Snapshot debt now clears on acknowledgement, not on enqueue** (new `SnapshotAck`, exchanged
|
||||
> only between `0.3.0` peers). A crash part-way through a snapshot re-streams the same scope on
|
||||
> reconnect instead of losing it. Against a pre-`0.3.0` peer the `0.2.x` clear-on-enqueue
|
||||
> behaviour is kept — there is no ack to wait for.
|
||||
> - `0.2.1` (2026-08-14) — two adversarial-review findings against `0.2.0`. Wire-compatible with
|
||||
> `0.1.x`/`0.2.0` peers; no proto change, no `lib_schema_version` bump, no on-disk schema change.
|
||||
> - **`DeregisterReplicated` no longer leaves the HLC without a crash anchor.** Clock recovery on
|
||||
> open is `MAX(hlc_high_water, oplog HLCs, ledger HLCs, last_seen_hlc)`, and `hlc_high_water` was
|
||||
> written only by graceful disposal — while deregistration deletes the table's oplog AND ledger
|
||||
> rows. A crash after a deregister boot could therefore recover the clock below stamps the peer
|
||||
> already holds, and LWW discards those as older. The current clock is now flushed inside the
|
||||
> deregistration transaction, before the prune (and the flush is MAX-guarded, so it can never
|
||||
> regress the anchor).
|
||||
> - **The sync inbox is bounded (64 messages) instead of unbounded**, so inbound memory is capped
|
||||
> at roughly `64 x MaxBatchBytes` rather than "whatever the peer sends" — the bad case being a
|
||||
> mutual full snapshot of a large dataset, buffered whole on both nodes at once. A full inbox now
|
||||
> stops draining the gRPC stream, which closes the peer's flow-control window.
|
||||
> - **A snapshot this node owes is streamed alongside a running receive loop**, not from inside the
|
||||
> handshake. That is what makes bounding safe: previously neither side drained its inbox while
|
||||
> sending, so a mutual snapshot converged only because the inbox was unbounded. Interleaving was
|
||||
> already safe (snapshot rows and deltas both merge through LWW, the watermark write is
|
||||
> MAX-guarded) — only the ordering changed.
|
||||
> - **Documented the cost of late baselining**: the snapshot flag is per-database, so baselining
|
||||
> one table re-streams every registered table, in both directions on a late-enabled pair. Trim
|
||||
> high-volume tables (event logs) before turning replication on late.
|
||||
> - `0.2.0` (2026-08-14) — performance + lifecycle work from the ScadaBridge architectural review.
|
||||
> All of it is wire-compatible with `0.1.x` peers (no proto change, no `lib_schema_version` bump);
|
||||
> the on-disk bookkeeping schema goes to **v2** and is upgraded in place on open.
|
||||
> - **`DeregisterReplicated(table)`** — drops a table's capture triggers and prunes its oplog and
|
||||
> row-version rows. A node that does not replicate should not be paying for capture on every
|
||||
> write, and until now there was no way to stop once a file had been registered. Idempotent, and
|
||||
> usable on triggers left behind by an earlier build.
|
||||
> - **`RegisterReplicated(table, baselineExistingRows: true)`** — closes the late-opt-in hole: rows
|
||||
> that predate registration were invisible to the peer forever, because the snapshot streamer
|
||||
> pages from the row-version ledger and CDC never wrote them there. Opting in seeds the ledger at
|
||||
> an LWW-safe floor (HLC `0`, this node's id) and flags a snapshot resync so they actually
|
||||
> travel. Default is still off — the documented CDC contract is unchanged unless you ask.
|
||||
> - **O(1) backlog depth** — `MAX(seq) - MAX(last_acked_seq, MIN(seq) - 1)` replaces a
|
||||
> `COUNT(*)` over the whole unacked backlog that ran once a minute AND on every metrics scrape
|
||||
> (measured 58 VM steps vs 100,020 on a 50k backlog).
|
||||
> - **Byte-budget batching** — `MaxBatchBytes` (default 2 MB) bounds delta and snapshot messages by
|
||||
> summed serialized size, with `MaxBatchSize` demoted to a secondary row cap. Consumers no longer
|
||||
> have to tune the row count against their widest row to stay under the gRPC message limit.
|
||||
> Inbound delta batches and acks already queued are coalesced into one transaction / one ack.
|
||||
> - **`LwwApplier` prepares once per statement** instead of three SQL parses per applied row.
|
||||
> - **Dropped `__localdb_oplog_hlc`** — an index no query ever used, costing a b-tree insert inside
|
||||
> every captured consumer write. Existing files lose it on the next open.
|
||||
> - `0.1.3` (2026-07-21) — fixes the other half of the rebuilt-peer story: a rebuilt peer's OWN
|
||||
> writes were silently dropped. `last_applied_remote_seq` is a watermark in the *peer's* seq
|
||||
> space, and a rebuilt peer numbers from 1 again — so the healthy node advertised the old peer's
|
||||
@@ -107,7 +218,8 @@ node still pushes its own deltas back. Only one side needs `PeerAddress`.
|
||||
| `PeerAddress` | `""` | gRPC address to dial. Empty ⇒ passive (initiator idles). |
|
||||
| `ApiKey` | `null` | Bearer token the initiator presents on its channel; `null` ⇒ no key header. |
|
||||
| `FlushInterval` | `250 ms` | How often the change pump flushes pending oplog deltas. |
|
||||
| `MaxBatchSize` | `500` | Max oplog entries per outbound batch (also the snapshot page size). |
|
||||
| `MaxBatchBytes` | `2 MB` | **Primary** batch cap: summed serialized bytes per outbound delta/snapshot message. Sized under the 4 MB gRPC default; a single row above the budget is still sent, alone. |
|
||||
| `MaxBatchSize` | `500` | **Secondary** row-count cap per outbound batch (also the snapshot DB page size). |
|
||||
| `MaxOplogRows` | `1_000_000` | Backlog ceiling; exceeding it flags a snapshot resync and prunes oldest rows. |
|
||||
| `MaxOplogAge` | `7 days` | Age ceiling; an unpruned entry older than this flags a snapshot resync. |
|
||||
| `TombstoneRetention` | `7 days` | How long deleted-row tombstones are kept before pruning. |
|
||||
@@ -133,8 +245,15 @@ All options are validated at startup (`ZB.MOM.WW.Configuration` validators).
|
||||
delta/snapshot batches), so receiving never blocks on the ability to ack.
|
||||
- **Resync** — when a peer's watermark falls below the pruned oplog horizon, it is sent a
|
||||
full snapshot of the registered tables (streamed, keyset-paged) and deltas resume above
|
||||
the snapshot's as-of seq. **Each node decides from its OWN state** whether it owes the
|
||||
peer a snapshot.
|
||||
the snapshot's as-of seq. When instead a single table was *baselined*, only that table is
|
||||
streamed and **no watermark moves on either side** — a partial snapshot speaks for nothing
|
||||
outside its own tables, so the deltas it overlaps are still pumped and merged by LWW.
|
||||
**Each node decides from its OWN state** whether it owes the peer a snapshot, and a peer
|
||||
that does not advertise `supports_partial_snapshot` is sent a full one. The snapshot is
|
||||
streamed *after* the handshake, alongside a running receive loop, so a node sending one
|
||||
still applies what the peer sends it — a mutual snapshot exchanges under flow control
|
||||
instead of being buffered whole in memory. Between `0.3.0` peers the sender's flags are
|
||||
cleared by the receiver's `SnapshotAck`, so a crash mid-snapshot re-streams it.
|
||||
- **Maintenance** — a background service on **both** roles enforces the oplog row/age caps
|
||||
and prunes expired tombstones every `MaintenanceInterval`.
|
||||
|
||||
@@ -160,14 +279,41 @@ All options are validated at startup (`ZB.MOM.WW.Configuration` validators).
|
||||
`RegisterReplicated`, because `json_object` cannot capture it). A *typeless* column can
|
||||
still hold a BLOB value at runtime, which then surfaces as a write-time error, not a
|
||||
registration error.
|
||||
- **Replicated tables must not be cascade-delete foreign-key parents.** If another table
|
||||
references the one you are registering with `ON DELETE CASCADE` (or `SET NULL` /
|
||||
`SET DEFAULT`), `RegisterReplicated` throws. Applying a remote row uses `INSERT OR
|
||||
REPLACE`, whose implicit DELETE fires that referential action while change capture is
|
||||
suppressed — so the child rows it deletes or nulls are never captured, the peer keeps
|
||||
them, and the pair diverges with nothing reporting a fault. Model the parent/child
|
||||
relationship with `ON DELETE NO ACTION` (or `RESTRICT`) and delete children explicitly, or
|
||||
leave the parent unreplicated. `NO ACTION` / `RESTRICT` are allowed because they mutate no
|
||||
child row: the implicit delete just fails, and the applier dead-letters the entry — loud,
|
||||
not silent. The check runs at **registration time**, so a cascading child table created
|
||||
*later* is not caught (SQLite offers no DDL hook); register after schema setup, which is
|
||||
where startup does it anyway.
|
||||
- **CDC semantics** — rows that existed **before** `RegisterReplicated` are not captured
|
||||
and are not snapshotted; only changes after registration are replicated. Re-baselining
|
||||
pre-existing rows requires re-writing them.
|
||||
and are not snapshotted; only changes after registration are replicated. **Opt out with
|
||||
`RegisterReplicated(table, baselineExistingRows: true)`**, which seeds the row-version
|
||||
ledger for existing rows at the baseline floor (HLC `0`) and flags a snapshot resync
|
||||
**for that table** so they reach the peer. The floor is below every genuine stamp, so a baselined row loses
|
||||
every comparison against a real remote write and wins only where the peer has no version
|
||||
of that key at all. Two nodes that baseline the same key both hold HLC `0` and resolve by
|
||||
the node-id tie-break — convergent, but arbitrary as to which content survives.
|
||||
- **A snapshot still ships whole tables.** The flag is per-table as of `0.3.0`, so baselining
|
||||
one table streams that table and no other — but *that* table travels in full, and two
|
||||
cases still stream **everything**: an oplog cap prune / impossible peer ack (delta
|
||||
continuity is lost for the oplog as a whole), and any peer older than `0.3.0` (which
|
||||
cannot be told a snapshot is partial). So **purge or trim high-volume tables such as event
|
||||
logs before baselining them**, and expect one slow first sync. Bounded either way (batch
|
||||
budget out, bounded inbox in).
|
||||
- **REAL ±Infinity** is carried in SQLite's JSON dialect on the wire (fine for the
|
||||
SQLite↔SQLite peers this library targets).
|
||||
- `AddZbLocalDb` / `AddZbLocalDbReplication` are **first-registration-wins** (idempotent).
|
||||
- A **mutual simultaneous snapshot** buffers the peer's inbound snapshot in memory while
|
||||
this node is still sending its own (acceptable for the small datasets this lib targets).
|
||||
- **Inbound buffering is bounded, not zero.** The duplex inbox holds up to 64 messages, so
|
||||
peak inbound memory is roughly `64 x MaxBatchBytes` (~128 MB at the 2 MB default, far
|
||||
less in practice — messages are only that large when the sender has that much backlog).
|
||||
Lower `MaxBatchBytes` to lower the ceiling. Beyond the bound the stream stops being
|
||||
drained and the peer is slowed by flow control.
|
||||
|
||||
## Observability
|
||||
|
||||
@@ -196,8 +342,28 @@ provider is wired or the poll threw, so a DB failure never reads as "0 backlog")
|
||||
|
||||
## Ops notes
|
||||
|
||||
- When the oplog row/age caps are exceeded, maintenance prunes to the ceiling and flags
|
||||
`needs_snapshot` (disk safety over delta continuity); the peer then snapshot-resyncs.
|
||||
- When the oplog row/age caps are exceeded, maintenance prunes to the ceiling and flags the
|
||||
**database-wide** `needs_snapshot` (disk safety over delta continuity); the peer then gets a
|
||||
full snapshot. Per-table debt lives separately in `__localdb_snapshot_state` and is what
|
||||
baselining writes.
|
||||
- **Upgrading a pair to `0.3.0`** needs no coordination: the two nodes negotiate scoped snapshots
|
||||
by capability, so a `0.3.0` node paired with a `0.2.x` one simply keeps sending full snapshots
|
||||
until both are upgraded. Rolling the file forward is one-way — a `0.2.x` build reopening a v3
|
||||
file leaves `__localdb_snapshot_state` in place and ignores it, so any per-table debt recorded
|
||||
by `0.3.0` is invisible to it (roll forward again, or re-baseline).
|
||||
- **Turning a table off** — `DeregisterReplicated(table)` drops its capture triggers and prunes
|
||||
its oplog + ledger rows. It must be applied **symmetrically**: the handshake compares
|
||||
registered-table digests fail-closed, so a node that drops a table its peer still
|
||||
replicates stops syncing with a schema-mismatch error. Deregister on both nodes, or on
|
||||
neither. Re-enabling later should pass `baselineExistingRows: true`, or the rows that
|
||||
accumulated while capture was off stay invisible to the peer — but trim high-volume
|
||||
tables first (see Limitations: baselining flags a full, all-tables snapshot). The HLC's
|
||||
durable anchor is flushed in the same transaction, so a crash after a deregister boot
|
||||
cannot bring the clock back below stamps peers already hold.
|
||||
- **On-disk schema upgrades** are applied in place when the file is opened, in one
|
||||
transaction, and every step is safe to re-run. `schema_version` in `__localdb_meta` is
|
||||
local-file state only — it is NOT the wire `lib_schema_version`, so a pair mid-upgrade
|
||||
keeps syncing.
|
||||
- The **`__localdb_dead_letter`** table holds poison rows (bad JSON, null-row non-tombstone
|
||||
entries) and over-drift rejects (when `FailClosedOnDrift`). Inspect it to diagnose
|
||||
rejects.
|
||||
@@ -208,13 +374,17 @@ provider is wired or the poll threw, so a DB failure never reads as "0 backlog")
|
||||
|
||||
## Testing
|
||||
|
||||
144 tests, all offline / no live dependencies — unit (HLC ordering + restart
|
||||
persistence, trigger SQL generation, LWW decision table, watermark/pruning, digest
|
||||
computation), integration over two real SQLite DBs on an in-memory (and real) gRPC pipe
|
||||
(bidirectional convergence, delete/update races, offline-accumulate → reconnect,
|
||||
prune → snapshot resync), plus seeded randomized property tests (random interleaved op
|
||||
sequences → both DBs identical after quiesce). Family conventions apply
|
||||
(`TreatWarningsAsErrors`).
|
||||
199 tests, all offline / no live dependencies — unit (HLC ordering + restart
|
||||
persistence, deregistration's HLC anchor surviving an ungraceful reopen, trigger SQL
|
||||
generation, LWW decision table, watermark/pruning, digest computation, schema-version
|
||||
upgrade, deregistration idempotency, baseline-floor LWW safety, O(1) depth vs a seeded
|
||||
backlog, byte-budget chunking, bounded-inbox backpressure under a slow reader), integration
|
||||
over two real SQLite DBs on an in-memory (and real) gRPC pipe (bidirectional convergence,
|
||||
delete/update races, offline-accumulate → reconnect, prune → snapshot resync, mutual
|
||||
snapshot over a capacity-1 transport, single-table baseline streaming only that table,
|
||||
mixed-version interop where a peer without `supports_partial_snapshot` gets a full one,
|
||||
snapshot debt surviving until the peer's `SnapshotAck`), plus seeded randomized property tests (random interleaved op sequences → both DBs identical after quiesce).
|
||||
Family conventions apply (`TreatWarningsAsErrors`).
|
||||
|
||||
Build / test from this directory:
|
||||
|
||||
|
||||
@@ -16,9 +16,19 @@ set -euo pipefail
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# Named solution, named output, and a cd to the repo root, all for one reason: `dotnet pack`
|
||||
# with neither a project argument nor a fixed working directory packs whatever solution
|
||||
# happens to sit in the CALLER's directory — and then pushes it to a live feed under this
|
||||
# script's name. Invoked as `bash /path/to/ZB.MOM.WW.LocalDb/build/push.sh` from another
|
||||
# repo's checkout, the relative form packed and published 25 unrelated packages (recovered
|
||||
# with `dotnet nuget delete`, but only because the token happened to carry delete rights).
|
||||
# A publish script must be unambiguous about WHAT it publishes no matter where it is run.
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
dotnet pack ZB.MOM.WW.LocalDb.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
dotnet nuget push "$REPO_ROOT/artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
|
||||
@@ -16,6 +16,7 @@ message SyncMessage {
|
||||
SnapshotBegin snapshot_begin = 5; // sender switches to snapshot mode
|
||||
SnapshotBatch snapshot_batch = 6;
|
||||
SnapshotComplete snapshot_complete = 7;
|
||||
SnapshotAck snapshot_ack = 8; // 0.3.0+; only sent when the peer advertised support
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +25,13 @@ message Handshake {
|
||||
uint32 lib_schema_version = 2;
|
||||
repeated TableDigest tables = 3; // fail-closed on any mismatch
|
||||
int64 last_applied_remote_seq = 4; // highest of YOUR oplog seqs I have applied
|
||||
// 0.3.0+ capability, additive: I understand a snapshot scoped to a SUBSET of the registered
|
||||
// tables (SnapshotBegin.tables) and I will answer a completed snapshot with a SnapshotAck.
|
||||
// A pre-0.3.0 peer leaves this false — proto3 default — and a 0.3.0 sender then promotes any
|
||||
// scoped snapshot to a full one and clears its flags on enqueue, exactly as 0.2.x did. This is
|
||||
// why lib_schema_version stays 1: it is compared fail-closed for EQUALITY, so bumping it would
|
||||
// stop 0.1.x/0.2.x pairs syncing outright.
|
||||
bool supports_partial_snapshot = 5;
|
||||
}
|
||||
message TableDigest { string table_name = 1; string digest = 2; }
|
||||
message HandshakeAck { string node_id = 1; bool snapshot_required = 2; }
|
||||
@@ -40,7 +48,12 @@ message OplogEntry {
|
||||
message DeltaBatch { repeated OplogEntry entries = 1; }
|
||||
message DeltaAck { int64 applied_thru_seq = 1; }
|
||||
|
||||
message SnapshotBegin { int64 as_of_seq = 1; }
|
||||
// tables: the SCOPE of this snapshot (0.3.0+). EMPTY means every registered table — a full
|
||||
// snapshot, which is the only thing a pre-0.3.0 sender ever emits and the only thing a
|
||||
// pre-0.3.0 receiver can safely assume. A non-empty list means the snapshot is PARTIAL, so the
|
||||
// receiver must NOT advance last_applied_remote_seq to as_of_seq (deltas below it for the tables
|
||||
// NOT listed have not been delivered); it merges the rows through LWW and leaves the watermark.
|
||||
message SnapshotBegin { int64 as_of_seq = 1; repeated string tables = 2; }
|
||||
message SnapshotBatch { string table_name = 1; repeated SnapshotRow rows = 2; }
|
||||
message SnapshotRow {
|
||||
string pk_json = 1;
|
||||
@@ -50,3 +63,8 @@ message SnapshotRow {
|
||||
bool is_tombstone = 5;
|
||||
}
|
||||
message SnapshotComplete { int64 as_of_seq = 1; }
|
||||
// Sent by the RECEIVER once a snapshot has been durably applied, so the sender clears the
|
||||
// per-table snapshot flags only after the peer actually holds the rows (a crash mid-snapshot then
|
||||
// re-streams the same scope on reconnect). Only exchanged when both peers advertised
|
||||
// supports_partial_snapshot; a pre-0.3.0 pair keeps the 0.2.x clear-on-enqueue behaviour.
|
||||
message SnapshotAck { int64 as_of_seq = 1; }
|
||||
|
||||
@@ -12,19 +12,58 @@ namespace ZB.MOM.WW.LocalDb.Replication.Internal;
|
||||
internal static class GrpcSyncDuplex
|
||||
{
|
||||
/// <summary>
|
||||
/// Wires <paramref name="reader"/> into an unbounded inbox channel drained by a background loop,
|
||||
/// and exposes <paramref name="writer"/> as the duplex send. Returns the duplex plus the reader
|
||||
/// loop's task so the caller can observe it. On clean stream end the inbox channel is completed;
|
||||
/// on a reader fault it is completed WITH the exception — the session's handshake and receive
|
||||
/// phases both await the inbox, so terminating it is what unblocks them when the call dies (they
|
||||
/// would otherwise hang on a dead stream).
|
||||
/// Inbound messages buffered between the transport and the session's receive loop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why bounded at all.</b> An unbounded inbox makes this process's memory a function of what
|
||||
/// the PEER decides to send. The worst case is not hypothetical: a full snapshot streams every
|
||||
/// row of every registered table, and both sides of a pair that enabled replication late
|
||||
/// snapshot each other simultaneously — so an unbounded inbox holds an entire peer dataset (an
|
||||
/// event log at its storage cap, say) in memory on both nodes at once. Bounding it converts that
|
||||
/// into backpressure: the pump stops draining the gRPC stream, the HTTP/2 flow-control window
|
||||
/// closes, and the SENDER slows down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why 64.</b> Memory here is bounded by roughly capacity x
|
||||
/// <see cref="ReplicationOptions.MaxBatchBytes"/> (the sender's own per-message budget), so the
|
||||
/// ceiling is a configured number rather than a peer-controlled one. 64 is four times the
|
||||
/// sender's outbound data-lane depth, so a healthy stream never reaches the bound and pays
|
||||
/// nothing; it engages only when this node is genuinely slower than the peer for a sustained
|
||||
/// burst, which is exactly when backpressure is wanted. Lower <c>MaxBatchBytes</c> to lower the
|
||||
/// ceiling.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What makes bounding safe.</b> Backpressure on the inbox deadlocks any design where both
|
||||
/// peers can stop draining their inbox at the same time — mutual snapshots were exactly that
|
||||
/// case while the send ran inside the handshake. <see cref="SyncSession"/> therefore starts its
|
||||
/// receive loop BEFORE streaming a snapshot it owes, so the inbox is drained continuously for
|
||||
/// the whole life of the session; see the remarks on its snapshot phase.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal const int InboxCapacity = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Wires <paramref name="reader"/> into a bounded inbox channel (see
|
||||
/// <see cref="InboxCapacity"/>) drained by a background loop, and exposes
|
||||
/// <paramref name="writer"/> as the duplex send. Returns the duplex plus the reader loop's task
|
||||
/// so the caller can observe it. On clean stream end the inbox channel is completed; on a reader
|
||||
/// fault it is completed WITH the exception — the session's handshake and receive phases both
|
||||
/// await the inbox, so terminating it is what unblocks them when the call dies (they would
|
||||
/// otherwise hang on a dead stream).
|
||||
/// </summary>
|
||||
public static (SyncDuplex Duplex, Task ReaderTask) Create(
|
||||
IAsyncStreamReader<SyncMessage> reader,
|
||||
IAsyncStreamWriter<SyncMessage> writer,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var inbox = Channel.CreateUnbounded<SyncMessage>(new UnboundedChannelOptions { SingleWriter = true });
|
||||
var inbox = Channel.CreateBounded<SyncMessage>(new BoundedChannelOptions(InboxCapacity)
|
||||
{
|
||||
// Wait, never Drop: every inbound message is protocol state (a delta the peer will not
|
||||
// resend in-session, a snapshot frame, an ack). Dropping one is silent divergence.
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleWriter = true,
|
||||
});
|
||||
var readerTask = PumpInboundAsync(reader, inbox.Writer, ct);
|
||||
var duplex = new SyncDuplex
|
||||
{
|
||||
@@ -44,7 +83,10 @@ internal static class GrpcSyncDuplex
|
||||
try
|
||||
{
|
||||
while (await reader.MoveNext(ct))
|
||||
inbox.TryWrite(reader.Current);
|
||||
// WriteAsync, not TryWrite: on a FULL bounded channel TryWrite returns false and the
|
||||
// message is silently lost. Awaiting here is the backpressure — it stops draining the
|
||||
// gRPC stream, which closes the peer's flow-control window.
|
||||
await inbox.WriteAsync(reader.Current, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -14,12 +14,28 @@ internal sealed record ApplyResult(long AppliedThruSeq, int Applied, int Discard
|
||||
/// row-version (hlc, then node_id ordinal tie-break), poison rows are dead-lettered while the
|
||||
/// batch continues, and the remote watermark advances in the SAME transaction as the data so a
|
||||
/// crash-replay of the batch is a no-op (LWW's strict-greater compare loses on equal versions).
|
||||
/// Each entry's row write and its ledger write are additionally scoped by a per-entry SAVEPOINT, so
|
||||
/// the pair is all-or-nothing even when the poison catch keeps the batch alive around it.
|
||||
/// This never writes <c>__localdb_oplog</c>: the 2-node topology does not re-forward applied deltas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every statement is issued through a <see cref="PreparedStatements"/> set held for the life of the
|
||||
/// batch, so each distinct statement is prepared ONCE and then rebound per row. The previous shape
|
||||
/// went through the generic <c>ILocalDbTransaction</c> façade, which builds a fresh
|
||||
/// <see cref="SqliteCommand"/> per call — three SQL parses per applied row (version lookup, the
|
||||
/// table's delete/upsert, the row-version write), on the path that drains a reconnect backlog.
|
||||
/// </remarks>
|
||||
internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow = null)
|
||||
{
|
||||
private readonly Func<DateTimeOffset> _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct statements prepared by the most recent <see cref="ApplyBatchAsync"/>. Test-only
|
||||
/// observation: it is the direct evidence that statement count tracks the number of distinct
|
||||
/// STATEMENTS rather than the number of rows.
|
||||
/// </summary>
|
||||
internal int LastBatchStatementCount { get; private set; }
|
||||
|
||||
public async Task<ApplyResult> ApplyBatchAsync(IReadOnlyList<OplogEntryRecord> entries, CancellationToken ct)
|
||||
{
|
||||
if (entries.Count == 0)
|
||||
@@ -42,13 +58,14 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
if (entry.Hlc > maxHlc) maxHlc = entry.Hlc;
|
||||
}
|
||||
|
||||
var sqlByTable = new Dictionary<string, (string Delete, string Upsert)>(StringComparer.Ordinal);
|
||||
var applied = 0;
|
||||
var discarded = 0;
|
||||
var deadLettered = 0;
|
||||
|
||||
await using var tx = await db.BeginTransactionAsync(ct);
|
||||
await tx.ExecuteAsync("UPDATE __localdb_applying SET applying = 1 WHERE id = 1", null, ct);
|
||||
await using var tx = await db.BeginPreparedTransactionAsync(ct);
|
||||
using var sql = new PreparedStatements(tx.Connection, tx.Sqlite);
|
||||
|
||||
await sql.SetApplyingAsync(1, ct);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
@@ -56,19 +73,16 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
// KEY table that silently inserts a phantom auto-rowid row — so it is poison, not a win.
|
||||
if (!entry.IsTombstone && entry.RowJson is null)
|
||||
{
|
||||
await DeadLetterAsync(tx, entry, "non-tombstone entry with null row_json", utcNow, ct);
|
||||
await sql.DeadLetterAsync(entry, "non-tombstone entry with null row_json", utcNow, ct);
|
||||
deadLettered++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var local = await tx.QueryAsync(
|
||||
"SELECT hlc, node_id FROM __localdb_row_version WHERE table_name = @t AND pk_json = @pk",
|
||||
static r => (Hlc: r.GetInt64(0), NodeId: r.GetString(1)),
|
||||
new { t = entry.TableName, pk = entry.PkJson }, ct);
|
||||
var local = await sql.ReadLocalVersionAsync(entry.TableName, entry.PkJson, ct);
|
||||
|
||||
var remoteWins = local.Count == 0
|
||||
|| entry.Hlc > local[0].Hlc
|
||||
|| (entry.Hlc == local[0].Hlc && string.CompareOrdinal(entry.NodeId, local[0].NodeId) > 0);
|
||||
var remoteWins = local is null
|
||||
|| entry.Hlc > local.Value.Hlc
|
||||
|| (entry.Hlc == local.Value.Hlc && string.CompareOrdinal(entry.NodeId, local.Value.NodeId) > 0);
|
||||
|
||||
if (!remoteWins)
|
||||
{
|
||||
@@ -76,33 +90,28 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sqlByTable.TryGetValue(entry.TableName, out var sql))
|
||||
{
|
||||
sql = BuildTableSql(db.ReplicatedTables[entry.TableName]);
|
||||
sqlByTable[entry.TableName] = sql;
|
||||
}
|
||||
|
||||
// The row mutation and its ledger entry are TWO statements and must land together or
|
||||
// not at all: a failure between them commits a deleted/replaced row carrying a stale
|
||||
// ledger version, which SnapshotStreamer then warns about and skips — so that key never
|
||||
// travels again and the pair is silently, permanently divergent for it. A per-entry
|
||||
// savepoint makes the pair atomic without giving up the poison catch's per-ENTRY
|
||||
// granularity (the surrounding transaction, and every entry already applied in it,
|
||||
// survives a rollback to the savepoint). Cost measured on the path that matters — a
|
||||
// 100 000-row backlog drain — is ~7% (178.6k rows/s → 166.1k rows/s): the two extra
|
||||
// statements are prepared once like the rest and neither one touches the disk, since
|
||||
// an inner savepoint's release just merges into the enclosing transaction.
|
||||
var pairApplied = false;
|
||||
await sql.SavepointAsync(ct);
|
||||
try
|
||||
{
|
||||
var table = db.ReplicatedTables[entry.TableName];
|
||||
if (entry.IsTombstone)
|
||||
await tx.ExecuteAsync(sql.Delete, new { pk = entry.PkJson }, ct);
|
||||
await sql.DeleteAsync(table, entry.PkJson, ct);
|
||||
else
|
||||
await tx.ExecuteAsync(sql.Upsert, new { row = entry.RowJson }, ct);
|
||||
await sql.UpsertAsync(table, entry.RowJson!, ct);
|
||||
|
||||
await tx.ExecuteAsync(
|
||||
"INSERT OR REPLACE INTO __localdb_row_version " +
|
||||
"(table_name, pk_json, hlc, node_id, is_tombstone, tombstone_utc) " +
|
||||
"VALUES (@t, @pk, @hlc, @node, @tomb, @tut)",
|
||||
new
|
||||
{
|
||||
t = entry.TableName,
|
||||
pk = entry.PkJson,
|
||||
hlc = entry.Hlc,
|
||||
node = entry.NodeId,
|
||||
tomb = entry.IsTombstone ? 1 : 0,
|
||||
tut = entry.IsTombstone ? utcNow : null,
|
||||
}, ct);
|
||||
applied++;
|
||||
await sql.WriteRowVersionAsync(entry, utcNow, ct);
|
||||
pairApplied = true;
|
||||
}
|
||||
catch (SqliteException ex)
|
||||
{
|
||||
@@ -110,59 +119,224 @@ internal sealed class LwwApplier(SqliteLocalDb db, Func<DateTimeOffset>? utcNow
|
||||
// malformed row_json) are treated as per-entry poison — SQLite aborts just the
|
||||
// statement, not the transaction, so the batch continues after dead-lettering.
|
||||
// Any other exception propagates and rolls back the whole batch (fail-closed).
|
||||
await DeadLetterAsync(tx, entry, ex.Message, utcNow, ct);
|
||||
// The rollback comes FIRST so the dead-letter row it records is not discarded with
|
||||
// the half-applied pair.
|
||||
await sql.RollbackToSavepointAsync(ct);
|
||||
await sql.DeadLetterAsync(entry, ex.Message, utcNow, ct);
|
||||
deadLettered++;
|
||||
}
|
||||
|
||||
// Released outside the catch on purpose: a RELEASE that itself fails means the
|
||||
// transaction is no longer sound, and there is no savepoint state left to reason about,
|
||||
// so it propagates and rolls the whole batch back rather than being swallowed as poison.
|
||||
if (pairApplied)
|
||||
{
|
||||
await sql.ReleaseSavepointAsync(ct);
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
|
||||
await tx.ExecuteAsync(
|
||||
"UPDATE __localdb_peer_state SET " +
|
||||
"last_applied_remote_seq = MAX(last_applied_remote_seq, @maxSeq), " +
|
||||
"last_seen_hlc = MAX(last_seen_hlc, @maxHlc), last_sync_utc = @utc WHERE id = 1",
|
||||
new { maxSeq, maxHlc, utc = utcNow }, ct);
|
||||
await sql.AdvancePeerStateAsync(maxSeq, maxHlc, utcNow, ct);
|
||||
await sql.SetApplyingAsync(0, ct);
|
||||
|
||||
LastBatchStatementCount = sql.StatementCount;
|
||||
|
||||
await tx.ExecuteAsync("UPDATE __localdb_applying SET applying = 0 WHERE id = 1", null, ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
db.Clock.Observe(maxHlc);
|
||||
return new ApplyResult(maxSeq, applied, discarded, deadLettered);
|
||||
}
|
||||
|
||||
private static Task DeadLetterAsync(
|
||||
ILocalDbTransaction tx, OplogEntryRecord entry, string error, string utcNow, CancellationToken ct) =>
|
||||
tx.ExecuteAsync(
|
||||
"INSERT INTO __localdb_dead_letter " +
|
||||
"(received_utc, table_name, pk_json, row_json, hlc, node_id, error) " +
|
||||
"VALUES (@utc, @t, @pk, @row, @hlc, @node, @err)",
|
||||
new
|
||||
{
|
||||
utc = utcNow,
|
||||
t = entry.TableName,
|
||||
pk = entry.PkJson,
|
||||
row = entry.RowJson,
|
||||
hlc = entry.Hlc,
|
||||
node = entry.NodeId,
|
||||
err = error,
|
||||
}, ct);
|
||||
|
||||
private static (string Delete, string Upsert) BuildTableSql(ReplicatedTable table)
|
||||
/// <summary>
|
||||
/// The statement set one apply-batch runs on: each distinct statement is built and prepared on
|
||||
/// first use, then rebound and re-executed per row. Statements whose SQL depends on the table
|
||||
/// (delete / upsert) are cached per table; the rest are shared across all of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reusing the same <see cref="SqliteCommand"/> with unchanged <c>CommandText</c> is what makes
|
||||
/// this prepare-once: Microsoft.Data.Sqlite holds the compiled <c>sqlite3_stmt</c> on the command
|
||||
/// and only recompiles when the text changes. <see cref="SqliteCommand.Prepare"/> is called
|
||||
/// explicitly anyway, so the intent is stated rather than inferred from an implementation detail.
|
||||
/// </remarks>
|
||||
private sealed class PreparedStatements(SqliteConnection connection, SqliteTransaction transaction) : IDisposable
|
||||
{
|
||||
var name = Ident(table.Name);
|
||||
private readonly List<SqliteCommand> _owned = [];
|
||||
private readonly Dictionary<string, SqliteCommand> _deletes = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, SqliteCommand> _upserts = new(StringComparer.Ordinal);
|
||||
|
||||
var pkMatch = string.Join(" AND ", table.PkColumns.Select(p => $"{Ident(p)} = json_extract(@pk, {JsonPath(p)})"));
|
||||
var delete = $"DELETE FROM {name} WHERE {pkMatch}";
|
||||
private SqliteCommand? _applying;
|
||||
private SqliteCommand? _lookup;
|
||||
private SqliteCommand? _rowVersion;
|
||||
private SqliteCommand? _deadLetter;
|
||||
private SqliteCommand? _peerState;
|
||||
private SqliteCommand? _savepoint;
|
||||
private SqliteCommand? _releaseSavepoint;
|
||||
private SqliteCommand? _rollbackToSavepoint;
|
||||
|
||||
var columns = string.Join(", ", table.Columns.Select(c => Ident(c.Name)));
|
||||
var values = string.Join(", ", table.Columns.Select(c => $"json_extract(@row, {JsonPath(c.Name)})"));
|
||||
var upsert = $"INSERT OR REPLACE INTO {name} ({columns}) VALUES ({values})";
|
||||
/// <summary>The per-entry savepoint's name. Reused for every entry — one is open at a time.</summary>
|
||||
private const string SavepointName = "__localdb_apply_entry";
|
||||
|
||||
return (delete, upsert);
|
||||
/// <summary>How many distinct statements this set has prepared.</summary>
|
||||
public int StatementCount => _owned.Count;
|
||||
|
||||
/// <summary>Opens the per-entry savepoint that scopes one entry's row + ledger writes.</summary>
|
||||
public Task SavepointAsync(CancellationToken ct)
|
||||
{
|
||||
var cmd = _savepoint ??= Create("SAVEPOINT " + SavepointName);
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Commits the entry's savepoint into the surrounding batch transaction.</summary>
|
||||
public Task ReleaseSavepointAsync(CancellationToken ct)
|
||||
{
|
||||
var cmd = _releaseSavepoint ??= Create("RELEASE " + SavepointName);
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undoes everything the entry did and closes its savepoint. ROLLBACK TO leaves the savepoint
|
||||
/// OPEN, so the RELEASE is required — without it the names would nest one deeper per poison
|
||||
/// entry and the next rollback would unwind more than one entry's work.
|
||||
/// </summary>
|
||||
public async Task RollbackToSavepointAsync(CancellationToken ct)
|
||||
{
|
||||
var cmd = _rollbackToSavepoint ??= Create("ROLLBACK TO " + SavepointName);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
await ReleaseSavepointAsync(ct);
|
||||
}
|
||||
|
||||
public Task SetApplyingAsync(int value, CancellationToken ct)
|
||||
{
|
||||
var cmd = _applying ??= Create(
|
||||
"UPDATE __localdb_applying SET applying = @applying WHERE id = 1", "@applying");
|
||||
cmd.Parameters["@applying"].Value = value;
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<(long Hlc, string NodeId)?> ReadLocalVersionAsync(
|
||||
string tableName, string pkJson, CancellationToken ct)
|
||||
{
|
||||
var cmd = _lookup ??= Create(
|
||||
"SELECT hlc, node_id FROM __localdb_row_version WHERE table_name = @t AND pk_json = @pk",
|
||||
"@t", "@pk");
|
||||
cmd.Parameters["@t"].Value = tableName;
|
||||
cmd.Parameters["@pk"].Value = pkJson;
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
return (reader.GetInt64(0), reader.GetString(1));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(ReplicatedTable table, string pkJson, CancellationToken ct)
|
||||
{
|
||||
if (!_deletes.TryGetValue(table.Name, out var cmd))
|
||||
_deletes[table.Name] = cmd = Create(BuildDelete(table), "@pk");
|
||||
cmd.Parameters["@pk"].Value = pkJson;
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(ReplicatedTable table, string rowJson, CancellationToken ct)
|
||||
{
|
||||
if (!_upserts.TryGetValue(table.Name, out var cmd))
|
||||
_upserts[table.Name] = cmd = Create(BuildUpsert(table), "@row");
|
||||
cmd.Parameters["@row"].Value = rowJson;
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public Task WriteRowVersionAsync(OplogEntryRecord entry, string utcNow, CancellationToken ct)
|
||||
{
|
||||
var cmd = _rowVersion ??= Create(
|
||||
"INSERT OR REPLACE INTO __localdb_row_version " +
|
||||
"(table_name, pk_json, hlc, node_id, is_tombstone, tombstone_utc) " +
|
||||
"VALUES (@t, @pk, @hlc, @node, @tomb, @tut)",
|
||||
"@t", "@pk", "@hlc", "@node", "@tomb", "@tut");
|
||||
cmd.Parameters["@t"].Value = entry.TableName;
|
||||
cmd.Parameters["@pk"].Value = entry.PkJson;
|
||||
cmd.Parameters["@hlc"].Value = entry.Hlc;
|
||||
cmd.Parameters["@node"].Value = entry.NodeId;
|
||||
cmd.Parameters["@tomb"].Value = entry.IsTombstone ? 1 : 0;
|
||||
cmd.Parameters["@tut"].Value = entry.IsTombstone ? utcNow : DBNull.Value;
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public Task DeadLetterAsync(OplogEntryRecord entry, string error, string utcNow, CancellationToken ct)
|
||||
{
|
||||
var cmd = _deadLetter ??= Create(
|
||||
"INSERT INTO __localdb_dead_letter " +
|
||||
"(received_utc, table_name, pk_json, row_json, hlc, node_id, error) " +
|
||||
"VALUES (@utc, @t, @pk, @row, @hlc, @node, @err)",
|
||||
"@utc", "@t", "@pk", "@row", "@hlc", "@node", "@err");
|
||||
cmd.Parameters["@utc"].Value = utcNow;
|
||||
cmd.Parameters["@t"].Value = entry.TableName;
|
||||
cmd.Parameters["@pk"].Value = entry.PkJson;
|
||||
cmd.Parameters["@row"].Value = (object?)entry.RowJson ?? DBNull.Value;
|
||||
cmd.Parameters["@hlc"].Value = entry.Hlc;
|
||||
cmd.Parameters["@node"].Value = entry.NodeId;
|
||||
cmd.Parameters["@err"].Value = error;
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public Task AdvancePeerStateAsync(long maxSeq, long maxHlc, string utcNow, CancellationToken ct)
|
||||
{
|
||||
var cmd = _peerState ??= Create(
|
||||
"UPDATE __localdb_peer_state SET " +
|
||||
"last_applied_remote_seq = MAX(last_applied_remote_seq, @maxSeq), " +
|
||||
"last_seen_hlc = MAX(last_seen_hlc, @maxHlc), last_sync_utc = @utc WHERE id = 1",
|
||||
"@maxSeq", "@maxHlc", "@utc");
|
||||
cmd.Parameters["@maxSeq"].Value = maxSeq;
|
||||
cmd.Parameters["@maxHlc"].Value = maxHlc;
|
||||
cmd.Parameters["@utc"].Value = utcNow;
|
||||
return cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private SqliteCommand Create(string sql, params string[] parameterNames)
|
||||
{
|
||||
var cmd = connection.CreateCommand();
|
||||
try
|
||||
{
|
||||
cmd.Transaction = transaction;
|
||||
cmd.CommandText = sql;
|
||||
foreach (var name in parameterNames)
|
||||
cmd.Parameters.Add(new SqliteParameter(name, DBNull.Value));
|
||||
cmd.Prepare();
|
||||
_owned.Add(cmd);
|
||||
return cmd;
|
||||
}
|
||||
catch
|
||||
{
|
||||
cmd.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var cmd in _owned)
|
||||
cmd.Dispose();
|
||||
_owned.Clear();
|
||||
}
|
||||
|
||||
private static string BuildDelete(ReplicatedTable table)
|
||||
{
|
||||
var pkMatch = string.Join(
|
||||
" AND ", table.PkColumns.Select(p => $"{Ident(p)} = json_extract(@pk, {JsonPath(p)})"));
|
||||
return $"DELETE FROM {Ident(table.Name)} WHERE {pkMatch}";
|
||||
}
|
||||
|
||||
private static string BuildUpsert(ReplicatedTable table)
|
||||
{
|
||||
var columns = string.Join(", ", table.Columns.Select(c => Ident(c.Name)));
|
||||
var values = string.Join(", ", table.Columns.Select(c => $"json_extract(@row, {JsonPath(c.Name)})"));
|
||||
return $"INSERT OR REPLACE INTO {Ident(table.Name)} ({columns}) VALUES ({values})";
|
||||
}
|
||||
|
||||
// Local copy of TriggerSqlGenerator's convention (that helper lives in the core assembly).
|
||||
private static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
|
||||
|
||||
// A single-quoted JSON path with a quoted key member, so a column name with reserved
|
||||
// characters still resolves: $."col". The key's double quotes are backslash-escaped per
|
||||
// JSON path grammar.
|
||||
private static string JsonPath(string column) => "'$.\"" + column.Replace("\"", "\\\"") + "\"'";
|
||||
}
|
||||
|
||||
// Local copy of TriggerSqlGenerator's convention (that helper lives in the core assembly).
|
||||
private static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
|
||||
|
||||
// A single-quoted JSON path with a quoted key member, so a column name with reserved characters
|
||||
// still resolves: $."col". The key's double quotes are backslash-escaped per JSON path grammar.
|
||||
private static string JsonPath(string column) => "'$.\"" + column.Replace("\"", "\\\"") + "\"'";
|
||||
}
|
||||
|
||||
@@ -24,8 +24,9 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
|
||||
|
||||
/// <summary>
|
||||
/// Reads up to <paramref name="maxBatch"/> oplog entries with <c>seq > afterSeq</c> in ascending
|
||||
/// seq order. Caller contract: check <c>needs_snapshot</c> before trusting incremental reads —
|
||||
/// after cap-pruning, reads silently resume past a gap.
|
||||
/// seq order. Caller contract: this read resumes SILENTLY past a cap-pruned gap, so a caller
|
||||
/// streaming incrementally must first confirm its position is still below the horizon — see
|
||||
/// <see cref="GetMinSeqAsync"/>, which <c>SyncSession</c>'s pump checks on every iteration.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<OplogEntryRecord>> ReadBatchAboveAsync(long afterSeq, int maxBatch, CancellationToken ct = default) =>
|
||||
db.QueryAsync(
|
||||
@@ -70,12 +71,42 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
|
||||
"DELETE FROM __localdb_row_version WHERE is_tombstone = 1 AND tombstone_utc < @cutoff",
|
||||
new { cutoff = FormatUtc(_utcNow() - options.TombstoneRetention) }, ct);
|
||||
|
||||
/// <summary>Counts the unacked backlog: oplog entries above the peer's last-acked watermark.</summary>
|
||||
/// <summary>
|
||||
/// The unacked backlog — oplog entries above the peer's last-acked watermark — in O(1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why not <c>COUNT(*)</c>.</b> The obvious query walks every unacked row, and this value is
|
||||
/// read once a minute by maintenance AND on every metrics scrape. A node whose peer has been
|
||||
/// down for a day therefore pays a full scan of a million-row backlog, repeatedly, precisely
|
||||
/// when it is already under stress.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The formula.</b> <c>MAX(seq) - MAX(last_acked_seq, MIN(seq) - 1)</c>, floored at 0. Both
|
||||
/// MIN and MAX over <c>seq</c> are single b-tree seeks (it is the rowid, and SQLite's min/max
|
||||
/// optimization turns each into one seek), and the peer-state read is a one-row lookup — so the
|
||||
/// whole thing is three seeks regardless of backlog size.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why it is exact.</b> <c>seq</c> is AUTOINCREMENT, so retained rows are a contiguous run
|
||||
/// wherever nothing has punched a hole in the middle — and no ordinary path punches holes: ack
|
||||
/// pruning deletes <c>seq <= last_acked_seq</c> (a prefix), the row cap deletes below
|
||||
/// <c>MAX(seq) - keep + 1</c> (a prefix), and the age cap deletes below an HLC cutoff, which is
|
||||
/// a prefix because HLCs are stamped in seq order. Clamping the floor to <c>MIN(seq) - 1</c> is
|
||||
/// what keeps the answer exact after a prune has moved the horizon above the ack watermark;
|
||||
/// without it a cap-pruned oplog would report its whole seq range forever and re-flag the cap on
|
||||
/// every tick.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The one inexact case</b> is <c>ILocalDb.DeregisterReplicated</c>, which deletes one
|
||||
/// table's rows from wherever they sit and so can leave holes. The estimate then over-reports by
|
||||
/// at most the number of holes, until the watermark advances past them. Over-reporting is the
|
||||
/// safe direction: it can only trigger a snapshot resync earlier, never suppress one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<long> GetOplogDepthAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = await db.QueryAsync(
|
||||
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > (SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1)",
|
||||
static r => r.GetInt64(0), null, ct);
|
||||
var rows = await db.QueryAsync(DepthSql, static r => r.GetInt64(0), null, ct);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
@@ -84,7 +115,7 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
|
||||
/// metric-observation callbacks (gauge scrape / status poll); mirrors
|
||||
/// <see cref="GetOplogDepthAsync"/>'s query. Deliberate tradeoff: synchronous I/O on the
|
||||
/// metrics-collection thread is acceptable here because WAL-mode readers never block (or are
|
||||
/// blocked by) writers, the COUNT is indexed-cheap, and the worst case is bounded by
|
||||
/// blocked by) writers, the query is three b-tree seeks, and the worst case is bounded by
|
||||
/// <c>BusyTimeoutMs</c> — all at scrape frequency, not on any data path. May throw if the db
|
||||
/// is disposing — callers on the observation path swallow and drop the sample.
|
||||
/// </summary>
|
||||
@@ -92,11 +123,18 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
|
||||
{
|
||||
using var conn = db.CreateConnection();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > (SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1)";
|
||||
cmd.CommandText = DepthSql;
|
||||
return (long)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
// Both depth accessors run exactly this; keeping it in one place is what makes "mirrors the
|
||||
// async query" a fact rather than a comment that can rot.
|
||||
internal const string DepthSql =
|
||||
"SELECT MAX(0, " +
|
||||
"COALESCE((SELECT MAX(seq) FROM __localdb_oplog), 0) - " +
|
||||
"MAX((SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1), " +
|
||||
"COALESCE((SELECT MIN(seq) FROM __localdb_oplog), 1) - 1))";
|
||||
|
||||
/// <summary>
|
||||
/// The highest seq this node has ever produced: the oplog's max, or — once acked rows have been
|
||||
/// pruned away — the ack watermark that pruned them. Nothing we emit can exceed it, so a peer
|
||||
@@ -111,6 +149,24 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The oldest oplog seq still present — the delta horizon — or 0 when the oplog is empty.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One b-tree seek (<c>seq</c> is the rowid and SQLite optimizes MIN into a seek), which is what
|
||||
/// makes it affordable for the pump to check on every flush interval. Compared against a
|
||||
/// session's sent watermark it answers the only question the pump has: is everything I have not
|
||||
/// yet sent still here? Ack-pruning deletes <c>seq <= last_acked_seq</c> and the peer can only
|
||||
/// ack what it received, so in a healthy session the horizon never climbs past
|
||||
/// <c>sentThruSeq + 1</c>; a higher one means a cap prune ate unsent deltas.
|
||||
/// </remarks>
|
||||
public async Task<long> GetMinSeqAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = await db.QueryAsync(
|
||||
"SELECT COALESCE(MIN(seq), 0) FROM __localdb_oplog", static r => r.GetInt64(0), null, ct);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/// <summary>The highest oplog seq present, or 0 when the oplog is empty.</summary>
|
||||
public async Task<long> GetMaxSeqAsync(CancellationToken ct = default)
|
||||
{
|
||||
@@ -133,9 +189,9 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
|
||||
// Condition reads and prunes share the transaction so the decision cannot go stale (TOCTOU).
|
||||
await using var tx = await db.BeginTransactionAsync(ct);
|
||||
|
||||
var depth = (await tx.QueryAsync(
|
||||
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > (SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1)",
|
||||
static r => r.GetInt64(0), null, ct))[0];
|
||||
// Same O(1) expression as GetOplogDepthAsync — the caps check is the per-minute reader that
|
||||
// made the old COUNT(*) expensive in the first place.
|
||||
var depth = (await tx.QueryAsync(DepthSql, static r => r.GetInt64(0), null, ct))[0];
|
||||
var overRows = depth > options.MaxOplogRows;
|
||||
|
||||
var oldest = await tx.QueryAsync(
|
||||
@@ -196,10 +252,61 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
|
||||
db.ExecuteAsync(
|
||||
"UPDATE __localdb_peer_state SET last_applied_remote_seq = 0, last_seen_hlc = 0 WHERE id = 1", null, ct);
|
||||
|
||||
/// <summary>Sets or clears the snapshot-resync-required flag.</summary>
|
||||
/// <summary>
|
||||
/// Sets or clears the DATABASE-WIDE snapshot-resync flag — the one that means "every registered
|
||||
/// table". Set by the oplog-horizon paths (cap prune, impossible peer ack), which invalidate
|
||||
/// delta continuity for all tables at once. Per-table flags live in
|
||||
/// <c>__localdb_snapshot_state</c>; see <see cref="GetTablesNeedingSnapshotAsync"/>.
|
||||
/// </summary>
|
||||
public Task SetNeedsSnapshotAsync(bool value, CancellationToken ct = default) =>
|
||||
db.ExecuteAsync("UPDATE __localdb_peer_state SET needs_snapshot = @v WHERE id = 1", new { v = value ? 1 : 0 }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// The tables individually flagged as owing the peer a snapshot (schema v3), in ordinal name
|
||||
/// order. Independent of the database-wide flag: baselining one table flags only that table.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<string>> GetTablesNeedingSnapshotAsync(CancellationToken ct = default) =>
|
||||
db.QueryAsync(
|
||||
"SELECT table_name FROM __localdb_snapshot_state WHERE needs_snapshot <> 0 ORDER BY table_name",
|
||||
static r => r.GetString(0), null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the snapshot debt a completed snapshot discharged: the named tables when
|
||||
/// <paramref name="tables"/> is non-empty, otherwise <b>everything</b> — the database-wide flag
|
||||
/// and every per-table row, because a full snapshot subsumes each of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Rows are deleted rather than zeroed: absence is the resting state, and keeping a row per
|
||||
/// table that has ever been baselined would accumulate bookkeeping that means nothing. Runs in
|
||||
/// one transaction so a full snapshot's two clears cannot half-apply and leave the pair
|
||||
/// re-snapshotting on every reconnect.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A scoped clear deliberately does NOT touch the database-wide flag. If the cap enforcer
|
||||
/// flagged a whole-database resync while this snapshot was in flight, that debt is real and
|
||||
/// unrelated — the snapshot only carried some of the tables — so it must survive. The full
|
||||
/// clear may take it down because a full snapshot carries every row state as of its as-of seq,
|
||||
/// which is exactly what the pruned deltas would have delivered.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task ClearSnapshotDebtAsync(IReadOnlyList<string> tables, CancellationToken ct = default)
|
||||
{
|
||||
await using var tx = await db.BeginTransactionAsync(ct);
|
||||
if (tables.Count == 0)
|
||||
{
|
||||
await tx.ExecuteAsync("UPDATE __localdb_peer_state SET needs_snapshot = 0 WHERE id = 1", null, ct);
|
||||
await tx.ExecuteAsync("DELETE FROM __localdb_snapshot_state", null, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var table in tables)
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM __localdb_snapshot_state WHERE table_name = @t", new { t = table }, ct);
|
||||
}
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the highest remote seq applied locally plus the peer HLC observed with it; also stamps
|
||||
/// <c>last_sync_utc</c>. Both watermarks are MAX-guarded (monotonic): a snapshot completing with a
|
||||
|
||||
@@ -19,21 +19,57 @@ internal interface ISnapshotApplier
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams a full snapshot of every registered table to a peer that has fallen behind the pruned
|
||||
/// oplog horizon. Reads the authoritative per-row version from <c>__localdb_row_version</c> joined
|
||||
/// Which tables a snapshot covers. An EMPTY <see cref="Tables"/> is the wire's own encoding of
|
||||
/// "all registered tables" (see <c>SnapshotBegin.tables</c>) and is what every pre-0.3.0 peer both
|
||||
/// sends and expects, so it is deliberately the same value here rather than a separate flag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The distinction is not cosmetic: a FULL snapshot carries every row state as of its
|
||||
/// <c>as_of_seq</c>, so the receiver may jump its watermark there and the sender may skip the
|
||||
/// deltas it covered. A PARTIAL one carries only the listed tables, so neither side may move a
|
||||
/// watermark that also speaks for the tables it left out.
|
||||
/// </remarks>
|
||||
internal sealed record SnapshotScope(IReadOnlyList<string> Tables)
|
||||
{
|
||||
/// <summary>Every registered table — the only scope a pre-0.3.0 peer can be sent.</summary>
|
||||
public static readonly SnapshotScope Full = new([]);
|
||||
|
||||
public bool IsPartial => Tables.Count > 0;
|
||||
|
||||
public bool Includes(string tableName) =>
|
||||
Tables.Count == 0 || Tables.Contains(tableName, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams a snapshot of the tables in the requested <see cref="SnapshotScope"/> — every registered
|
||||
/// table for a peer that has fallen behind the pruned oplog horizon, or just the baselined ones for
|
||||
/// a scoped resync. Reads the authoritative per-row version from <c>__localdb_row_version</c> joined
|
||||
/// to each live table, pages it into <see cref="SnapshotBatch"/> messages through the gated data
|
||||
/// lane, and brackets them with SnapshotBegin/SnapshotComplete carrying the max oplog seq as-of.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Clearing the snapshot debt is deliberately NOT done here: whether the flags may be cleared at all
|
||||
/// depends on the peer's capability (a 0.3.0 peer acknowledges the snapshot, a pre-0.3.0 one cannot),
|
||||
/// which is session state. <see cref="SyncSession"/> owns that decision; this type only streams.
|
||||
/// </remarks>
|
||||
internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, ReplicationOptions options, ILogger logger)
|
||||
{
|
||||
/// <summary>Streams the full snapshot; returns the as-of oplog seq it covers, so the session can skip already-covered tail deltas.</summary>
|
||||
public async Task<long> SendAsync(Func<SyncMessage, CancellationToken, Task> send, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Streams the snapshot for <paramref name="scope"/>; returns the as-of oplog seq it covers, so
|
||||
/// the session can skip already-covered tail deltas (a FULL snapshot only — a partial one speaks
|
||||
/// for nothing outside its tables).
|
||||
/// </summary>
|
||||
public async Task<long> SendAsync(SnapshotScope scope, Func<SyncMessage, CancellationToken, Task> send, CancellationToken ct)
|
||||
{
|
||||
var asOfSeq = await store.GetMaxSeqAsync(ct);
|
||||
await send(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = asOfSeq } }, ct);
|
||||
var begin = new SnapshotBegin { AsOfSeq = asOfSeq };
|
||||
begin.Tables.AddRange(scope.Tables);
|
||||
await send(new SyncMessage { SnapshotBegin = begin }, ct);
|
||||
|
||||
var totalRows = 0L;
|
||||
foreach (var table in db.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
foreach (var table in db.ReplicatedTables.Values
|
||||
.Where(t => scope.Includes(t.Name))
|
||||
.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var sql = BuildPageSql(table);
|
||||
// Keyset cursor (pk_json > last seen), not LIMIT/OFFSET: concurrent inserts/prunes
|
||||
@@ -57,7 +93,13 @@ internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, Repli
|
||||
break;
|
||||
lastPk = rows[^1].PkJson;
|
||||
|
||||
// One DB page may span several messages: the page is bounded by ROW COUNT, but a
|
||||
// message is bounded by BYTES (ReplicationOptions.MaxBatchBytes) — the same budget
|
||||
// the delta pump honours, and for the same reason. Wide rows are exactly why a
|
||||
// snapshot is the easiest way to exceed the transport's message limit: it ships
|
||||
// whole rows, all of them, back to back.
|
||||
var batch = new SnapshotBatch { TableName = table.Name };
|
||||
var bytes = 0L;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!row.IsTombstone && row.RowJson is null)
|
||||
@@ -79,11 +121,25 @@ internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, Repli
|
||||
};
|
||||
if (row.RowJson is not null)
|
||||
snapshotRow.RowJson = row.RowJson;
|
||||
|
||||
var size = RowSize(snapshotRow);
|
||||
// Flush before adding, never after: a message keeps at least one row however
|
||||
// large, so an oversized row goes out alone rather than stalling the snapshot.
|
||||
if (batch.Rows.Count > 0 && bytes + size > options.MaxBatchBytes)
|
||||
{
|
||||
await send(new SyncMessage { SnapshotBatch = batch }, ct);
|
||||
totalRows += batch.Rows.Count;
|
||||
batch = new SnapshotBatch { TableName = table.Name };
|
||||
bytes = 0;
|
||||
}
|
||||
batch.Rows.Add(snapshotRow);
|
||||
bytes += size;
|
||||
}
|
||||
if (batch.Rows.Count > 0)
|
||||
{
|
||||
await send(new SyncMessage { SnapshotBatch = batch }, ct);
|
||||
totalRows += batch.Rows.Count;
|
||||
totalRows += batch.Rows.Count;
|
||||
}
|
||||
|
||||
if (rows.Count < options.MaxBatchSize)
|
||||
break;
|
||||
@@ -91,24 +147,29 @@ internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, Repli
|
||||
}
|
||||
|
||||
await send(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = asOfSeq } }, ct);
|
||||
// The sender owed this snapshot (its own needs_snapshot); clearing it here — after the whole
|
||||
// snapshot is on the wire — stops it re-streaming on the next reconnect. Narrow window: the
|
||||
// messages are enqueued on the data lane, not yet transport-delivered, so a crash/drop after
|
||||
// this clear could lose the snapshot with the flag already down. The reconnect handshake's
|
||||
// gap check (peer watermark below the pruned horizon) re-detects that in every case where the
|
||||
// oplog is non-empty; the sole undetectable corner is an EMPTY oplog (nothing to gap-check),
|
||||
// where the peer stays behind until new local writes recreate a horizon. Accepted: closing it
|
||||
// would need transport-level delivery acks for snapshot frames.
|
||||
await store.SetNeedsSnapshotAsync(false, ct);
|
||||
logger.LogInformation("Snapshot sent (as_of_seq {AsOfSeq}, {Rows} rows).", asOfSeq, totalRows);
|
||||
logger.LogInformation(
|
||||
"Snapshot sent (as_of_seq {AsOfSeq}, {Rows} rows, scope {Scope}).",
|
||||
asOfSeq, totalRows, scope.IsPartial ? string.Join(",", scope.Tables) : "all");
|
||||
return asOfSeq;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialized bytes one row contributes to its batch message: the row's own encoding plus the
|
||||
/// repeated field's tag and length prefix, as protobuf itself computes them.
|
||||
/// </summary>
|
||||
private static long RowSize(SnapshotRow row) =>
|
||||
1 + Google.Protobuf.CodedOutputStream.ComputeMessageSize(row);
|
||||
|
||||
// Pages one table's rows by joining the row-version ledger to the live table on the PK columns
|
||||
// extracted from pk_json. Rows present in the live table but absent from __localdb_row_version
|
||||
// (data written BEFORE the table was registered for replication) are intentionally NOT snapshotted:
|
||||
// they were never captured, consistent with CDC semantics — a full re-registration is required to
|
||||
// baseline them.
|
||||
// (data written BEFORE the table was registered for replication) are NOT snapshotted: they were
|
||||
// never captured, consistent with CDC semantics.
|
||||
//
|
||||
// That is a real hole for a node that starts replicating LATE — its whole pre-existing dataset is
|
||||
// invisible to the peer, silently and permanently — and the supported way to close it is to
|
||||
// register with `ILocalDb.RegisterReplicated(table, baselineExistingRows: true)`, which seeds the
|
||||
// ledger at an LWW-safe floor and flags the resync that carries the rows here. Nothing changes on
|
||||
// this side: baselined rows are ordinary ledger rows and page like any other.
|
||||
private static string BuildPageSql(ReplicatedTable table)
|
||||
{
|
||||
var name = Ident(table.Name);
|
||||
@@ -149,6 +210,7 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
private long _asOfSeq;
|
||||
private long _rows;
|
||||
private long _maxHlc;
|
||||
private bool _partial;
|
||||
|
||||
// db is part of the collaborator's canonical shape (mirrors the streamer) even though the merge
|
||||
// reaches the database through the applier and store; kept for construction symmetry in the factory.
|
||||
@@ -165,7 +227,13 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
_asOfSeq = begin.AsOfSeq;
|
||||
_rows = 0;
|
||||
_maxHlc = 0;
|
||||
_logger.LogInformation("Snapshot receive begun (as_of_seq {AsOfSeq}).", _asOfSeq);
|
||||
// A non-empty table list scopes the snapshot; empty (which is all a pre-0.3.0 sender can
|
||||
// emit) means every registered table. Read here rather than on complete because
|
||||
// SnapshotComplete does not repeat it — the receive loop guarantees a Begin precedes both.
|
||||
_partial = begin.Tables.Count > 0;
|
||||
_logger.LogInformation(
|
||||
"Snapshot receive begun (as_of_seq {AsOfSeq}, scope {Scope}).",
|
||||
_asOfSeq, _partial ? string.Join(",", begin.Tables) : "all");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -187,9 +255,20 @@ internal sealed class SnapshotApplier : ISnapshotApplier
|
||||
|
||||
public async Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct)
|
||||
{
|
||||
// A PARTIAL snapshot may not move the inbound seq watermark. That watermark is one number
|
||||
// for the whole peer oplog, and jumping it to as_of_seq would declare every table applied
|
||||
// through that point — including the ones this snapshot deliberately left out, whose deltas
|
||||
// below as_of_seq the sender would then never resend. Only the HLC is carried across (seq 0
|
||||
// is a no-op against the MAX guard), so the clock still cannot regress below what the peer
|
||||
// has stamped. The sender makes the mirror-image concession: it does not advance its own
|
||||
// sent-thru watermark past a partial snapshot, so those deltas are still pumped normally
|
||||
// and merely re-deliver rows the snapshot already carried, which LWW absorbs.
|
||||
var seq = _partial ? 0 : complete.AsOfSeq;
|
||||
// SetLastAppliedRemoteSeqAsync is MAX-guarded, so a delta applied mid-snapshot with a seq
|
||||
// above the snapshot's as-of (or an HLC above what the snapshot carried) is never regressed.
|
||||
await _store.SetLastAppliedRemoteSeqAsync(complete.AsOfSeq, _maxHlc, ct);
|
||||
_logger.LogInformation("Snapshot receive complete (as_of_seq {AsOfSeq}, {Rows} rows).", complete.AsOfSeq, _rows);
|
||||
await _store.SetLastAppliedRemoteSeqAsync(seq, _maxHlc, ct);
|
||||
_logger.LogInformation(
|
||||
"Snapshot receive complete (as_of_seq {AsOfSeq}, {Rows} rows, partial {Partial}).",
|
||||
complete.AsOfSeq, _rows, _partial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ namespace ZB.MOM.WW.LocalDb.Replication.Internal;
|
||||
/// <summary>
|
||||
/// The symmetric 2-node sync protocol as one state machine: both the gRPC service and the gRPC
|
||||
/// client run this identical logic over a <see cref="SyncDuplex"/>. It performs the fail-closed
|
||||
/// handshake, then runs a bidirectional steady state — an outbound change pump and an inbound
|
||||
/// apply/ack loop — until the token is cancelled or the stream ends. All outbound traffic flows
|
||||
/// through a single writer task fed by two lanes: an unbounded CONTROL lane (handshake messages,
|
||||
/// acks) and a bounded DATA lane (delta/snapshot batches). Receiving therefore never blocks on the
|
||||
/// ability to write — acks always fit — while the bounded data lane is the pump's backpressure.
|
||||
/// handshake, then runs a bidirectional steady state until the token is cancelled or the stream
|
||||
/// ends: an outbound phase (the full snapshot this node owes, if any, then the change pump) and an
|
||||
/// inbound apply/ack loop, STARTED TOGETHER so the inbox keeps draining even while a snapshot is
|
||||
/// being sent. All outbound traffic flows through a single writer task fed by two lanes: an
|
||||
/// unbounded CONTROL lane (handshake messages, acks) and a bounded DATA lane (delta/snapshot
|
||||
/// batches). Receiving therefore never blocks on the ability to write — acks always fit — while the
|
||||
/// bounded data lane is the pump's backpressure.
|
||||
/// Local writes never block on the peer; the first fault tears down all loops and propagates.
|
||||
/// </summary>
|
||||
internal sealed class SyncSession
|
||||
@@ -25,6 +27,10 @@ internal sealed class SyncSession
|
||||
// inbound batch rate (the peer's flow control), so its lane can safely be unbounded.
|
||||
private const int DataLaneCapacity = 16;
|
||||
|
||||
// How long a faulting session's teardown lets the writer keep flushing already-enqueued
|
||||
// messages before the backstop cancel; only ever reached when the transport itself stalls.
|
||||
private static readonly TimeSpan FaultFlushGrace = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly SqliteLocalDb _db;
|
||||
private readonly OplogStore _store;
|
||||
private readonly LwwApplier _applier;
|
||||
@@ -45,6 +51,17 @@ internal sealed class SyncSession
|
||||
// single receive-loop task, so no synchronization is needed.
|
||||
private bool _snapshotReceiving;
|
||||
|
||||
// Whether the peer speaks the 0.3.0 snapshot extensions: scoped snapshots
|
||||
// (SnapshotBegin.tables) and the SnapshotAck that answers a completed one. Written once during
|
||||
// the handshake — which completes before either loop starts — then read-only.
|
||||
private bool _peerSupportsPartialSnapshot;
|
||||
|
||||
// The scope of the snapshot this node is streaming, held until the peer's SnapshotAck discharges
|
||||
// it; null on every session that owes none. Assigned on the pump task strictly BEFORE the
|
||||
// snapshot's first message is enqueued and read on the receive loop when the ack lands, so the
|
||||
// ordering is causal — volatile only to make the cross-thread publication explicit.
|
||||
private SnapshotScope? _sentSnapshotScope;
|
||||
|
||||
public SyncSession(
|
||||
SqliteLocalDb db, OplogStore store, LwwApplier applier, ReplicationOptions options, ILogger logger,
|
||||
Func<DateTimeOffset>? utcNow = null, LocalDbMetrics? metrics = null, SyncStatus? status = null)
|
||||
@@ -61,11 +78,12 @@ internal sealed class SyncSession
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when THIS node owes the peer a snapshot (its own snapshot-required computation, not
|
||||
/// the peer's) before deltas resume. Receives a gated send delegate that enqueues on the bounded
|
||||
/// data lane (never the raw transport); returns the as-of oplog seq the snapshot covers so the
|
||||
/// pump can skip already-covered tail deltas. Wired to <see cref="SnapshotStreamer.SendAsync"/>.
|
||||
/// the peer's) before deltas resume. Receives the negotiated <see cref="SnapshotScope"/> and a
|
||||
/// gated send delegate that enqueues on the bounded data lane (never the raw transport); returns
|
||||
/// the as-of oplog seq the snapshot covers so the pump can skip already-covered tail deltas.
|
||||
/// Wired to <see cref="SnapshotStreamer.SendAsync"/>.
|
||||
/// </summary>
|
||||
public Func<Func<SyncMessage, CancellationToken, Task>, CancellationToken, Task<long>>? SnapshotSender { get; set; }
|
||||
public Func<SnapshotScope, Func<SyncMessage, CancellationToken, Task>, CancellationToken, Task<long>>? SnapshotSender { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Consumes an inbound snapshot inline on the receive loop: SnapshotBegin opens the receive,
|
||||
@@ -97,10 +115,19 @@ internal sealed class SyncSession
|
||||
var writer = WriterLoopAsync(duplex, control.Reader, data.Reader, linked.Token);
|
||||
try
|
||||
{
|
||||
await HandshakeAsync(duplex, control.Writer, GatedDataSend, linked.Token);
|
||||
var snapshotScope = await HandshakeAsync(duplex, control.Writer, linked.Token);
|
||||
|
||||
var pump = PumpLoopAsync(data.Writer, linked.Token);
|
||||
// The receive loop starts BEFORE any snapshot this node owes is streamed. That ordering
|
||||
// is load-bearing, not incidental: a snapshot sent from inside the handshake left the
|
||||
// inbox undrained for its whole duration, so a MUTUAL snapshot (both sides flagged —
|
||||
// e.g. both baselined an existing dataset) required an unbounded inbox to avoid
|
||||
// deadlock, and therefore buffered a whole peer dataset in memory. With the receive loop
|
||||
// running throughout, the peer's snapshot is applied as it arrives, the inbox can be
|
||||
// bounded (see GrpcSyncDuplex.InboxCapacity), and mutual snapshots exchange under
|
||||
// ordinary flow control. Interleaving is already safe by design: snapshot rows and
|
||||
// deltas both merge through LWW, and the snapshot's watermark write is MAX-guarded.
|
||||
var receive = ReceiveLoopAsync(duplex, control.Writer, linked.Token);
|
||||
var pump = SnapshotThenPumpLoopAsync(snapshotScope, GatedDataSend, data.Writer, linked.Token);
|
||||
var loops = new[] { pump, receive, writer };
|
||||
|
||||
// The first COMPLETED task is the primary outcome. Cancel the siblings, observe every
|
||||
@@ -117,15 +144,33 @@ internal sealed class SyncSession
|
||||
{
|
||||
// Handshake-phase faults (and the primary rethrow above) land here: the writer may
|
||||
// still be running, so stop and observe it before the primary exception surfaces.
|
||||
linked.Cancel();
|
||||
// It must be allowed to DRAIN, not be cancelled outright: the Handshake enqueued at
|
||||
// the top of HandshakeAsync can still be sitting in the control lane (the enqueue →
|
||||
// receive → validate → fault path has no yields, while the writer's wakeup waits in
|
||||
// the thread-pool queue), and that message is the only thing that lets the peer
|
||||
// compute its own fail-closed verdict — mismatch detection is symmetric, so this
|
||||
// node's locally-thrown verdict reaches the peer only as the Handshake it transmits.
|
||||
// Cancelling first killed it almost every time the peer's handshake was already
|
||||
// buffered, leaving the healthy peer to misread a schema mismatch as a generic
|
||||
// disconnect and reconnect-loop at the fast backoff. Completing the lanes lets the
|
||||
// writer send what is already enqueued and exit on its own; the deferred cancel is
|
||||
// the backstop that keeps a wedged transport from holding teardown hostage.
|
||||
control.Writer.TryComplete();
|
||||
data.Writer.TryComplete();
|
||||
linked.CancelAfter(FaultFlushGrace);
|
||||
await DrainSecondaryAsync(writer);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandshakeAsync(
|
||||
SyncDuplex duplex, ChannelWriter<SyncMessage> controlLane,
|
||||
Func<SyncMessage, CancellationToken, Task> dataSend, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Runs the two-phase fail-closed handshake and returns the snapshot THIS node owes the peer —
|
||||
/// null for none, otherwise the <see cref="SnapshotScope"/> to stream (its own computation from
|
||||
/// its own state; the peer's answer drives the peer's send direction, symmetrically). Sending
|
||||
/// that snapshot is deliberately NOT part of the handshake; see <see cref="RunAsync"/>.
|
||||
/// </summary>
|
||||
private async Task<SnapshotScope?> HandshakeAsync(
|
||||
SyncDuplex duplex, ChannelWriter<SyncMessage> controlLane, CancellationToken ct)
|
||||
{
|
||||
var peerState = await _store.GetPeerStateAsync(ct);
|
||||
var localHandshake = BuildHandshake(peerState);
|
||||
@@ -142,6 +187,11 @@ internal sealed class SyncSession
|
||||
|
||||
ValidateHandshake(localHandshake, peerHandshake);
|
||||
|
||||
// Read before anything can act on it. A pre-0.3.0 peer leaves the field at its proto3
|
||||
// default, so "false" covers both "explicitly unsupported" and "older build" without a
|
||||
// version check — which is the point of advertising a capability rather than a version.
|
||||
_peerSupportsPartialSnapshot = peerHandshake.SupportsPartialSnapshot;
|
||||
|
||||
// A different node id on the far end means the peer was replaced, not merely restarted — a
|
||||
// rebuilt host, a restored-from-nothing volume. Its oplog numbers from 1 again, so the
|
||||
// watermark we hold (in the OLD peer's seq space) would silently swallow its first N
|
||||
@@ -175,34 +225,78 @@ internal sealed class SyncSession
|
||||
_sentThruSeq = peerAppliedThruSeq;
|
||||
Interlocked.Exchange(ref _peerAckedSeq, peerAppliedThruSeq);
|
||||
|
||||
var snapshotRequired = await ComputeSnapshotRequiredAsync(peerHandshake, peerState, ct);
|
||||
var snapshotScope = await ComputeSnapshotScopeAsync(peerHandshake, peerState, ct);
|
||||
await controlLane.WriteAsync(
|
||||
new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = _db.NodeId, SnapshotRequired = snapshotRequired } }, ct);
|
||||
new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = _db.NodeId, SnapshotRequired = snapshotScope is not null } }, ct);
|
||||
|
||||
var peerAckMsg = await ReceiveOneAsync(duplex, ct);
|
||||
if (peerAckMsg.MsgCase != SyncMessage.MsgOneofCase.HandshakeAck)
|
||||
throw new InvalidOperationException(
|
||||
$"Replication protocol error: expected a HandshakeAck, got {peerAckMsg.MsgCase}.");
|
||||
|
||||
// We stream the snapshot when WE owe the peer one (computed from our pruned horizon /
|
||||
// needs_snapshot), then it lands on the peer's receive loop. The peer's own flag drives ITS
|
||||
// send direction symmetrically; both are safe to run because LWW merges either way.
|
||||
// Memory profile of a MUTUAL snapshot: while we send here (receive loop not yet running),
|
||||
// the peer's inbound frames — potentially its entire snapshot — buffer in the unbounded
|
||||
// duplex inbox until our send completes. Worst case one full peer snapshot held in memory;
|
||||
// acceptable for the small LocalDb datasets this library targets.
|
||||
if (snapshotRequired)
|
||||
return snapshotScope;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outbound steady state: stream the full snapshot this node owes the peer (if any), then
|
||||
/// run the incremental delta pump. One task, because the ordering between them is a protocol
|
||||
/// requirement — the pump must not put deltas on the data lane ahead of the snapshot, and it
|
||||
/// starts from the as-of seq the snapshot covered.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split out of the handshake so the receive loop is already draining the inbox while this
|
||||
/// streams (see <see cref="RunAsync"/>). Nothing else about the snapshot changes: it goes
|
||||
/// through the same gated data lane, so it is still bounded by the writer's flow control rather
|
||||
/// than racing the transport.
|
||||
/// </remarks>
|
||||
private async Task SnapshotThenPumpLoopAsync(
|
||||
SnapshotScope? snapshotScope, Func<SyncMessage, CancellationToken, Task> dataSend,
|
||||
ChannelWriter<SyncMessage> dataLane, CancellationToken ct)
|
||||
{
|
||||
if (snapshotScope is not null)
|
||||
{
|
||||
if (SnapshotSender is null)
|
||||
throw new NotSupportedException("snapshot required but no snapshot sender configured");
|
||||
var snapshotAsOfSeq = await SnapshotSender(dataSend, ct);
|
||||
// Published BEFORE the first frame is enqueued, so the peer's SnapshotAck can never
|
||||
// arrive on the receive loop while this is still null.
|
||||
Volatile.Write(ref _sentSnapshotScope, snapshotScope);
|
||||
var snapshotAsOfSeq = await SnapshotSender(snapshotScope, dataSend, ct);
|
||||
_metrics?.RecordSnapshotSent();
|
||||
_status?.SetLastSyncUtc(_utcNow());
|
||||
// The snapshot already carries every row state up to as-of; skipping the pump past it
|
||||
// avoids re-sending tail deltas the peer would only discard via LWW.
|
||||
if (snapshotAsOfSeq > _sentThruSeq)
|
||||
// A FULL snapshot carries every row state up to as-of, so skipping the pump past it
|
||||
// avoids re-sending tail deltas the peer would only discard via LWW. A PARTIAL one
|
||||
// speaks only for its own tables, so the pump must still deliver every delta below
|
||||
// as-of — the peer has not moved its watermark either (see SnapshotApplier.OnComplete).
|
||||
if (!snapshotScope.IsPartial && snapshotAsOfSeq > _sentThruSeq)
|
||||
_sentThruSeq = snapshotAsOfSeq;
|
||||
|
||||
// A peer that cannot acknowledge the snapshot leaves only the 0.2.x option: clear once
|
||||
// the frames are on the data lane. The window that opens (enqueued, not yet delivered —
|
||||
// a crash there loses the snapshot with the debt already discharged) is why 0.3.0 peers
|
||||
// wait for the ack instead. The reconnect handshake's gap check re-detects it whenever
|
||||
// the oplog is non-empty; an EMPTY oplog remains the undetectable corner.
|
||||
if (!_peerSupportsPartialSnapshot)
|
||||
await ClearSentSnapshotDebtAsync(ct);
|
||||
}
|
||||
|
||||
await PumpLoopAsync(dataLane, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discharges the snapshot debt this session's outbound snapshot covered, exactly once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Taking the scope out of the field before clearing makes this idempotent, which matters
|
||||
/// because two paths reach it — the pre-0.3.0 clear-on-enqueue above and the peer's SnapshotAck
|
||||
/// — and a peer that acknowledges anyway (a newer build than its handshake claimed) must not
|
||||
/// clear a second time on top of flags a later baseline may already have re-raised.
|
||||
/// </remarks>
|
||||
private async Task ClearSentSnapshotDebtAsync(CancellationToken ct)
|
||||
{
|
||||
var scope = Interlocked.Exchange(ref _sentSnapshotScope, null);
|
||||
if (scope is null)
|
||||
return;
|
||||
await _store.ClearSnapshotDebtAsync(scope.Tables, ct);
|
||||
}
|
||||
|
||||
// The ONLY caller of duplex.Send. Control (handshake + acks) always preempts data so the peer's
|
||||
@@ -246,20 +340,54 @@ internal sealed class SyncSession
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads oplog deltas above the sent watermark and ships them, splitting each read into as many
|
||||
/// messages as <see cref="ReplicationOptions.MaxBatchBytes"/> requires.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Row count alone cannot bound a message: rows are arbitrarily wide, so any row-count cap is
|
||||
/// really a guess about the widest row, and guessing wrong produces a message the transport
|
||||
/// rejects — replication then stops converging with a size error, not a data error. Budgeting by
|
||||
/// summed serialized bytes bounds the thing that is actually limited, and lets the row cap go
|
||||
/// back to meaning what it says.
|
||||
/// </remarks>
|
||||
private async Task PumpLoopAsync(ChannelWriter<SyncMessage> dataLane, CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
// BEFORE the read, because the read itself cannot tell a pruned gap from an idle oplog.
|
||||
ThrowIfPruneGap(await _store.GetMinSeqAsync(ct));
|
||||
var batch = await _store.ReadBatchAboveAsync(_sentThruSeq, _options.MaxBatchSize, ct);
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
var delta = new DeltaBatch();
|
||||
foreach (var entry in batch)
|
||||
delta.Entries.Add(ToProto(entry));
|
||||
// Blocking here on a full data lane IS the pump's backpressure.
|
||||
await dataLane.WriteAsync(new SyncMessage { DeltaBatch = delta }, ct);
|
||||
_sentThruSeq = batch[^1].Seq;
|
||||
// The exact guard on the rows about to go out, for a prune that landed between the
|
||||
// probe and the read. Free (the seq is already in hand) and strictly tighter.
|
||||
ThrowIfPruneGap(batch[0].Seq);
|
||||
var index = 0;
|
||||
while (index < batch.Count)
|
||||
{
|
||||
var delta = new DeltaBatch();
|
||||
var bytes = 0L;
|
||||
while (index < batch.Count)
|
||||
{
|
||||
var proto = ToProto(batch[index]);
|
||||
var size = EntrySize(proto);
|
||||
// The first entry of a message is taken however large it is: refusing an
|
||||
// oversized row would stall the stream on it permanently, whereas sending it
|
||||
// alone either succeeds or fails loudly at the transport with its real size.
|
||||
if (delta.Entries.Count > 0 && bytes + size > _options.MaxBatchBytes)
|
||||
break;
|
||||
delta.Entries.Add(proto);
|
||||
bytes += size;
|
||||
index++;
|
||||
}
|
||||
// Blocking here on a full data lane IS the pump's backpressure.
|
||||
await dataLane.WriteAsync(new SyncMessage { DeltaBatch = delta }, ct);
|
||||
// Advanced per MESSAGE, not per read: a fault mid-split must not re-send what
|
||||
// already went out, and must not skip what did not.
|
||||
_sentThruSeq = batch[index - 1].Seq;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -267,23 +395,77 @@ internal sealed class SyncSession
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Faults the session when the oplog horizon has climbed above this session's read position —
|
||||
/// i.e. a cap prune deleted deltas between <see cref="_sentThruSeq"/> and
|
||||
/// <paramref name="oldestAvailableSeq"/>. An empty oplog (0) is not a gap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a live session has to die for this.</b> <c>EnforceCapsAsync</c> sets
|
||||
/// <c>needs_snapshot</c> in the same transaction as the prune, so a RECONNECT heals — the
|
||||
/// handshake serves a full snapshot instead of deltas. Nothing, however, made the live session
|
||||
/// notice: the pump reads <c>seq > _sentThruSeq</c>, which silently resumes above the hole, and
|
||||
/// the peer acks those later seqs as if the pair were converged. On a stable LAN with a slow but
|
||||
/// alive peer that session can run for days, and the pair is durably divergent for all of it —
|
||||
/// the flag that would fix it is only ever read at handshake time. Tearing down is what turns a
|
||||
/// persisted "needs snapshot" into an actual snapshot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the horizon, not the flag.</b> <c>needs_snapshot</c> is also set for reasons that do
|
||||
/// not invalidate THIS session's position (and a full snapshot this session just streamed clears
|
||||
/// it only when the peer's <c>SnapshotAck</c> lands, which is after the pump has started).
|
||||
/// Comparing the horizon against the sent watermark asks the precise question instead, so a
|
||||
/// session is faulted when — and only when — rows it still owes the peer are gone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void ThrowIfPruneGap(long oldestAvailableSeq)
|
||||
{
|
||||
if (oldestAvailableSeq == 0 || oldestAvailableSeq <= _sentThruSeq + 1)
|
||||
return;
|
||||
throw new LocalDbOplogGapException(
|
||||
$"Oplog deltas above the sent watermark {_sentThruSeq} were pruned (oldest surviving seq " +
|
||||
$"{oldestAvailableSeq}): this session can no longer stream incrementally without skipping " +
|
||||
"them. Ending the session so the reconnect handshake serves a snapshot resync.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialized bytes one entry contributes to its batch message: the entry's own encoding plus
|
||||
/// the repeated field's tag and length prefix, as protobuf itself computes them.
|
||||
/// </summary>
|
||||
private static long EntrySize(OplogEntry entry) =>
|
||||
1 + Google.Protobuf.CodedOutputStream.ComputeMessageSize(entry);
|
||||
|
||||
private async Task ReceiveLoopAsync(
|
||||
SyncDuplex duplex, ChannelWriter<SyncMessage> controlLane, CancellationToken ct)
|
||||
{
|
||||
while (await duplex.Inbox.WaitToReadAsync(ct))
|
||||
// One-message lookahead. Coalescing means reading ahead until a message turns out NOT to be
|
||||
// of the kind being coalesced, and a Channel cannot un-read — so that message is parked here
|
||||
// and processed first on the next pass. Its presence also keeps the outer loop from waiting
|
||||
// on the inbox when there is already work in hand.
|
||||
SyncMessage? lookahead = null;
|
||||
|
||||
while (lookahead is not null || await duplex.Inbox.WaitToReadAsync(ct))
|
||||
{
|
||||
while (duplex.Inbox.TryRead(out var msg))
|
||||
while (true)
|
||||
{
|
||||
var msg = lookahead;
|
||||
lookahead = null;
|
||||
if (msg is null)
|
||||
{
|
||||
if (!duplex.Inbox.TryRead(out var read))
|
||||
break;
|
||||
msg = read;
|
||||
}
|
||||
|
||||
switch (msg.MsgCase)
|
||||
{
|
||||
case SyncMessage.MsgOneofCase.DeltaBatch:
|
||||
// Deltas apply normally even mid-snapshot: LWW makes the interleave safe.
|
||||
await HandleDeltaBatchAsync(controlLane, msg.DeltaBatch, ct);
|
||||
lookahead = await HandleCoalescedDeltasAsync(duplex, controlLane, msg.DeltaBatch, ct);
|
||||
break;
|
||||
case SyncMessage.MsgOneofCase.DeltaAck:
|
||||
var acked = msg.DeltaAck.AppliedThruSeq;
|
||||
InterlockedMax(ref _peerAckedSeq, acked);
|
||||
await _store.RecordPeerAckAsync(acked, ct);
|
||||
lookahead = await HandleCoalescedAcksAsync(duplex, msg.DeltaAck, ct);
|
||||
break;
|
||||
case SyncMessage.MsgOneofCase.SnapshotBegin:
|
||||
if (SnapshotApplier is null)
|
||||
@@ -305,6 +487,20 @@ internal sealed class SyncSession
|
||||
_snapshotReceiving = false;
|
||||
_metrics?.RecordSnapshotReceived();
|
||||
_status?.SetLastSyncUtc(_utcNow());
|
||||
// Acknowledge only to a peer that asked for it. Sent AFTER the applier's
|
||||
// durable watermark write, so the ack means "I hold these rows", which is
|
||||
// what lets the sender drop its per-table flags. On the unbounded control
|
||||
// lane, like DeltaAck: an ack must never queue behind a data backlog.
|
||||
if (_peerSupportsPartialSnapshot)
|
||||
await controlLane.WriteAsync(
|
||||
new SyncMessage { SnapshotAck = new SnapshotAck { AsOfSeq = msg.SnapshotComplete.AsOfSeq } }, ct);
|
||||
break;
|
||||
case SyncMessage.MsgOneofCase.SnapshotAck:
|
||||
// The peer has durably applied the snapshot THIS node streamed, so the
|
||||
// tables it covered no longer owe one. Nothing to reconcile against the
|
||||
// ack's as-of seq: only one snapshot is ever sent per session, so the scope
|
||||
// held in _sentSnapshotScope is unambiguously the one being acknowledged.
|
||||
await ClearSentSnapshotDebtAsync(ct);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
@@ -314,19 +510,98 @@ internal sealed class SyncSession
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleDeltaBatchAsync(ChannelWriter<SyncMessage> controlLane, DeltaBatch batch, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Applies the given delta batch together with any delta batches ALREADY waiting in the inbox,
|
||||
/// as one transaction answered by one ack; returns the non-delta message that ended the run (to
|
||||
/// be processed next), or null if the inbox merely ran dry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Byte-budgeting the sender produces more, smaller messages, and one apply transaction plus one
|
||||
/// ack round-trip per message is the cost that would otherwise scale with that. Coalescing pays
|
||||
/// it once per burst instead — and a burst is exactly what a reconnect after an outage delivers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only messages already queued are absorbed (<c>TryRead</c>, never a wait), so this adds no
|
||||
/// latency: a trickle of one batch at a time coalesces nothing and behaves exactly as before.
|
||||
/// The run stops at the sender's own caps, so the extra memory held is bounded by roughly one
|
||||
/// sender batch beyond the budget — the overshoot of finishing whichever batch crosses it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<SyncMessage?> HandleCoalescedDeltasAsync(
|
||||
SyncDuplex duplex, ChannelWriter<SyncMessage> controlLane, DeltaBatch first, CancellationToken ct)
|
||||
{
|
||||
if (batch.Entries.Count == 0)
|
||||
var entries = new List<OplogEntry>(first.Entries);
|
||||
var bytes = 0L;
|
||||
foreach (var entry in first.Entries)
|
||||
bytes += EntrySize(entry);
|
||||
|
||||
SyncMessage? lookahead = null;
|
||||
while (entries.Count < _options.MaxBatchSize && bytes < _options.MaxBatchBytes
|
||||
&& duplex.Inbox.TryRead(out var next))
|
||||
{
|
||||
if (next.MsgCase != SyncMessage.MsgOneofCase.DeltaBatch)
|
||||
{
|
||||
lookahead = next;
|
||||
break;
|
||||
}
|
||||
foreach (var entry in next.DeltaBatch.Entries)
|
||||
{
|
||||
entries.Add(entry);
|
||||
bytes += EntrySize(entry);
|
||||
}
|
||||
}
|
||||
|
||||
await ApplyDeltaEntriesAsync(controlLane, entries, ct);
|
||||
return lookahead;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the given ack together with any acks ALREADY waiting in the inbox, as a single
|
||||
/// watermark write; returns the non-ack message that ended the run, or null if the inbox ran dry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exactly equivalent to recording each in turn, and strictly cheaper: <c>applied_thru_seq</c> is
|
||||
/// monotonic and <c>RecordPeerAckAsync</c> is MAX-guarded, so every superseded ack would have
|
||||
/// written a watermark that the final one overwrites and run a DELETE below its own horizon.
|
||||
/// Unbounded coalescing is safe here — acks collapse into one number, accumulating nothing.
|
||||
/// </remarks>
|
||||
private async Task<SyncMessage?> HandleCoalescedAcksAsync(
|
||||
SyncDuplex duplex, DeltaAck first, CancellationToken ct)
|
||||
{
|
||||
var acked = first.AppliedThruSeq;
|
||||
|
||||
SyncMessage? lookahead = null;
|
||||
while (duplex.Inbox.TryRead(out var next))
|
||||
{
|
||||
if (next.MsgCase != SyncMessage.MsgOneofCase.DeltaAck)
|
||||
{
|
||||
lookahead = next;
|
||||
break;
|
||||
}
|
||||
if (next.DeltaAck.AppliedThruSeq > acked)
|
||||
acked = next.DeltaAck.AppliedThruSeq;
|
||||
}
|
||||
|
||||
InterlockedMax(ref _peerAckedSeq, acked);
|
||||
await _store.RecordPeerAckAsync(acked, ct);
|
||||
return lookahead;
|
||||
}
|
||||
|
||||
private async Task ApplyDeltaEntriesAsync(
|
||||
ChannelWriter<SyncMessage> controlLane, IReadOnlyList<OplogEntry> batchEntries, CancellationToken ct)
|
||||
{
|
||||
if (batchEntries.Count == 0)
|
||||
return;
|
||||
|
||||
var now = _utcNow();
|
||||
var nowMs = now.ToUnixTimeMilliseconds();
|
||||
var driftLimitMs = nowMs + (long)_options.MaxHlcDriftAhead.TotalMilliseconds;
|
||||
var toApply = new List<OplogEntryRecord>(batch.Entries.Count);
|
||||
var toApply = new List<OplogEntryRecord>(batchEntries.Count);
|
||||
var maxSeq = 0L;
|
||||
var driftDeadLettered = 0;
|
||||
|
||||
foreach (var proto in batch.Entries)
|
||||
foreach (var proto in batchEntries)
|
||||
{
|
||||
if (proto.Seq > maxSeq) maxSeq = proto.Seq;
|
||||
var entry = FromProto(proto);
|
||||
@@ -407,6 +682,9 @@ internal sealed class SyncSession
|
||||
NodeId = _db.NodeId,
|
||||
LibSchemaVersion = LibSchemaVersion,
|
||||
LastAppliedRemoteSeq = peerState.LastAppliedRemoteSeq,
|
||||
// Unconditional: this build always understands a scoped snapshot and always answers a
|
||||
// completed one. It is a statement about the code, not about configuration or state.
|
||||
SupportsPartialSnapshot = true,
|
||||
};
|
||||
foreach (var table in _db.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
handshake.Tables.Add(new TableDigest { TableName = table.Name, Digest = table.Digest });
|
||||
@@ -437,11 +715,63 @@ internal sealed class SyncSession
|
||||
"Replication handshake rejected (fail-closed): " + string.Join("; ", parts) + ".");
|
||||
}
|
||||
|
||||
private async Task<bool> ComputeSnapshotRequiredAsync(Handshake peerHandshake, PeerState peerState, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Decides what snapshot this node owes the peer: null for none, <see cref="SnapshotScope.Full"/>
|
||||
/// for every registered table, or a scoped snapshot naming just the tables that need one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two sources of snapshot debt are genuinely different in reach, which is why they are
|
||||
/// stored separately. An <b>oplog-horizon</b> gap — the peer's watermark below the oldest delta
|
||||
/// still streamable, which is what a cap prune or an impossible peer ack leaves behind — means
|
||||
/// deltas have been lost for the oplog as a whole, so nothing short of every table is sound. A
|
||||
/// <b>baseline</b> means one table's pre-existing rows were never captured, and says nothing at
|
||||
/// all about the others.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A scope that happens to name every registered table is returned as Full rather than as a
|
||||
/// list: it covers exactly what a full snapshot covers, so it should also get a full snapshot's
|
||||
/// watermark advance on both sides, and it needs no promotion for a pre-0.3.0 peer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Flags naming tables that are not registered are ignored (they can only stream nothing).
|
||||
/// <c>DeregisterReplicated</c> drops them with the table's ledger rows, so the surviving way to
|
||||
/// hold one is to drop the SQL table without deregistering — which the handshake's fail-closed
|
||||
/// digest comparison rejects for its own reasons.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<SnapshotScope?> ComputeSnapshotScopeAsync(Handshake peerHandshake, PeerState peerState, CancellationToken ct)
|
||||
{
|
||||
if (peerState.NeedsSnapshot)
|
||||
return true;
|
||||
if (peerState.NeedsSnapshot || await HasHorizonGapAsync(peerHandshake, peerState, ct))
|
||||
return SnapshotScope.Full;
|
||||
|
||||
var flagged = await _store.GetTablesNeedingSnapshotAsync(ct);
|
||||
if (flagged.Count == 0)
|
||||
return null;
|
||||
|
||||
var scoped = flagged.Where(_db.ReplicatedTables.ContainsKey).ToList();
|
||||
if (scoped.Count == 0)
|
||||
return null;
|
||||
if (scoped.Count >= _db.ReplicatedTables.Count)
|
||||
return SnapshotScope.Full;
|
||||
|
||||
// A peer that would read SnapshotBegin.tables as "all tables" and then advance its watermark
|
||||
// past deltas this snapshot did not carry gets the full snapshot instead. Wider than asked
|
||||
// for, but it is the only shape the older protocol can express correctly — and it is what
|
||||
// 0.2.x did for every baseline.
|
||||
if (!_peerSupportsPartialSnapshot)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Peer {PeerNodeId} does not support scoped snapshots; promoting the snapshot of {Tables} to a full one.",
|
||||
PeerNodeId, string.Join(",", scoped));
|
||||
return SnapshotScope.Full;
|
||||
}
|
||||
|
||||
return new SnapshotScope(scoped);
|
||||
}
|
||||
|
||||
private async Task<bool> HasHorizonGapAsync(Handshake peerHandshake, PeerState peerState, CancellationToken ct)
|
||||
{
|
||||
var minRows = await _db.QueryAsync(
|
||||
"SELECT COALESCE(MIN(seq), 0) FROM __localdb_oplog", static r => r.GetInt64(0), null, ct);
|
||||
var minSeq = minRows[0];
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.LocalDb.Replication.Internal;
|
||||
/// <summary>
|
||||
/// Shared construction of a <see cref="SyncSession"/> and its dependencies (OplogStore + LwwApplier)
|
||||
/// from a <see cref="SqliteLocalDb"/> and the bound options. Both the service and client adapters use
|
||||
/// this so they build an identical session; snapshot hooks are left null (Task 12 fills them in).
|
||||
/// this so they build an identical session.
|
||||
/// </summary>
|
||||
internal static class SyncSessionFactory
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.LocalDb.Replication;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by a live sync session when the oplog horizon has moved ABOVE the position that session
|
||||
/// is streaming from: a cap prune deleted deltas it had not yet sent, so every later delta it could
|
||||
/// ship would land on the far side of a hole the peer will never receive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is not a transport failure and not a data error — it is the session discovering that its own
|
||||
/// incremental position has become unsound. Nothing can repair it in-session (the deleted rows are
|
||||
/// gone), so the session tears itself down; the same prune set <c>needs_snapshot</c>, which makes the
|
||||
/// reconnect handshake serve a snapshot instead of deltas. Catching this and continuing would
|
||||
/// reintroduce exactly the silent divergence it exists to stop.
|
||||
/// </remarks>
|
||||
public sealed class LocalDbOplogGapException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception with a message describing the gap.</summary>
|
||||
public LocalDbOplogGapException(string message) : base(message) { }
|
||||
|
||||
/// <summary>Creates the exception with a message and the underlying cause.</summary>
|
||||
public LocalDbOplogGapException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
@@ -21,8 +21,26 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
|
||||
private readonly LocalDbMetrics? _metrics;
|
||||
private readonly SyncStatus? _status;
|
||||
|
||||
// 0 = idle, 1 = a stream is active. Interlocked-guarded across concurrent calls (singleton service).
|
||||
private int _active;
|
||||
// The stream currently holding the guard, or null when idle. Guarded by _gate (singleton service).
|
||||
private readonly object _gate = new();
|
||||
private Registration? _active;
|
||||
|
||||
/// <summary>
|
||||
/// How long a displacing call waits for the incumbent it evicted to actually finish before
|
||||
/// giving up and refusing itself. Test seam; the default is generous because eviction only has
|
||||
/// to outrun the operator, not the network.
|
||||
/// </summary>
|
||||
internal TimeSpan TakeoverTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>One in-flight <c>Sync</c> handler: its cancellation handle plus a completion signal
|
||||
/// a displacing handler can await, so two sessions never run against the database at once.</summary>
|
||||
private sealed class Registration(CancellationTokenSource cts)
|
||||
{
|
||||
public CancellationTokenSource Cts { get; } = cts;
|
||||
|
||||
public TaskCompletionSource Finished { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the passive sync service. <paramref name="status"/> is taken as the public
|
||||
@@ -41,21 +59,47 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
|
||||
_status = status as SyncStatus;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one inbound sync stream, evicting any stream already holding the guard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Newest wins.</b> The guard exists to SERIALIZE sessions — a node syncs with exactly one
|
||||
/// peer, and two sessions against one database would fight over the same watermarks — not to
|
||||
/// fence out an impostor; authentication is the interceptor's job, and an authenticated caller
|
||||
/// on this key IS the peer by definition. Refusing the newcomer therefore protected nothing and
|
||||
/// cost everything in the case that actually happens: the dialling node crashes or is
|
||||
/// partitioned, this handler stays parked forever inside <c>MoveNext</c> on a half-open stream
|
||||
/// that will never produce another byte, and every reconnect from the rebooted peer is rejected
|
||||
/// <c>AlreadyExists</c> — potentially for as long as the pair stays idle, while this side keeps
|
||||
/// reporting itself connected. The newest dialler is the one with a live link, so it takes over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Eviction is cancel-then-await, not cancel-and-go: the incumbent's cancellation unwinds its
|
||||
/// session and reader pump, and the newcomer waits for that to complete before starting its own,
|
||||
/// so the "one session at a time" property the guard was built for still holds. An incumbent
|
||||
/// that cannot be evicted within <see cref="TakeoverTimeout"/> is the one case where the old
|
||||
/// answer is still right — the newcomer refuses itself rather than double-running.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public override async Task Sync(
|
||||
IAsyncStreamReader<SyncMessage> requestStream,
|
||||
IServerStreamWriter<SyncMessage> responseStream,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _active, 1, 0) != 0)
|
||||
throw new RpcException(new Status(StatusCode.AlreadyExists, "a sync stream is already active"));
|
||||
// The linked source exists so the reader pump is deterministically torn down on EVERY exit
|
||||
// path — including a session fault for a local (non-transport) reason, where the call token
|
||||
// never fires and the pump would otherwise keep reading a stream whose handler has exited.
|
||||
// It is also the handle a later call cancels to displace this one.
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
|
||||
var mine = new Registration(linkedCts);
|
||||
|
||||
try
|
||||
{
|
||||
// The linked source exists so the reader pump is deterministically torn down on EVERY
|
||||
// exit path — including a session fault for a local (non-transport) reason, where the
|
||||
// call token never fires and the pump would otherwise keep reading a stream whose
|
||||
// handler has exited.
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
|
||||
// Inside the try so the finally below always releases the guard — including when the
|
||||
// takeover itself is abandoned because THIS caller gave up mid-handover.
|
||||
await TakeOverAsync(mine, context.CancellationToken);
|
||||
|
||||
var (duplex, readerTask) = GrpcSyncDuplex.Create(requestStream, responseStream, linkedCts.Token);
|
||||
var session = SyncSessionFactory.Create(_db, _options, _loggerFactory, _metrics, _status);
|
||||
_status?.SessionStarted();
|
||||
@@ -81,13 +125,66 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
|
||||
{
|
||||
// Client disconnect / server shutdown: the normal end of a long-lived streaming RPC.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Displaced by a newer stream: an orderly handover, not a failure of this node.
|
||||
_logger.LogInformation("Sync stream ended: displaced by a newer stream from the peer.");
|
||||
}
|
||||
catch (LocalDbSchemaMismatchException ex)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _active, 0);
|
||||
lock (_gate)
|
||||
{
|
||||
if (ReferenceEquals(_active, mine))
|
||||
_active = null;
|
||||
}
|
||||
mine.Finished.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claims the single-stream guard for <paramref name="mine"/>, cancelling and awaiting whatever
|
||||
/// held it. Throws <c>AlreadyExists</c> only when the incumbent refuses to end.
|
||||
/// </summary>
|
||||
private async Task TakeOverAsync(Registration mine, CancellationToken ct)
|
||||
{
|
||||
Registration? displaced;
|
||||
lock (_gate)
|
||||
{
|
||||
displaced = _active;
|
||||
_active = mine;
|
||||
}
|
||||
if (displaced is null)
|
||||
return;
|
||||
|
||||
_logger.LogWarning(
|
||||
"A new sync stream arrived while one was already active; displacing the incumbent (newest wins).");
|
||||
try
|
||||
{
|
||||
// The incumbent may already be disposing its own source between its finally and here.
|
||||
displaced.Cts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
|
||||
try
|
||||
{
|
||||
await displaced.Finished.Task.WaitAsync(TakeoverTimeout, ct);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Could not evict it: two live sessions over one database is worse than a refused dial,
|
||||
// so fall back to the historical answer — and hand the guard back to the incumbent.
|
||||
lock (_gate)
|
||||
{
|
||||
if (ReferenceEquals(_active, mine))
|
||||
_active = displaced;
|
||||
}
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.AlreadyExists,
|
||||
"a sync stream is already active and did not yield within the takeover timeout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class MaintenanceBackgroundService : BackgroundService
|
||||
{
|
||||
_snapshotFlagWarned = true;
|
||||
_logger.LogWarning(
|
||||
"Oplog backlog/age caps exceeded: pruned to the ceiling and flagged needs_snapshot — the peer must snapshot-resync. Not re-logged until a clean tick.");
|
||||
"Oplog backlog/age caps exceeded: pruned to the ceiling and flagged needs_snapshot — the peer must snapshot-resync. A session streaming from below the new horizon ends itself on its next pump tick so the reconnect serves that snapshot. Not re-logged until a clean tick.");
|
||||
}
|
||||
else if (!snapshotFlagged)
|
||||
{
|
||||
|
||||
@@ -14,9 +14,44 @@ public sealed class ReplicationOptions
|
||||
/// <summary>How often the change pump flushes pending oplog deltas to the peer.</summary>
|
||||
public TimeSpan FlushInterval { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>Maximum oplog entries sent per outbound batch.</summary>
|
||||
/// <summary>
|
||||
/// Secondary, row-count cap on an outbound batch (and the snapshot streamer's DB page size).
|
||||
/// <see cref="MaxBatchBytes"/> is the primary cap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Historically this was the ONLY cap, which made it a proxy for a byte budget that consumers
|
||||
/// had to tune by hand against their widest row — ScadaBridge pinned it to 16 because a 70 KB
|
||||
/// <c>config_json</c> times the 500 default is ~35 MB against a 4 MB gRPC limit. With
|
||||
/// <see cref="MaxBatchBytes"/> doing that job directly, this is back to being what its name
|
||||
/// says: a bound on how many rows one message may carry.
|
||||
/// </remarks>
|
||||
public int MaxBatchSize { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Primary cap on an outbound batch: the summed serialized size of the entries in one
|
||||
/// <c>DeltaBatch</c> / <c>SnapshotBatch</c> message, in bytes. Default 2 MB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Sized at half the default 4 MB gRPC receive limit, so the message framing, the oneof wrapper
|
||||
/// and any per-entry overhead the estimate does not model all fit inside the margin.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A single row larger than the budget is still sent, alone.</b> Skipping it would stall the
|
||||
/// stream permanently on one oversized row; sending it gives the transport the chance to accept
|
||||
/// it (gRPC limits are configurable) and, if it refuses, a loud transport error naming the size
|
||||
/// rather than silent non-convergence.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Wire compatibility:</b> none required. Batch size is a sender-side choice the protocol
|
||||
/// already leaves free — <c>DeltaBatch</c>/<c>SnapshotBatch</c> carry a repeated field with no
|
||||
/// declared count — so a node on this version interoperates with a peer on any earlier one in
|
||||
/// both directions, with no proto change and no <c>lib_schema_version</c> bump. (ScadaBridge
|
||||
/// site pairs stop and start together regardless; this simply does not depend on it.)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int MaxBatchBytes { get; set; } = 2 * 1024 * 1024;
|
||||
|
||||
/// <summary>Backlog ceiling: exceeding it flags a snapshot resync and prunes the oldest oplog rows.</summary>
|
||||
public long MaxOplogRows { get; set; } = 1_000_000;
|
||||
|
||||
@@ -29,6 +64,34 @@ public sealed class ReplicationOptions
|
||||
/// <summary>Upper bound on the exponential reconnect backoff.</summary>
|
||||
public TimeSpan ReconnectBackoffMax { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// How long the initiator's HTTP/2 connection may sit without a keepalive PING before one is
|
||||
/// sent. Default 60s.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A sync stream is long-lived and, on a converged pair, almost entirely silent — which is
|
||||
/// exactly the shape a half-open TCP connection hides behind. Without pings, a hard crash or a
|
||||
/// partition of one node leaves the other holding a stream that will never produce another byte
|
||||
/// and never fail either: the initiator sits in a session it believes is healthy, and the
|
||||
/// passive node's handler sits inside its single-stream guard, refusing the reconnect that would
|
||||
/// heal the pair. Pings turn that into a detected failure on both ends.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Sent with <c>KeepAlivePingPolicy.Always</c>, so they flow on an idle connection too — an
|
||||
/// active-streams-only policy would not fire in precisely the silent-pair case above. Safe over
|
||||
/// h2c: a PING is an HTTP/2 frame, nothing to do with TLS.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public TimeSpan KeepAlivePingDelay { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// How long the initiator waits for a keepalive PING ack before tearing the connection down.
|
||||
/// Default 20s. Detection therefore lands within roughly
|
||||
/// <see cref="KeepAlivePingDelay"/> + this.
|
||||
/// </summary>
|
||||
public TimeSpan KeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(20);
|
||||
|
||||
/// <summary>How often the maintenance service enforces the oplog caps and prunes expired tombstones.</summary>
|
||||
public TimeSpan MaintenanceInterval { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed class ReplicationOptionsValidator : IValidateOptions<ReplicationOp
|
||||
failures.Add($"LocalDb:Replication:FlushInterval must be greater than zero; got {options.FlushInterval}.");
|
||||
if (options.MaxBatchSize <= 0)
|
||||
failures.Add($"LocalDb:Replication:MaxBatchSize must be greater than 0; got {options.MaxBatchSize}.");
|
||||
if (options.MaxBatchBytes <= 0)
|
||||
failures.Add($"LocalDb:Replication:MaxBatchBytes must be greater than 0; got {options.MaxBatchBytes}.");
|
||||
if (options.MaxOplogRows <= 0)
|
||||
failures.Add($"LocalDb:Replication:MaxOplogRows must be greater than 0; got {options.MaxOplogRows}.");
|
||||
if (options.MaxOplogAge <= TimeSpan.Zero)
|
||||
@@ -38,6 +40,10 @@ public sealed class ReplicationOptionsValidator : IValidateOptions<ReplicationOp
|
||||
failures.Add($"LocalDb:Replication:ReconnectBackoffMax must be greater than zero; got {options.ReconnectBackoffMax}.");
|
||||
else if (options.ReconnectBackoffMax > ReconnectBackoffCeiling)
|
||||
failures.Add($"LocalDb:Replication:ReconnectBackoffMax must be at most {ReconnectBackoffCeiling}; got {options.ReconnectBackoffMax}.");
|
||||
if (options.KeepAlivePingDelay <= TimeSpan.Zero)
|
||||
failures.Add($"LocalDb:Replication:KeepAlivePingDelay must be greater than zero; got {options.KeepAlivePingDelay}.");
|
||||
if (options.KeepAlivePingTimeout <= TimeSpan.Zero)
|
||||
failures.Add($"LocalDb:Replication:KeepAlivePingTimeout must be greater than zero; got {options.KeepAlivePingTimeout}.");
|
||||
if (options.MaintenanceInterval <= TimeSpan.Zero)
|
||||
failures.Add($"LocalDb:Replication:MaintenanceInterval must be greater than zero; got {options.MaintenanceInterval}.");
|
||||
if (options.MaxHlcDriftAhead <= TimeSpan.Zero)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Http;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -76,7 +77,7 @@ public sealed class SyncBackgroundService : BackgroundService
|
||||
{
|
||||
var channel = ChannelFactory is { } factory
|
||||
? factory()
|
||||
: ownedChannel = GrpcChannel.ForAddress(_options.PeerAddress);
|
||||
: ownedChannel = DialPeer();
|
||||
|
||||
var client = new LocalDbSync.LocalDbSyncClient(channel);
|
||||
var headers = new Metadata();
|
||||
@@ -152,6 +153,39 @@ public sealed class SyncBackgroundService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dials the peer over a channel that sends HTTP/2 keepalive PINGs, so a half-open link is
|
||||
/// detected instead of being waited on forever.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The bare <c>GrpcChannel.ForAddress(address)</c> this replaces had NO keepalive of any kind,
|
||||
/// and neither end of a sync stream ever times out on silence by design (a converged pair is
|
||||
/// legitimately quiet). A hard crash or partition of the peer therefore left this node inside a
|
||||
/// session that could never make progress and could never fail — and its own half of that stream
|
||||
/// pinned on the peer, where the single-stream guard turns it into a refusal of the very
|
||||
/// reconnect that heals the pair.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>DisposeHttpClient</c> hands the handler's lifetime to the channel, which this loop already
|
||||
/// disposes on every exit path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private GrpcChannel DialPeer() =>
|
||||
GrpcChannel.ForAddress(_options.PeerAddress, new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = new SocketsHttpHandler
|
||||
{
|
||||
KeepAlivePingDelay = _options.KeepAlivePingDelay,
|
||||
KeepAlivePingTimeout = _options.KeepAlivePingTimeout,
|
||||
// Always, not WithActiveRequests: the case being closed is a SILENT stream on a dead
|
||||
// link, and an active-requests policy stops pinging once the peer stops responding.
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
EnableMultipleHttp2Connections = true,
|
||||
},
|
||||
DisposeHttpClient = true,
|
||||
});
|
||||
|
||||
private static bool IsSchemaMismatch(Exception? ex) =>
|
||||
ex is LocalDbSchemaMismatchException
|
||||
|| (ex is RpcException rpc && rpc.StatusCode == StatusCode.FailedPrecondition);
|
||||
|
||||
@@ -38,10 +38,133 @@ public interface ILocalDb
|
||||
/// schema digest, and installs the three AFTER triggers that capture every change into the
|
||||
/// oplog + row-version tables within the same transaction as the consumer's write. Idempotent
|
||||
/// (re-registering regenerates the triggers). Throws <see cref="LocalDbRegistrationException"/>
|
||||
/// if the name is unsafe, the table is missing, or it has no primary key.
|
||||
/// if the name is unsafe, the table is missing, it has no primary key, it has a BLOB column, or
|
||||
/// another table references it with a row-mutating <c>ON DELETE</c> action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Replicated tables must not be cascade-delete foreign-key parents.</b> Applying a remote row
|
||||
/// uses <c>INSERT OR REPLACE</c>, whose implicit DELETE fires <c>ON DELETE CASCADE</c> /
|
||||
/// <c>SET NULL</c> / <c>SET DEFAULT</c> on referencing tables while change capture is suppressed
|
||||
/// — the child rows it touches are never captured, so the peer keeps them and the pair diverges
|
||||
/// silently. Such a table is refused here, because registration is the only point at which the
|
||||
/// problem is still visible. <c>NO ACTION</c> / <c>RESTRICT</c> are allowed: they mutate no child
|
||||
/// row, so the implicit delete simply fails and the applier dead-letters the entry.
|
||||
/// </remarks>
|
||||
ReplicatedTable RegisterReplicated(string tableName);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="RegisterReplicated(string)"/>, additionally baselining rows that already exist
|
||||
/// in the table when <paramref name="baselineExistingRows"/> is true.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The table to opt into replication.</param>
|
||||
/// <param name="baselineExistingRows">
|
||||
/// When true, every row already in the table that has no <c>__localdb_row_version</c> entry is
|
||||
/// given one at the <b>baseline floor</b> — HLC <c>0</c>, stamped with this node's id — and, if
|
||||
/// anything was seeded, a snapshot resync is flagged <b>for this table</b> so the peer actually
|
||||
/// receives them.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The problem this solves.</b> Capture is change-data-capture: without baselining, a row
|
||||
/// written before registration exists in neither the oplog nor the row-version ledger, and the
|
||||
/// snapshot streamer pages from the LEDGER — so a node that starts replicating LATE never sends
|
||||
/// its pre-existing rows, silently and forever. Opting in seeds the ledger so a snapshot can
|
||||
/// carry them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the floor is HLC 0, and why that is LWW-safe.</b> A seeded version must never beat a
|
||||
/// genuine write. Every genuine HLC is a UTC millisecond shifted left 16 bits, so every genuine
|
||||
/// HLC is greater than 0: a baselined row therefore LOSES every comparison against a real remote
|
||||
/// write of the same key, and wins only where the peer has no version of that key at all
|
||||
/// (which is precisely the case baselining exists to fix). It cannot perturb the local clock
|
||||
/// either — clock recovery takes a MAX over existing HLCs, and 0 raises nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The one genuinely ambiguous case</b> is both nodes baselining the SAME key (e.g. both
|
||||
/// restored the same legacy file). Both sides hold HLC 0, so the LWW tie-break falls to the
|
||||
/// node-id ordinal: deterministic and convergent, but arbitrary as to content. Nothing can do
|
||||
/// better — two rows of unknown, unordered antiquity carry no information about which is newer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Idempotent.</b> Seeding uses <c>ON CONFLICT DO NOTHING</c>, so a row that already has a
|
||||
/// ledger entry — captured normally, or seeded on an earlier boot — keeps its genuine version
|
||||
/// and no snapshot is flagged. Passing true on every boot is therefore free after the first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cost, and what bounds it (0.3.0).</b> The snapshot flag is <b>per-table</b>
|
||||
/// (<c>__localdb_snapshot_state</c>, on-disk schema v3), so baselining one table asks the peer
|
||||
/// for that table alone — the tables around it are not re-streamed. Through 0.2.x the flag was a
|
||||
/// single per-database bit and baselining anything re-sent everything, in both directions at
|
||||
/// once on a pair enabling replication late.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two cases still stream every registered table, by design. A <b>pre-0.3.0 peer</b> cannot be
|
||||
/// told a snapshot is partial (it would advance its watermark past deltas it never received),
|
||||
/// so a scoped snapshot is promoted to a full one for it. And the <b>oplog-horizon</b> paths —
|
||||
/// a cap prune, an impossible peer ack — invalidate delta continuity for every table at once
|
||||
/// and still set the database-wide flag. So the operational advice survives, narrowed: baseline
|
||||
/// a high-volume table (an event log at its retention cap, say) deliberately, and <b>purge or
|
||||
/// trim it first</b>, because that ONE table's rows still travel in full. The work is bounded
|
||||
/// either way — outbound messages respect <c>MaxBatchBytes</c>, the receive side is bounded by
|
||||
/// the transport's inbox.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Crash safety.</b> Between 0.3.0 peers a table's flag is cleared only when the receiver
|
||||
/// acknowledges the completed snapshot, so an ungraceful exit part-way through re-streams the
|
||||
/// same scope on reconnect rather than losing it. Against a pre-0.3.0 peer (which sends no such
|
||||
/// acknowledgement) the 0.2.x behaviour is kept: the flag clears once the snapshot is on the
|
||||
/// wire, and the handshake's horizon gap check is the net.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
ReplicatedTable RegisterReplicated(string tableName, bool baselineExistingRows);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a table from replication: drops its three capture triggers and prunes its rows from
|
||||
/// <c>__localdb_oplog</c> and <c>__localdb_row_version</c>, in one transaction. Idempotent, and
|
||||
/// deliberately usable on a table this process never registered — cleaning up triggers left
|
||||
/// behind by an EARLIER build is the primary use.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The table to stop capturing. The table's own data is never touched.</param>
|
||||
/// <returns>True if anything was removed (a trigger dropped or a row pruned); false if there was nothing to clean up.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Intended caller:</b> an application that has decided this node no longer replicates (or no
|
||||
/// longer replicates this table). Capture is not free — the three triggers run inside every
|
||||
/// consumer write transaction and append to an oplog nothing will ever drain — so a node with no
|
||||
/// peer should not be paying for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Pruning the row-version ledger is safe with respect to re-registration</b>, because
|
||||
/// <see cref="RegisterReplicated(string, bool)"/> can re-baseline. Without baselining, a
|
||||
/// deregister-then-register cycle would leave the previously-known rows unstreamable — the same
|
||||
/// late-opt-in hole — so the two APIs are designed as a pair.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deregistration must be symmetric across a replicating pair, and the library enforces it
|
||||
/// loudly.</b> The sync handshake compares the two nodes' registered-table digests fail-closed,
|
||||
/// so a node that drops a table its peer still replicates simply stops syncing — with a
|
||||
/// schema-mismatch error, not silent divergence. Deregister on both nodes, or on neither.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The HLC's durable crash anchor is flushed as part of the same transaction</b>, before the
|
||||
/// prune. The clock recovers on open as the MAX of <c>hlc_high_water</c>, the oplog's HLCs, the
|
||||
/// row-version ledger's HLCs and the last-seen peer HLC — and <c>hlc_high_water</c> is otherwise
|
||||
/// written only on graceful disposal. Since this API deletes the deregistered table's oplog and
|
||||
/// ledger rows, a file with no other registered table would, after an UNGRACEFUL exit (crash,
|
||||
/// SIGKILL, power loss), have nothing left to recover from: the clock would restart from
|
||||
/// wall-clock time and could re-issue stamps below HLCs peers already hold, which LWW then
|
||||
/// discards as older. Flushing here removes that window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Backlog-depth residual.</b> Pruning one table's rows can leave holes in the oplog's seq
|
||||
/// range, and the O(1) depth estimate assumes the retained range is contiguous, so it can
|
||||
/// over-report until the peer acks past the holes. Over-reporting is conservative — it can only
|
||||
/// trigger an earlier snapshot resync, never suppress one — and on the node this API is for
|
||||
/// (no peer at all) depth is moot.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
bool DeregisterReplicated(string tableName);
|
||||
|
||||
/// <summary>The tables currently opted into replication, keyed by table name.</summary>
|
||||
IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables { get; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.LocalDb.Internal;
|
||||
@@ -7,6 +8,40 @@ namespace ZB.MOM.WW.LocalDb.Internal;
|
||||
/// </summary>
|
||||
internal static class LocalDbSchema
|
||||
{
|
||||
/// <summary>
|
||||
/// The on-disk bookkeeping-schema version this build creates and upgrades to. Bumping it means
|
||||
/// adding a case to <see cref="Upgrade"/>; existing files are upgraded in place on open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately NOT the same number as <c>SyncSession.LibSchemaVersion</c>, which is the WIRE
|
||||
/// protocol version and is compared fail-closed against the peer's. This one is local-file-only:
|
||||
/// two nodes may briefly sit on different file-schema versions during an upgrade and still sync,
|
||||
/// because nothing about the file layout crosses the wire.
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <b>1</b> — the original layout, which also created <c>__localdb_oplog_hlc</c>.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <b>2</b> — drops <c>__localdb_oplog_hlc</c>. That index (<c>table_name, pk_json, hlc</c>) was
|
||||
/// never read by any query: every oplog access path is by <c>seq</c> (the rowid) or a full
|
||||
/// <c>hlc</c>-predicate delete. It cost a b-tree insert on the CAPTURE path — i.e. inside every
|
||||
/// consumer write transaction — to serve nothing.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <b>3</b> — adds <c>__localdb_snapshot_state</c>, the PER-TABLE snapshot-resync flag. Until
|
||||
/// v3 the only flag was <c>__localdb_peer_state.needs_snapshot</c>, one bit for the whole
|
||||
/// database, so baselining a single table re-streamed EVERY registered table in full — in both
|
||||
/// directions at once on a pair enabling replication late. The database-wide bit survives with
|
||||
/// its original meaning (the oplog-horizon and cap-prune paths genuinely invalidate every
|
||||
/// table); the new table narrows the baseline case to the tables that actually need it. The
|
||||
/// transition itself is a version stamp only — <see cref="Ddl"/> creates the table with
|
||||
/// <c>IF NOT EXISTS</c> on every open, so an older file gains it whatever its stamp says, and
|
||||
/// an empty flag table means exactly what it meant before: nothing per-table is owed.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal const long CurrentSchemaVersion = 3;
|
||||
|
||||
private const string Ddl = """
|
||||
CREATE TABLE IF NOT EXISTS __localdb_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
@@ -25,7 +60,6 @@ internal static class LocalDbSchema
|
||||
node_id TEXT NOT NULL,
|
||||
is_tombstone INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS __localdb_oplog_hlc ON __localdb_oplog(table_name, pk_json, hlc);
|
||||
CREATE TABLE IF NOT EXISTS __localdb_peer_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
peer_node_id TEXT NULL,
|
||||
@@ -50,6 +84,14 @@ internal static class LocalDbSchema
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
applying INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
-- Per-table snapshot-resync flags (schema v3). A row exists only while that table owes the
|
||||
-- peer a snapshot; the whole-database __localdb_peer_state.needs_snapshot bit still means
|
||||
-- "every registered table" and is what the cap/horizon paths set.
|
||||
-- WITHOUT ROWID: table_name IS the only access path (read as a set, cleared by name).
|
||||
CREATE TABLE IF NOT EXISTS __localdb_snapshot_state (
|
||||
table_name TEXT NOT NULL PRIMARY KEY,
|
||||
needs_snapshot INTEGER NOT NULL DEFAULT 0
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS __localdb_dead_letter (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
received_utc TEXT NOT NULL,
|
||||
@@ -80,14 +122,67 @@ internal static class LocalDbSchema
|
||||
meta.Transaction = transaction;
|
||||
meta.CommandText =
|
||||
"INSERT INTO __localdb_meta (id, node_id, schema_version) " +
|
||||
"SELECT 1, $nodeId, 1 WHERE NOT EXISTS (SELECT 1 FROM __localdb_meta)";
|
||||
"SELECT 1, $nodeId, $version WHERE NOT EXISTS (SELECT 1 FROM __localdb_meta)";
|
||||
meta.Parameters.AddWithValue("$nodeId", Guid.NewGuid().ToString("D"));
|
||||
meta.Parameters.AddWithValue("$version", CurrentSchemaVersion);
|
||||
meta.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
Upgrade(connection, transaction);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings a file created by an OLDER build up to <see cref="CurrentSchemaVersion"/>, in the
|
||||
/// caller's transaction. A file already at the current version is untouched.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each step is written to be safe to re-run (IF EXISTS / IF NOT EXISTS), so a crash between the
|
||||
/// DDL and the version stamp costs nothing but a repeat on the next open. Steps run in ascending
|
||||
/// order and the stamp is written once at the end, inside the same transaction as the DDL — a
|
||||
/// file is therefore never observed at the new version with an unapplied step.
|
||||
/// </remarks>
|
||||
private static void Upgrade(SqliteConnection connection, SqliteTransaction transaction)
|
||||
{
|
||||
long current;
|
||||
using (var read = connection.CreateCommand())
|
||||
{
|
||||
read.Transaction = transaction;
|
||||
read.CommandText = "SELECT schema_version FROM __localdb_meta WHERE id = 1";
|
||||
current = Convert.ToInt64(read.ExecuteScalar(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (current >= CurrentSchemaVersion)
|
||||
return;
|
||||
|
||||
var steps = new List<string>();
|
||||
|
||||
// 1 -> 2: drop the write-amplifying, never-read oplog index (see CurrentSchemaVersion).
|
||||
if (current < 2)
|
||||
steps.Add("DROP INDEX IF EXISTS __localdb_oplog_hlc;");
|
||||
|
||||
// 2 -> 3: __localdb_snapshot_state. No step: the shared Ddl above already ran
|
||||
// CREATE TABLE IF NOT EXISTS for it in this same transaction, and an empty flag table is
|
||||
// the correct v3 state for a file that has never baselined a table. Repeating the DDL here
|
||||
// would be a second source of truth for the same layout. The stamp still moves, because
|
||||
// schema_version records what the file's layout IS, not which statements produced it.
|
||||
|
||||
if (steps.Count > 0)
|
||||
{
|
||||
using var ddl = connection.CreateCommand();
|
||||
ddl.Transaction = transaction;
|
||||
ddl.CommandText = string.Concat(steps);
|
||||
ddl.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var stamp = connection.CreateCommand();
|
||||
stamp.Transaction = transaction;
|
||||
stamp.CommandText = "UPDATE __localdb_meta SET schema_version = $version WHERE id = 1";
|
||||
stamp.Parameters.AddWithValue("$version", CurrentSchemaVersion);
|
||||
stamp.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
internal static (string NodeId, long HlcHighWater) ReadMeta(SqliteConnection connection)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
@@ -99,10 +194,22 @@ internal static class LocalDbSchema
|
||||
return (reader.GetString(0), reader.GetInt64(1));
|
||||
}
|
||||
|
||||
internal static void FlushHlcHighWater(SqliteConnection connection, long value)
|
||||
/// <summary>
|
||||
/// Writes the clock's durable crash anchor. <paramref name="transaction"/> must be supplied when
|
||||
/// the connection has one open (SQLite commands are rejected otherwise), which is what lets a
|
||||
/// caller flush the anchor atomically with a change that would otherwise erase the clock's other
|
||||
/// recovery evidence — see <see cref="ILocalDb.DeregisterReplicated"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MAX-guarded rather than an assignment: every writer of this column is flushing a monotonic
|
||||
/// clock, so a lower value can only be a stale one, and the anchor exists precisely to never
|
||||
/// regress.
|
||||
/// </remarks>
|
||||
internal static void FlushHlcHighWater(SqliteConnection connection, long value, SqliteTransaction? transaction = null)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "UPDATE __localdb_meta SET hlc_high_water = $value WHERE id = 1";
|
||||
cmd.Transaction = transaction;
|
||||
cmd.CommandText = "UPDATE __localdb_meta SET hlc_high_water = MAX(hlc_high_water, $value) WHERE id = 1";
|
||||
cmd.Parameters.AddWithValue("$value", value);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
@@ -138,7 +138,15 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
||||
return await ReadAllAsync(cmd, map, ct);
|
||||
}
|
||||
|
||||
public async Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default)
|
||||
public async Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default) =>
|
||||
await BeginPreparedTransactionAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="BeginTransactionAsync"/>, but typed so internal callers reach the underlying
|
||||
/// connection and transaction — which is what lets a hot loop hold <see cref="SqliteCommand"/>s
|
||||
/// across many rows and rebind them, instead of re-preparing statements per statement per row.
|
||||
/// </summary>
|
||||
internal async Task<Transaction> BeginPreparedTransactionAsync(CancellationToken ct = default)
|
||||
{
|
||||
var conn = await CreateConnectionAsync(ct);
|
||||
try
|
||||
@@ -155,8 +163,11 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
||||
|
||||
public IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables => _replicatedTables;
|
||||
|
||||
public ReplicatedTable RegisterReplicated(string tableName) =>
|
||||
RegisterReplicated(tableName, baselineExistingRows: false);
|
||||
|
||||
// Thread-safe: all master-connection work runs under _masterLock.
|
||||
public ReplicatedTable RegisterReplicated(string tableName)
|
||||
public ReplicatedTable RegisterReplicated(string tableName, bool baselineExistingRows)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tableName) || !TableNameRegex.IsMatch(tableName))
|
||||
throw new LocalDbRegistrationException(
|
||||
@@ -188,6 +199,8 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
||||
throw new LocalDbRegistrationException(
|
||||
$"Cannot register table '{tableName}': replication requires an explicit primary key.");
|
||||
|
||||
EnsureNoRowMutatingDeleteAction(tableName);
|
||||
|
||||
var allColumns = columns.Select(c => (c.Name, c.Type)).ToList();
|
||||
var digest = ReplicatedTable.ComputeDigest(tableName, pkColumns, allColumns);
|
||||
var table = new ReplicatedTable(tableName, pkColumns, allColumns, digest);
|
||||
@@ -201,6 +214,10 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
||||
cmd.CommandText = script;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (baselineExistingRows)
|
||||
BaselineExistingRows(tx, table);
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
@@ -209,6 +226,214 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe: all master-connection work runs under _masterLock.
|
||||
public bool DeregisterReplicated(string tableName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tableName) || !TableNameRegex.IsMatch(tableName))
|
||||
throw new LocalDbRegistrationException(
|
||||
$"Cannot deregister table '{tableName}': name must be a plain identifier matching {TableNameRegex}.");
|
||||
|
||||
lock (_masterLock)
|
||||
{
|
||||
using var tx = _master.BeginTransaction();
|
||||
|
||||
// Counted BEFORE the drop: DROP TRIGGER reports no row count, and "did anything change?"
|
||||
// is the caller's cue to log a one-time cleanup rather than a no-op every boot.
|
||||
long triggersPresent;
|
||||
using (var probe = _master.CreateCommand())
|
||||
{
|
||||
probe.Transaction = tx;
|
||||
probe.CommandText =
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN ($ai, $au, $ad)";
|
||||
foreach (var suffix in TriggerSqlGenerator.Suffixes)
|
||||
probe.Parameters.AddWithValue("$" + suffix, TriggerSqlGenerator.TriggerName(tableName, suffix));
|
||||
triggersPresent = Convert.ToInt64(probe.ExecuteScalar(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
using (var drop = _master.CreateCommand())
|
||||
{
|
||||
drop.Transaction = tx;
|
||||
drop.CommandText = TriggerSqlGenerator.BuildUninstallScript(tableName);
|
||||
drop.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Flushed BEFORE the prune below, in the SAME transaction, because that prune destroys
|
||||
// the clock's crash evidence. Recovery on open takes a MAX over hlc_high_water, the
|
||||
// oplog's HLCs, the row-version ledger's HLCs and last_seen_hlc — and hlc_high_water is
|
||||
// otherwise written only by Dispose. Deregistering a table deletes that table's oplog
|
||||
// AND ledger rows, so on a file whose registered tables are all deregistered, an
|
||||
// UNGRACEFUL exit (crash, SIGKILL, power loss — no Dispose) afterwards leaves nothing
|
||||
// above zero to recover from: the reopened clock would restart at wall-clock time and
|
||||
// could re-issue HLCs below stamps peers have already observed, which LWW reads as
|
||||
// "older" and discards. One row-level UPDATE on an administrative one-shot buys the
|
||||
// anchor back; it is a separate command from the prune so it cannot inflate prunedRows.
|
||||
LocalDbSchema.FlushHlcHighWater(_master, _clock.Current, tx);
|
||||
|
||||
// Full scan of the oplog by table_name — the index that would have served it is exactly
|
||||
// the one schema v2 drops, and paying a write on every capture to speed up a one-shot
|
||||
// administrative prune is the wrong trade.
|
||||
int prunedRows;
|
||||
using (var prune = _master.CreateCommand())
|
||||
{
|
||||
prune.Transaction = tx;
|
||||
prune.CommandText =
|
||||
"DELETE FROM __localdb_oplog WHERE table_name = $t;" +
|
||||
"DELETE FROM __localdb_row_version WHERE table_name = $t;" +
|
||||
// A pending per-table snapshot flag for a table nothing streams any more would
|
||||
// never clear — the streamer skips unregistered tables, so the flag would sit
|
||||
// in the file forever, and re-registering later would find a stale "already
|
||||
// owed" it did not ask for. Dropped with the ledger it refers to.
|
||||
"DELETE FROM __localdb_snapshot_state WHERE table_name = $t;";
|
||||
prune.Parameters.AddWithValue("$t", tableName);
|
||||
prunedRows = prune.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
|
||||
_replicatedTables.TryRemove(tableName, out _);
|
||||
return triggersPresent > 0 || prunedRows > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses to register <paramref name="tableName"/> when another table references it with an
|
||||
/// <c>ON DELETE</c> action that MUTATES the referencing rows (<c>CASCADE</c>, <c>SET NULL</c>,
|
||||
/// <c>SET DEFAULT</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this is a replication correctness rule and not a schema opinion.</b> Applying a peer's
|
||||
/// row runs <c>INSERT OR REPLACE</c>, and REPLACE resolves a primary-key conflict by DELETING the
|
||||
/// existing row first. Connections run with <c>PRAGMA foreign_keys=ON</c>, so that implicit delete
|
||||
/// fires the referential action on every child table — while the apply path holds the capture
|
||||
/// guard raised, precisely so its own writes are not re-captured. The child rows are therefore
|
||||
/// deleted (or nulled) locally and produce NO oplog entry: the peer never hears about them, keeps
|
||||
/// them, and the pair is durably divergent with nothing anywhere reporting a fault.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refusing at registration is the only place the problem is visible. At apply time the damage is
|
||||
/// already done and indistinguishable from a normal write, and no runtime signal exists to detect
|
||||
/// it. <b>NO ACTION / RESTRICT are deliberately allowed</b>: they mutate no child row — the
|
||||
/// implicit delete simply fails, which the applier surfaces as a dead-lettered entry. Loud, not
|
||||
/// silent, so there is nothing to guard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The check is registration-time, so a cascading child table CREATED LATER is not caught (SQLite
|
||||
/// has no DDL hook to catch it on). Registration normally runs at startup after schema setup,
|
||||
/// which is where the constraint belongs.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void EnsureNoRowMutatingDeleteAction(string tableName)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
using (var cmd = _master.CreateCommand())
|
||||
{
|
||||
// Every user table is a candidate CHILD; the internal sqlite_* tables carry no
|
||||
// foreign keys. The registered table itself is included: a self-referencing cascade
|
||||
// is the same hazard.
|
||||
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
candidates.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
foreach (var child in candidates)
|
||||
{
|
||||
using var cmd = _master.CreateCommand();
|
||||
cmd.CommandText = $"PRAGMA foreign_key_list({TriggerSqlGenerator.Ident(child)})";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
// foreign_key_list cols: 0=id, 1=seq, 2=table (the PARENT), 3=from, 4=to,
|
||||
// 5=on_update, 6=on_delete, 7=match.
|
||||
var parent = reader.IsDBNull(2) ? "" : reader.GetString(2);
|
||||
// SQLite identifiers are case-insensitive, and the REFERENCES clause need not
|
||||
// match the CREATE TABLE spelling.
|
||||
if (!string.Equals(parent, tableName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var onDelete = reader.IsDBNull(6) ? "" : reader.GetString(6);
|
||||
if (!RowMutatingDeleteActions.Contains(onDelete))
|
||||
continue;
|
||||
|
||||
throw new LocalDbRegistrationException(
|
||||
$"Cannot register table '{tableName}': table '{child}' has a foreign key referencing it " +
|
||||
$"with ON DELETE {onDelete}. Replicated tables must not be cascade-delete foreign-key " +
|
||||
"parents: applying a remote row uses INSERT OR REPLACE, whose implicit DELETE fires the " +
|
||||
"referential action while change capture is suppressed — the child rows it touches are " +
|
||||
"never captured, so the peer never learns of them and the two nodes diverge silently. " +
|
||||
$"Drop the referential action on '{child}' (ON DELETE NO ACTION, removing children " +
|
||||
"explicitly instead), or do not replicate this table.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>ON DELETE</c> actions that mutate referencing rows, as SQLite spells them in
|
||||
/// <c>PRAGMA foreign_key_list</c>. NO ACTION / RESTRICT are absent on purpose — see
|
||||
/// <see cref="EnsureNoRowMutatingDeleteAction"/>.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> RowMutatingDeleteActions =
|
||||
new(["CASCADE", "SET NULL", "SET DEFAULT"], StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds <c>__localdb_row_version</c> at the baseline floor for every row of
|
||||
/// <paramref name="table"/> that has no ledger entry, and flags a snapshot resync if it seeded
|
||||
/// anything. Runs in the registration transaction so triggers and baseline commit together.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The floor is HLC <c>0</c> — see <see cref="ILocalDb.RegisterReplicated(string, bool)"/> for
|
||||
/// why that is the only LWW-safe choice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The <c>needs_snapshot</c> flag is what makes this actually deliver.</b> Seeding the ledger
|
||||
/// alone changes nothing on the wire: a node whose oplog starts at seq 1 presents no gap at the
|
||||
/// handshake, so the peer is told it needs no snapshot and only ever receives post-registration
|
||||
/// deltas. Flagging the resync is what turns the seeded ledger into rows the peer receives.
|
||||
/// Deliberately NOT done by writing oplog entries instead: that would dump the entire table into
|
||||
/// the oplog on the first boot after opting in, likely blowing <c>MaxOplogRows</c> and forcing
|
||||
/// the same snapshot anyway, via a cap-prune.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The flag is PER-TABLE</b> (<c>__localdb_snapshot_state</c>, schema v3): baselining one
|
||||
/// table asks for a snapshot of that table alone, not of every registered table. The
|
||||
/// database-wide <c>__localdb_peer_state.needs_snapshot</c> bit is untouched here and keeps its
|
||||
/// original meaning — the oplog-horizon and cap-prune paths invalidate every table's delta
|
||||
/// continuity at once, and only those paths set it. A peer too old to understand a scoped
|
||||
/// snapshot is served a full one instead; the negotiation lives in the replication package.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void BaselineExistingRows(SqliteTransaction tx, ReplicatedTable table)
|
||||
{
|
||||
int seeded;
|
||||
using (var cmd = _master.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
// The trailing WHERE 1 disambiguates the upsert parse on a SELECT source — the same
|
||||
// reason TriggerSqlGenerator's row-version statements carry one.
|
||||
cmd.CommandText =
|
||||
"INSERT INTO __localdb_row_version (table_name, pk_json, hlc, node_id, is_tombstone, tombstone_utc) " +
|
||||
$"SELECT $t, {TriggerSqlGenerator.JsonObject("t", table.PkColumns)}, 0, $node, 0, NULL " +
|
||||
$"FROM {TriggerSqlGenerator.Ident(table.Name)} t WHERE 1 " +
|
||||
"ON CONFLICT(table_name, pk_json) DO NOTHING";
|
||||
cmd.Parameters.AddWithValue("$t", table.Name);
|
||||
cmd.Parameters.AddWithValue("$node", _nodeId);
|
||||
seeded = cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (seeded == 0)
|
||||
return;
|
||||
|
||||
using var flag = _master.CreateCommand();
|
||||
flag.Transaction = tx;
|
||||
flag.CommandText =
|
||||
"INSERT INTO __localdb_snapshot_state (table_name, needs_snapshot) VALUES ($t, 1) " +
|
||||
"ON CONFLICT(table_name) DO UPDATE SET needs_snapshot = 1";
|
||||
flag.Parameters.AddWithValue("$t", table.Name);
|
||||
flag.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -329,8 +554,14 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
||||
return Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class Transaction(SqliteConnection connection, SqliteTransaction transaction) : ILocalDbTransaction
|
||||
internal sealed class Transaction(SqliteConnection connection, SqliteTransaction transaction) : ILocalDbTransaction
|
||||
{
|
||||
/// <summary>The pinned connection, for internal callers holding their own commands.</summary>
|
||||
internal SqliteConnection Connection => connection;
|
||||
|
||||
/// <summary>The underlying transaction, to assign to internally-held commands.</summary>
|
||||
internal SqliteTransaction Sqlite => transaction;
|
||||
|
||||
public async Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default)
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
|
||||
@@ -27,14 +27,16 @@ internal static class TriggerSqlGenerator
|
||||
"ON CONFLICT(table_name, pk_json) DO UPDATE SET hlc = excluded.hlc, node_id = excluded.node_id, " +
|
||||
"is_tombstone = excluded.is_tombstone, tombstone_utc = excluded.tombstone_utc";
|
||||
|
||||
/// <summary>The three trigger suffixes, in the order the install/uninstall scripts emit them.</summary>
|
||||
internal static readonly string[] Suffixes = ["ai", "au", "ad"];
|
||||
|
||||
public static string TriggerName(string table, string suffix) => $"__localdb_{table}_{suffix}";
|
||||
|
||||
/// <summary>DROP IF EXISTS + CREATE for all three triggers, ready to run in one transaction.</summary>
|
||||
public static string BuildInstallScript(ReplicatedTable table)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var suffix in new[] { "ai", "au", "ad" })
|
||||
sb.Append("DROP TRIGGER IF EXISTS ").Append(Ident(TriggerName(table.Name, suffix))).Append(";\n");
|
||||
sb.Append(BuildUninstallScript(table.Name));
|
||||
|
||||
sb.Append(BuildInsert(table)).Append('\n');
|
||||
sb.Append(BuildUpdate(table)).Append('\n');
|
||||
@@ -42,6 +44,20 @@ internal static class TriggerSqlGenerator
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DROP IF EXISTS for all three triggers of <paramref name="tableName"/>. Takes the bare name
|
||||
/// rather than a <see cref="ReplicatedTable"/> because uninstall must work for a table this
|
||||
/// process never registered — the whole point of the cleanup API is stale triggers left by an
|
||||
/// EARLIER build, whose column list (and therefore digest) is unavailable and irrelevant.
|
||||
/// </summary>
|
||||
public static string BuildUninstallScript(string tableName)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var suffix in Suffixes)
|
||||
sb.Append("DROP TRIGGER IF EXISTS ").Append(Ident(TriggerName(tableName, suffix))).Append(";\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildInsert(ReplicatedTable t) =>
|
||||
Wrap(t, "ai", "INSERT", NewRowCapture(t));
|
||||
|
||||
@@ -112,13 +128,16 @@ internal static class TriggerSqlGenerator
|
||||
}
|
||||
|
||||
// json_object('col', ALIAS."col", ...) — natively handles NULLs and SQLite scalar types.
|
||||
private static string JsonObject(string alias, IEnumerable<string> columns)
|
||||
// Internal rather than private: baseline seeding must build a pk_json identical to what the
|
||||
// capture triggers emit, character for character, or a seeded ledger key would never match the
|
||||
// key a later capture writes for the same row.
|
||||
internal static string JsonObject(string alias, IEnumerable<string> columns)
|
||||
{
|
||||
var args = columns.Select(c => $"{Literal(c)}, {alias}.{Ident(c)}");
|
||||
return $"json_object({string.Join(", ", args)})";
|
||||
}
|
||||
|
||||
private static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
|
||||
internal static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
|
||||
|
||||
private static string Literal(string value) => "'" + value.Replace("'", "''") + "'";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.LocalDb.Contracts;
|
||||
using ZB.MOM.WW.LocalDb.Internal;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
using ZB.MOM.WW.LocalDb.Replication.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.LocalDb.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound batching by summed serialized BYTES (with the row count kept as a secondary cap), and
|
||||
/// the inbound coalescing that keeps one transaction + one ack per burst rather than per message.
|
||||
/// </summary>
|
||||
public sealed class BatchBudgetTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan RunTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly List<string> _paths = [];
|
||||
private readonly List<IDisposable> _disposables = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
d.Dispose();
|
||||
SqliteConnection.ClearAllPools();
|
||||
foreach (var p in _paths)
|
||||
{
|
||||
if (File.Exists(p)) File.Delete(p);
|
||||
if (File.Exists(p + "-wal")) File.Delete(p + "-wal");
|
||||
if (File.Exists(p + "-shm")) File.Delete(p + "-shm");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SqliteLocalDb> NewOrdersDbAsync()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
||||
_paths.Add(path);
|
||||
var db = new SqliteLocalDb(new LocalDbOptions { Path = path });
|
||||
_disposables.Add(db);
|
||||
await db.ExecuteAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)");
|
||||
db.RegisterReplicated("orders");
|
||||
return db;
|
||||
}
|
||||
|
||||
private static (SyncDuplex Duplex, ChannelWriter<SyncMessage> ToSession, ChannelReader<SyncMessage> FromSession) ScriptedPeer()
|
||||
{
|
||||
var toSession = Channel.CreateUnbounded<SyncMessage>();
|
||||
var fromSession = Channel.CreateUnbounded<SyncMessage>();
|
||||
var duplex = new SyncDuplex
|
||||
{
|
||||
Send = (m, ct) => fromSession.Writer.WriteAsync(m, ct).AsTask(),
|
||||
Inbox = toSession.Reader,
|
||||
};
|
||||
return (duplex, toSession.Writer, fromSession.Reader);
|
||||
}
|
||||
|
||||
private static Handshake MatchingHandshake(SqliteLocalDb sessionDb, string peerNodeId)
|
||||
{
|
||||
var h = new Handshake { NodeId = peerNodeId, LibSchemaVersion = 1, LastAppliedRemoteSeq = 0 };
|
||||
foreach (var t in sessionDb.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
h.Tables.Add(new TableDigest { TableName = t.Name, Digest = t.Digest });
|
||||
return h;
|
||||
}
|
||||
|
||||
private static async Task<SyncMessage> NextAsync(ChannelReader<SyncMessage> reader, CancellationToken ct)
|
||||
{
|
||||
await reader.WaitToReadAsync(ct);
|
||||
reader.TryRead(out var msg);
|
||||
return msg!;
|
||||
}
|
||||
|
||||
// Reads the next scripted-peer message while also observing the session task: a session that
|
||||
// faults would otherwise leave this wait to run out the whole test timeout, reporting an
|
||||
// opaque cancellation instead of the session's real exception.
|
||||
private static async Task<SyncMessage> NextAsync(Task run, ChannelReader<SyncMessage> reader, CancellationToken ct)
|
||||
{
|
||||
var next = NextAsync(reader, ct);
|
||||
if (ReferenceEquals(await Task.WhenAny(run, next), run))
|
||||
{
|
||||
await run;
|
||||
throw new InvalidOperationException("The sync session ended before the expected message arrived.");
|
||||
}
|
||||
return await next;
|
||||
}
|
||||
|
||||
private static async Task SwallowAsync(Task task)
|
||||
{
|
||||
try { await task; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
// Queues a complete peer handshake AHEAD of the session's own, so anything queued after it is
|
||||
// guaranteed to be sitting in the inbox by the time the receive loop starts. That is what makes
|
||||
// the coalescing assertions deterministic rather than a race with the reader.
|
||||
private static async Task PreloadHandshakeAsync(
|
||||
SqliteLocalDb db, ChannelWriter<SyncMessage> toSession, CancellationToken ct)
|
||||
{
|
||||
await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(db, "peer") }, ct);
|
||||
await toSession.WriteAsync(
|
||||
new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, ct);
|
||||
}
|
||||
|
||||
private static long BatchBytes(DeltaBatch batch) =>
|
||||
batch.Entries.Sum(e => 1L + Google.Protobuf.CodedOutputStream.ComputeMessageSize(e));
|
||||
|
||||
private static long BatchBytes(SnapshotBatch batch) =>
|
||||
batch.Rows.Sum(r => 1L + Google.Protobuf.CodedOutputStream.ComputeMessageSize(r));
|
||||
|
||||
private static SyncSession NewSession(SqliteLocalDb db, ReplicationOptions options)
|
||||
{
|
||||
var store = new OplogStore(db, options);
|
||||
var applier = new LwwApplier(db);
|
||||
return new SyncSession(db, store, applier, options, NullLogger.Instance);
|
||||
}
|
||||
|
||||
private static OplogEntry ProtoEntry(long seq, long id, long hlc) =>
|
||||
new()
|
||||
{
|
||||
Seq = seq,
|
||||
TableName = "orders",
|
||||
PkJson = $"{{\"id\":{id}}}",
|
||||
RowJson = $"{{\"id\":{id},\"sku\":\"S\",\"qty\":1}}",
|
||||
Hlc = hlc,
|
||||
NodeId = "peer",
|
||||
IsTombstone = false,
|
||||
};
|
||||
|
||||
// ---- Outbound: delta pump --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Pump_SplitsOneReadIntoSeveralMessages_EachWithinTheByteBudget()
|
||||
{
|
||||
const int budget = 4_000;
|
||||
var db = await NewOrdersDbAsync();
|
||||
var wide = new string('x', 900);
|
||||
for (var i = 1; i <= 20; i++)
|
||||
await db.ExecuteAsync(
|
||||
"INSERT INTO orders (id, sku, qty) VALUES (@id, @sku, @id)", new { id = i, sku = wide });
|
||||
|
||||
// Row count alone would put all 20 wide rows (~18 KB) in ONE message; the byte budget is
|
||||
// what actually bounds the message the transport has to carry.
|
||||
var options = new ReplicationOptions
|
||||
{
|
||||
FlushInterval = TimeSpan.FromMilliseconds(20), MaxBatchSize = 500, MaxBatchBytes = budget,
|
||||
};
|
||||
var session = NewSession(db, options);
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = session.RunAsync(duplex, cts.Token);
|
||||
await PreloadHandshakeAsync(db, toSession, cts.Token);
|
||||
|
||||
var seen = new List<long>();
|
||||
while (seen.Count < 20)
|
||||
{
|
||||
var msg = await NextAsync(run, fromSession, cts.Token);
|
||||
if (msg.MsgCase != SyncMessage.MsgOneofCase.DeltaBatch)
|
||||
continue;
|
||||
Assert.True(BatchBytes(msg.DeltaBatch) <= budget,
|
||||
$"a {BatchBytes(msg.DeltaBatch)}-byte message exceeded the {budget}-byte budget");
|
||||
Assert.True(msg.DeltaBatch.Entries.Count > 1, "the budget should still fit several of these rows");
|
||||
seen.AddRange(msg.DeltaBatch.Entries.Select(e => e.Seq));
|
||||
}
|
||||
|
||||
// Split, not dropped or duplicated: every captured seq travelled exactly once, in order.
|
||||
Assert.Equal(20, seen.Count);
|
||||
Assert.Equal(seen.OrderBy(x => x).ToList(), seen);
|
||||
Assert.Equal(20, seen.Distinct().Count());
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pump_RowLargerThanTheBudget_IsSentAloneRatherThanStallingTheStream()
|
||||
{
|
||||
var db = await NewOrdersDbAsync();
|
||||
await db.ExecuteAsync(
|
||||
"INSERT INTO orders (id, sku, qty) VALUES (1, @sku, 1)", new { sku = new string('x', 5_000) });
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'small', 2)");
|
||||
|
||||
var options = new ReplicationOptions
|
||||
{
|
||||
FlushInterval = TimeSpan.FromMilliseconds(20), MaxBatchSize = 500, MaxBatchBytes = 100,
|
||||
};
|
||||
var session = NewSession(db, options);
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = session.RunAsync(duplex, cts.Token);
|
||||
await PreloadHandshakeAsync(db, toSession, cts.Token);
|
||||
|
||||
var batches = new List<DeltaBatch>();
|
||||
while (batches.Sum(b => b.Entries.Count) < 2)
|
||||
{
|
||||
var msg = await NextAsync(run, fromSession, cts.Token);
|
||||
if (msg.MsgCase == SyncMessage.MsgOneofCase.DeltaBatch)
|
||||
batches.Add(msg.DeltaBatch);
|
||||
}
|
||||
|
||||
// The oversized row goes out on its own — refusing it would wedge the stream on one row
|
||||
// forever, and everything behind it with it.
|
||||
Assert.Equal(2, batches.Count);
|
||||
Assert.Single(batches[0].Entries);
|
||||
Assert.Equal(1, batches[0].Entries[0].Seq);
|
||||
Assert.True(BatchBytes(batches[0]) > options.MaxBatchBytes);
|
||||
Assert.Single(batches[1].Entries);
|
||||
Assert.Equal(2, batches[1].Entries[0].Seq);
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pump_RowCountRemainsASecondaryCap()
|
||||
{
|
||||
var db = await NewOrdersDbAsync();
|
||||
for (var i = 1; i <= 12; i++)
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'S', @id)", new { id = i });
|
||||
|
||||
// Tiny rows, enormous byte budget: only the row cap can be doing the splitting here.
|
||||
var options = new ReplicationOptions
|
||||
{
|
||||
FlushInterval = TimeSpan.FromMilliseconds(20), MaxBatchSize = 5, MaxBatchBytes = 8 * 1024 * 1024,
|
||||
};
|
||||
var session = NewSession(db, options);
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = session.RunAsync(duplex, cts.Token);
|
||||
await PreloadHandshakeAsync(db, toSession, cts.Token);
|
||||
|
||||
var seen = 0;
|
||||
while (seen < 12)
|
||||
{
|
||||
var msg = await NextAsync(run, fromSession, cts.Token);
|
||||
if (msg.MsgCase != SyncMessage.MsgOneofCase.DeltaBatch)
|
||||
continue;
|
||||
Assert.True(msg.DeltaBatch.Entries.Count <= 5);
|
||||
seen += msg.DeltaBatch.Entries.Count;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
// ---- Outbound: snapshot --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_SplitsPagesIntoMessagesWithinTheByteBudget()
|
||||
{
|
||||
const int budget = 4_000;
|
||||
var db = await NewOrdersDbAsync();
|
||||
var wide = new string('x', 900);
|
||||
for (var i = 1; i <= 20; i++)
|
||||
await db.ExecuteAsync(
|
||||
"INSERT INTO orders (id, sku, qty) VALUES (@id, @sku, @id)", new { id = i, sku = wide });
|
||||
|
||||
// A snapshot is the easiest way to blow a message limit: it ships whole rows, all of them,
|
||||
// back to back — so its page size has to be bounded in bytes too, not just in rows.
|
||||
var options = new ReplicationOptions { MaxBatchSize = 500, MaxBatchBytes = budget };
|
||||
var store = new OplogStore(db, options);
|
||||
var streamer = new SnapshotStreamer(db, store, options, NullLogger.Instance);
|
||||
|
||||
var sent = new List<SyncMessage>();
|
||||
await streamer.SendAsync(SnapshotScope.Full, (m, _) => { sent.Add(m); return Task.CompletedTask; }, default);
|
||||
|
||||
var batches = sent.Where(m => m.MsgCase == SyncMessage.MsgOneofCase.SnapshotBatch)
|
||||
.Select(m => m.SnapshotBatch).ToList();
|
||||
Assert.True(batches.Count > 1, "20 wide rows must not have travelled as one message");
|
||||
Assert.All(batches, b => Assert.True(BatchBytes(b) <= budget,
|
||||
$"a {BatchBytes(b)}-byte snapshot message exceeded the {budget}-byte budget"));
|
||||
|
||||
// Bracketed and complete: every row, exactly once.
|
||||
Assert.Equal(SyncMessage.MsgOneofCase.SnapshotBegin, sent[0].MsgCase);
|
||||
Assert.Equal(SyncMessage.MsgOneofCase.SnapshotComplete, sent[^1].MsgCase);
|
||||
var pks = batches.SelectMany(b => b.Rows).Select(r => r.PkJson).ToList();
|
||||
Assert.Equal(20, pks.Count);
|
||||
Assert.Equal(20, pks.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_RowLargerThanTheBudget_IsSentAlone()
|
||||
{
|
||||
var db = await NewOrdersDbAsync();
|
||||
await db.ExecuteAsync(
|
||||
"INSERT INTO orders (id, sku, qty) VALUES (1, @sku, 1)", new { sku = new string('x', 5_000) });
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'small', 2)");
|
||||
|
||||
var options = new ReplicationOptions { MaxBatchSize = 500, MaxBatchBytes = 100 };
|
||||
var store = new OplogStore(db, options);
|
||||
var streamer = new SnapshotStreamer(db, store, options, NullLogger.Instance);
|
||||
|
||||
var sent = new List<SyncMessage>();
|
||||
await streamer.SendAsync(SnapshotScope.Full, (m, _) => { sent.Add(m); return Task.CompletedTask; }, default);
|
||||
|
||||
var batches = sent.Where(m => m.MsgCase == SyncMessage.MsgOneofCase.SnapshotBatch)
|
||||
.Select(m => m.SnapshotBatch).ToList();
|
||||
Assert.Equal(2, batches.Count);
|
||||
Assert.All(batches, b => Assert.Single(b.Rows));
|
||||
Assert.Equal("{\"id\":1}", batches[0].Rows[0].PkJson);
|
||||
Assert.Equal("{\"id\":2}", batches[1].Rows[0].PkJson);
|
||||
}
|
||||
|
||||
// ---- Inbound: coalescing -------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Receive_CoalescesQueuedDeltaBatches_IntoOneAck()
|
||||
{
|
||||
var db = await NewOrdersDbAsync();
|
||||
var options = new ReplicationOptions { FlushInterval = TimeSpan.FromSeconds(30) };
|
||||
var session = NewSession(db, options);
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = session.RunAsync(duplex, cts.Token);
|
||||
|
||||
// Queued ahead of the receive loop, so all three are demonstrably in the inbox together.
|
||||
await PreloadHandshakeAsync(db, toSession, cts.Token);
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
var batch = new DeltaBatch();
|
||||
batch.Entries.Add(ProtoEntry(i, i, 1_000_000 + i));
|
||||
await toSession.WriteAsync(new SyncMessage { DeltaBatch = batch }, cts.Token);
|
||||
}
|
||||
|
||||
SyncMessage ack;
|
||||
do { ack = await NextAsync(run, fromSession, cts.Token); }
|
||||
while (ack.MsgCase != SyncMessage.MsgOneofCase.DeltaAck);
|
||||
|
||||
// The discriminator: uncoalesced, the FIRST ack could only cover seq 1.
|
||||
Assert.Equal(3, ack.DeltaAck.AppliedThruSeq);
|
||||
|
||||
var rows = await db.QueryAsync("SELECT COUNT(*) FROM orders", r => r.GetInt64(0));
|
||||
Assert.Equal(3, rows[0]);
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Receive_DeltaCoalescing_DoesNotSwallowTheMessageThatEndsTheRun()
|
||||
{
|
||||
// The risk the one-message lookahead exists to handle: coalescing reads ahead, and whatever
|
||||
// it reads that is NOT a delta must still be processed, not dropped on the floor.
|
||||
var db = await NewOrdersDbAsync();
|
||||
var options = new ReplicationOptions { FlushInterval = TimeSpan.FromSeconds(30) };
|
||||
var session = NewSession(db, options);
|
||||
var captured = new CapturingSnapshotApplier();
|
||||
session.SnapshotApplier = captured;
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await PreloadHandshakeAsync(db, toSession, cts.Token);
|
||||
var batch = new DeltaBatch();
|
||||
batch.Entries.Add(ProtoEntry(1, 1, 1_000_001));
|
||||
await toSession.WriteAsync(new SyncMessage { DeltaBatch = batch }, cts.Token);
|
||||
await toSession.WriteAsync(
|
||||
new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 77 } }, cts.Token);
|
||||
|
||||
SyncMessage ack;
|
||||
do { ack = await NextAsync(run, fromSession, cts.Token); }
|
||||
while (ack.MsgCase != SyncMessage.MsgOneofCase.DeltaAck);
|
||||
Assert.Equal(1, ack.DeltaAck.AppliedThruSeq);
|
||||
|
||||
// The SnapshotBegin that terminated the coalescing run still reached its handler.
|
||||
await WaitForAsync(() => captured.BeginAsOfSeq == 77, RunTimeout);
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Receive_CoalescesQueuedAcks_ToTheHighestWatermark_WithoutLosingTheNextMessage()
|
||||
{
|
||||
var db = await NewOrdersDbAsync();
|
||||
for (var i = 1; i <= 9; i++)
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'S', @id)", new { id = i });
|
||||
|
||||
var options = new ReplicationOptions { FlushInterval = TimeSpan.FromSeconds(30) };
|
||||
var session = NewSession(db, options);
|
||||
var captured = new CapturingSnapshotApplier();
|
||||
session.SnapshotApplier = captured;
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await PreloadHandshakeAsync(db, toSession, cts.Token);
|
||||
// Out of order on purpose: collapsing acks must take the MAX, never the last.
|
||||
foreach (var seq in new long[] { 4, 9, 2 })
|
||||
await toSession.WriteAsync(
|
||||
new SyncMessage { DeltaAck = new DeltaAck { AppliedThruSeq = seq } }, cts.Token);
|
||||
await toSession.WriteAsync(
|
||||
new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 55 } }, cts.Token);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var acked = await db.QueryAsync(
|
||||
"SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1", r => r.GetInt64(0));
|
||||
return acked[0] == 9;
|
||||
}, RunTimeout);
|
||||
|
||||
Assert.Equal(9, session.PeerAckedSeq);
|
||||
await WaitForAsync(() => captured.BeginAsOfSeq == 55, RunTimeout);
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
private sealed class CapturingSnapshotApplier : ISnapshotApplier
|
||||
{
|
||||
private long _beginAsOfSeq = -1;
|
||||
|
||||
public long BeginAsOfSeq => Interlocked.Read(ref _beginAsOfSeq);
|
||||
|
||||
public Task OnBeginAsync(SnapshotBegin begin, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Exchange(ref _beginAsOfSeq, begin.AsOfSeq);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task OnBatchAsync(SnapshotBatch batch, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task WaitForAsync(Func<bool> predicate, TimeSpan timeout) =>
|
||||
WaitForAsync(() => Task.FromResult(predicate()), timeout);
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> predicate, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (await predicate()) return;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
throw new TimeoutException("Condition not reached within " + timeout);
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,12 @@ public sealed class GrpcAdapterTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondConcurrentStream_Rejected()
|
||||
public async Task SecondConcurrentStream_DisplacesTheIncumbent()
|
||||
{
|
||||
// Newest wins (0.3.2). The guard serializes sessions; it was never a fence — the auth
|
||||
// interceptor is, and an authenticated same-key dialler IS the peer. Refusing the newcomer
|
||||
// bought nothing and cost the pair its only route back from a half-open link, where the
|
||||
// incumbent handler can hold the guard indefinitely on a dead stream.
|
||||
var serverHost = await BuildServerAsync(NewDbPath(), apiKey: null, sink: null);
|
||||
var serverDb = serverHost.Services.GetRequiredService<ILocalDb>();
|
||||
var handler = serverHost.GetTestServer().CreateHandler();
|
||||
@@ -85,11 +89,15 @@ public sealed class GrpcAdapterTests : IAsyncLifetime
|
||||
|
||||
// Convergence proves the first stream's server handler is active and holding the single-stream guard.
|
||||
await WaitForAsync(async () => (await ReadOrders(serverDb)).Count == 1, RunTimeout);
|
||||
var attemptsBefore = bg.ConnectionAttempts;
|
||||
|
||||
var client = new LocalDbSync.LocalDbSyncClient(ChannelOver(handler));
|
||||
using var call2 = client.Sync(cancellationToken: cts.Token);
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => call2.ResponseStream.MoveNext(cts.Token));
|
||||
Assert.Equal(StatusCode.AlreadyExists, ex.StatusCode);
|
||||
Assert.True(await call2.ResponseStream.MoveNext(cts.Token));
|
||||
Assert.Equal(SyncMessage.MsgOneofCase.Handshake, call2.ResponseStream.Current.MsgCase);
|
||||
|
||||
// The incumbent really ended: the displaced client notices and dials again.
|
||||
await WaitForAsync(() => Task.FromResult(bg.ConnectionAttempts > attemptsBefore), RunTimeout);
|
||||
|
||||
await bg.StopAsync(CancellationToken.None);
|
||||
}
|
||||
@@ -173,6 +181,71 @@ public sealed class GrpcAdapterTests : IAsyncLifetime
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(3), $"teardown took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
// ---- inbox backpressure ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Inbox_SlowReader_StopsDrainingTheStream_AndLosesNothing()
|
||||
{
|
||||
// The pump must STOP pulling from the gRPC stream once the inbox is full — that stall is
|
||||
// what closes the peer's flow-control window and bounds this process's memory at
|
||||
// InboxCapacity x MaxBatchBytes instead of "whatever the peer decides to send" (a full
|
||||
// snapshot of a large dataset, on both nodes at once, when a pair baselines late).
|
||||
const int capacity = Replication.Internal.GrpcSyncDuplex.InboxCapacity;
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var reader = new CountingReader();
|
||||
var (duplex, readerTask) = Replication.Internal.GrpcSyncDuplex.Create(
|
||||
reader, new HangingWriter(), cts.Token);
|
||||
|
||||
// Nothing reads the inbox: the pump fills it and blocks.
|
||||
await WaitForAsync(() => Task.FromResult(reader.Produced >= capacity), TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(200, cts.Token);
|
||||
// capacity in the channel + at most one in the pump's hand, blocked on WriteAsync.
|
||||
Assert.True(reader.Produced <= capacity + 1,
|
||||
$"the reader pump kept draining an already-full inbox: {reader.Produced} messages read for a {capacity}-message bound");
|
||||
|
||||
// Draining releases it, and every message arrives exactly once in stream order — TryWrite
|
||||
// on a full bounded channel would have dropped them silently instead.
|
||||
for (long expected = 1; expected <= capacity + 1; expected++)
|
||||
{
|
||||
var msg = await ReadInboxAsync(duplex, cts.Token);
|
||||
Assert.Equal(expected, msg.DeltaAck.AppliedThruSeq);
|
||||
}
|
||||
await WaitForAsync(() => Task.FromResult(reader.Produced > capacity + 1), TimeSpan.FromSeconds(5));
|
||||
|
||||
cts.Cancel();
|
||||
try { await readerTask.WaitAsync(TimeSpan.FromSeconds(5)); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private static async Task<SyncMessage> ReadInboxAsync(
|
||||
Replication.Internal.SyncDuplex duplex, CancellationToken ct)
|
||||
{
|
||||
await duplex.Inbox.WaitToReadAsync(ct);
|
||||
duplex.Inbox.TryRead(out var message);
|
||||
return message!;
|
||||
}
|
||||
|
||||
// Produces messages as fast as the inbox will take them, numbering each one so the consumer can
|
||||
// prove nothing was dropped or reordered.
|
||||
private sealed class CountingReader : IAsyncStreamReader<SyncMessage>
|
||||
{
|
||||
private long _produced;
|
||||
|
||||
public long Produced => Interlocked.Read(ref _produced);
|
||||
|
||||
public SyncMessage Current { get; private set; } = new();
|
||||
|
||||
public Task<bool> MoveNext(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Current = new SyncMessage
|
||||
{
|
||||
DeltaAck = new DeltaAck { AppliedThruSeq = Interlocked.Increment(ref _produced) },
|
||||
};
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- validator -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
@@ -197,9 +270,13 @@ public sealed class GrpcAdapterTests : IAsyncLifetime
|
||||
TombstoneRetention = TimeSpan.Zero,
|
||||
ReconnectBackoffMax = TimeSpan.Zero,
|
||||
MaxHlcDriftAhead = TimeSpan.Zero,
|
||||
KeepAlivePingDelay = TimeSpan.Zero,
|
||||
KeepAlivePingTimeout = TimeSpan.Zero,
|
||||
});
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures, f => f.Contains("KeepAlivePingDelay", StringComparison.Ordinal));
|
||||
Assert.Contains(result.Failures, f => f.Contains("KeepAlivePingTimeout", StringComparison.Ordinal));
|
||||
Assert.Contains(result.Failures, f => f.Contains("FlushInterval", StringComparison.Ordinal));
|
||||
Assert.Contains(result.Failures, f => f.Contains("MaxBatchSize", StringComparison.Ordinal));
|
||||
Assert.Contains(result.Failures, f => f.Contains("MaxOplogRows", StringComparison.Ordinal));
|
||||
|
||||
@@ -51,12 +51,118 @@ public sealed class LocalDbSchemaTests : IDisposable
|
||||
foreach (var expected in new[]
|
||||
{
|
||||
"__localdb_meta", "__localdb_oplog", "__localdb_peer_state",
|
||||
"__localdb_row_version", "__localdb_applying", "__localdb_dead_letter"
|
||||
"__localdb_row_version", "__localdb_applying", "__localdb_dead_letter",
|
||||
"__localdb_snapshot_state",
|
||||
})
|
||||
Assert.Contains(expected, tables);
|
||||
|
||||
// __localdb_oplog_hlc is deliberately NOT created: schema v2 removed it. It indexed
|
||||
// (table_name, pk_json, hlc), which no query has ever used — every oplog access path is by
|
||||
// seq or a full hlc-predicate delete — while costing a b-tree insert inside every captured
|
||||
// consumer write.
|
||||
Assert.Equal(0, ScalarLong(conn,
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name='__localdb_oplog_hlc'"));
|
||||
Assert.Equal(LocalDbSchema.CurrentSchemaVersion,
|
||||
ScalarLong(conn, "SELECT schema_version FROM __localdb_meta WHERE id=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_UpgradesLegacyFile_DropsOplogHlcIndex()
|
||||
{
|
||||
using var conn = Open();
|
||||
|
||||
// A file exactly as schema v1 left it: the tables, the index, and a v1 stamp.
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
Exec(conn, "CREATE INDEX __localdb_oplog_hlc ON __localdb_oplog(table_name, pk_json, hlc);" +
|
||||
"UPDATE __localdb_meta SET schema_version = 1 WHERE id = 1;");
|
||||
Assert.Equal(1, ScalarLong(conn,
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name='__localdb_oplog_hlc'"));
|
||||
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
|
||||
Assert.Equal(0, ScalarLong(conn,
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name='__localdb_oplog_hlc'"));
|
||||
Assert.Equal(LocalDbSchema.CurrentSchemaVersion,
|
||||
ScalarLong(conn, "SELECT schema_version FROM __localdb_meta WHERE id=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_UpgradesV2File_AddsPerTableSnapshotState()
|
||||
{
|
||||
using var conn = Open();
|
||||
|
||||
// A file exactly as schema v2 left it: everything except __localdb_snapshot_state, stamped 2.
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
Exec(conn, "DROP TABLE __localdb_snapshot_state;" +
|
||||
"UPDATE __localdb_meta SET schema_version = 2 WHERE id = 1;");
|
||||
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
|
||||
Assert.Equal(1, ScalarLong(conn,
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__localdb_snapshot_state'"));
|
||||
// Empty is the right v3 state for a file that never baselined anything: no table owes a
|
||||
// snapshot, and the database-wide flag it was carrying keeps whatever value it had.
|
||||
Assert.Equal(0, ScalarLong(conn, "SELECT COUNT(*) FROM __localdb_snapshot_state"));
|
||||
Assert.Equal(LocalDbSchema.CurrentSchemaVersion,
|
||||
ScalarLong(conn, "SELECT schema_version FROM __localdb_meta WHERE id=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_Upgrade_PreservesTheDatabaseWideSnapshotFlag()
|
||||
{
|
||||
// A v2 file mid-resync (cap prune flagged a full snapshot, the peer was down) must still owe
|
||||
// that snapshot after the upgrade. v3 narrows where NEW debt is recorded; it does not
|
||||
// reinterpret debt already on disk.
|
||||
using var conn = Open();
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
Exec(conn, "DROP TABLE __localdb_snapshot_state;" +
|
||||
"UPDATE __localdb_meta SET schema_version = 2 WHERE id = 1;" +
|
||||
"UPDATE __localdb_peer_state SET needs_snapshot = 1 WHERE id = 1;");
|
||||
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
|
||||
Assert.Equal(1, ScalarLong(conn, "SELECT needs_snapshot FROM __localdb_peer_state WHERE id=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_Upgrade_PreservesNodeIdentityAndOplogRows()
|
||||
{
|
||||
using var conn = Open();
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
var nodeId = ScalarString(conn, "SELECT node_id FROM __localdb_meta WHERE id=1");
|
||||
Exec(conn,
|
||||
"CREATE INDEX __localdb_oplog_hlc ON __localdb_oplog(table_name, pk_json, hlc);" +
|
||||
"UPDATE __localdb_meta SET schema_version = 1 WHERE id = 1;" +
|
||||
"INSERT INTO __localdb_oplog (table_name, pk_json, row_json, hlc, node_id, is_tombstone) " +
|
||||
"VALUES ('orders', '{\"id\":1}', '{\"id\":1}', 42, 'n', 0);" +
|
||||
"UPDATE __localdb_peer_state SET last_acked_seq = 7 WHERE id = 1;");
|
||||
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
|
||||
// The upgrade is DDL only — identity, replication watermarks and captured data survive it.
|
||||
Assert.Equal(nodeId, ScalarString(conn, "SELECT node_id FROM __localdb_meta WHERE id=1"));
|
||||
Assert.Equal(1, ScalarLong(conn, "SELECT COUNT(*) FROM __localdb_oplog"));
|
||||
Assert.Equal(42, ScalarLong(conn, "SELECT hlc FROM __localdb_oplog"));
|
||||
Assert.Equal(7, ScalarLong(conn, "SELECT last_acked_seq FROM __localdb_peer_state WHERE id=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCreated_AlreadyCurrent_LeavesVersionAlone()
|
||||
{
|
||||
using var conn = Open();
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
LocalDbSchema.EnsureCreated(conn);
|
||||
|
||||
Assert.Equal(LocalDbSchema.CurrentSchemaVersion,
|
||||
ScalarLong(conn, "SELECT schema_version FROM __localdb_meta WHERE id=1"));
|
||||
}
|
||||
|
||||
private static void Exec(SqliteConnection conn, string sql)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -404,6 +404,133 @@ public sealed class LwwApplierTests : IDisposable
|
||||
Assert.Equal(("NEW", (long?)7), await ReadOrder(db, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_PreparesEachStatementOnce_NotOncePerRow()
|
||||
{
|
||||
// The regression this guards: the old shape went through the generic transaction façade,
|
||||
// which builds a fresh SqliteCommand — and therefore re-parses the SQL — per call. Three
|
||||
// statements per applied row means a 200-row batch parsed 600 statements to run 6 distinct
|
||||
// ones. Statement count must track distinct SQL, not rows.
|
||||
using var db = await NewOrdersDb();
|
||||
await db.ExecuteAsync("CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
db.RegisterReplicated("widgets");
|
||||
|
||||
var entries = new List<OplogEntryRecord>();
|
||||
for (var i = 1; i <= 200; i++)
|
||||
entries.Add(Entry(i, $"{{\"id\":{i}}}", $"{{\"id\":{i},\"sku\":\"S\",\"qty\":{i}}}", 1_000_000 + i, "remote"));
|
||||
// A second table and a tombstone, so every statement kind in the set is exercised.
|
||||
entries.Add(new OplogEntryRecord(
|
||||
201, "widgets", "{\"id\":1}", "{\"id\":1,\"name\":\"W\"}", 1_000_300, "remote", false));
|
||||
entries.Add(Entry(202, "{\"id\":1}", null, 1_000_400, "remote", tombstone: true));
|
||||
|
||||
var applier = new LwwApplier(db);
|
||||
var result = await applier.ApplyBatchAsync(entries, default);
|
||||
|
||||
Assert.Equal(202, result.Applied);
|
||||
// Six table-independent statements (applying guard, version lookup, row-version write,
|
||||
// peer state, plus the per-entry savepoint's SAVEPOINT and RELEASE), an upsert per table
|
||||
// touched, and a delete for the one table tombstoned. The savepoint's ROLLBACK TO and the
|
||||
// dead-letter insert are absent because this batch has no poison entry — statements are
|
||||
// built on first use, so the count tracks the statement KINDS the batch actually ran.
|
||||
Assert.Equal(9, applier.LastBatchStatementCount);
|
||||
|
||||
// ...and the batch is still correct, statement reuse notwithstanding.
|
||||
Assert.Null(await ReadOrder(db, 1));
|
||||
Assert.Equal(("S", (long?)200), await ReadOrder(db, 200));
|
||||
var widget = await db.QueryAsync("SELECT name FROM widgets WHERE id = 1", x => x.GetString(0));
|
||||
Assert.Equal("W", widget[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_ReusedStatements_SurviveAPoisonRowMidBatch()
|
||||
{
|
||||
// A statement that faults is reset and reused for the following rows; if it were not, one
|
||||
// poison row would silently corrupt every row after it in the batch.
|
||||
using var db = await NewOrdersDb();
|
||||
|
||||
var applier = new LwwApplier(db);
|
||||
var result = await applier.ApplyBatchAsync(
|
||||
[
|
||||
Entry(1, "{\"id\":1}", "{\"id\":1,\"sku\":\"BEFORE\",\"qty\":1}", 1_000_000, "remote"),
|
||||
Entry(2, "{\"id\":2}", "NOT VALID JSON", 1_000_001, "remote"),
|
||||
Entry(3, "{\"id\":3}", "{\"id\":3,\"sku\":\"AFTER\",\"qty\":3}", 1_000_002, "remote"),
|
||||
Entry(4, "{\"id\":4}", "ALSO NOT JSON", 1_000_003, "remote"),
|
||||
Entry(5, "{\"id\":5}", "{\"id\":5,\"sku\":\"LAST\",\"qty\":5}", 1_000_004, "remote"),
|
||||
], default);
|
||||
|
||||
Assert.Equal(3, result.Applied);
|
||||
Assert.Equal(2, result.DeadLettered);
|
||||
Assert.Equal(("BEFORE", (long?)1), await ReadOrder(db, 1));
|
||||
Assert.Equal(("AFTER", (long?)3), await ReadOrder(db, 3));
|
||||
Assert.Equal(("LAST", (long?)5), await ReadOrder(db, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_LedgerWriteFails_LeavesNeitherTheDeleteNorTheLedgerEntry()
|
||||
{
|
||||
// The hazard (P4-7): applying a tombstone is TWO statements — delete the row, then write the
|
||||
// ledger's tombstone version — and the poison catch commits whatever the first one already
|
||||
// did. A failure landing between them therefore committed a DELETED ROW carrying a STALE LIVE
|
||||
// ledger entry, which is exactly the shape SnapshotStreamer warns about and skips: the key
|
||||
// then never travels again and the pair is divergent for that row, permanently and silently.
|
||||
//
|
||||
// The failure is injected with a trigger that aborts the ledger write (in production the
|
||||
// realistic causes are disk-full / IO error / corruption — none of them reproducible here),
|
||||
// and RAISE(ABORT) aborts only the statement, so the applier's narrow SqliteException catch
|
||||
// treats it as per-entry poison exactly as it would a real one.
|
||||
using var db = await NewOrdersDb();
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'LOCAL', 5)");
|
||||
var localHlc = await RowVersionHlc(db, Pk1);
|
||||
|
||||
await db.ExecuteAsync(
|
||||
"CREATE TRIGGER poison_ledger BEFORE INSERT ON __localdb_row_version " +
|
||||
"WHEN NEW.is_tombstone = 1 BEGIN SELECT RAISE(ABORT, 'injected ledger failure'); END");
|
||||
|
||||
var result = await new LwwApplier(db).ApplyBatchAsync(
|
||||
[Entry(90, Pk1, null, localHlc + 1000, "remote-node", tombstone: true)], default);
|
||||
|
||||
// The entry is poison, so it is dead-lettered and the batch continues — unchanged semantics.
|
||||
Assert.Equal(1, result.DeadLettered);
|
||||
Assert.Equal(0, result.Applied);
|
||||
|
||||
// BOTH or NEITHER. Neither, here: the row must still be present, because its ledger entry
|
||||
// still says "live at the local version".
|
||||
Assert.Equal(("LOCAL", (long?)5), await ReadOrder(db, 1));
|
||||
var rv = await db.QueryAsync(
|
||||
"SELECT hlc, is_tombstone FROM __localdb_row_version WHERE table_name = 'orders' AND pk_json = @pk",
|
||||
x => (Hlc: x.GetInt64(0), Tomb: x.GetInt64(1)), new { pk = Pk1 });
|
||||
Assert.Single(rv);
|
||||
Assert.Equal(localHlc, rv[0].Hlc);
|
||||
Assert.Equal(0, rv[0].Tomb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_LedgerWriteFails_MidBatch_LeavesTheOtherEntriesApplied()
|
||||
{
|
||||
// The atomicity must be per-ENTRY, not per-batch: rolling back to the savepoint may not
|
||||
// discard the rows applied before the poison one, nor block the ones after it.
|
||||
using var db = await NewOrdersDb();
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'DOOMED', 2)");
|
||||
var doomedHlc = await RowVersionHlc(db, "{\"id\":2}");
|
||||
|
||||
await db.ExecuteAsync(
|
||||
"CREATE TRIGGER poison_ledger BEFORE INSERT ON __localdb_row_version " +
|
||||
"WHEN NEW.is_tombstone = 1 BEGIN SELECT RAISE(ABORT, 'injected ledger failure'); END");
|
||||
|
||||
var result = await new LwwApplier(db).ApplyBatchAsync(
|
||||
[
|
||||
Entry(91, Pk1, "{\"id\":1,\"sku\":\"BEFORE\",\"qty\":1}", doomedHlc + 1, "remote-node"),
|
||||
Entry(92, "{\"id\":2}", null, doomedHlc + 1000, "remote-node", tombstone: true),
|
||||
Entry(93, "{\"id\":3}", "{\"id\":3,\"sku\":\"AFTER\",\"qty\":3}", doomedHlc + 3, "remote-node"),
|
||||
], default);
|
||||
|
||||
Assert.Equal(2, result.Applied);
|
||||
Assert.Equal(1, result.DeadLettered);
|
||||
Assert.Equal(("BEFORE", (long?)1), await ReadOrder(db, 1));
|
||||
Assert.Equal(("DOOMED", (long?)2), await ReadOrder(db, 2));
|
||||
Assert.Equal(("AFTER", (long?)3), await ReadOrder(db, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_ReplaceExistingRow_DoesNotFireCaptureTriggers()
|
||||
{
|
||||
|
||||
@@ -212,6 +212,137 @@ public sealed class OplogStoreTests : IDisposable
|
||||
Assert.Equal("{\"id\":2}", remaining[0].PkJson);
|
||||
}
|
||||
|
||||
// ---- O(1) backlog depth --------------------------------------------------------------
|
||||
|
||||
// The definition the O(1) formula has to reproduce. Every scenario below is asserted against
|
||||
// this, not against a hand-computed number, so the two can never drift apart.
|
||||
private static async Task<long> CountingDepth(SqliteLocalDb db) =>
|
||||
(await db.QueryAsync(
|
||||
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > (SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1)",
|
||||
r => r.GetInt64(0)))[0];
|
||||
|
||||
private static async Task AssertDepthMatchesCount(SqliteLocalDb db, OplogStore store)
|
||||
{
|
||||
var expected = await CountingDepth(db);
|
||||
Assert.Equal(expected, await store.GetOplogDepthAsync(default));
|
||||
Assert.Equal(expected, store.GetOplogDepthSync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OplogDepth_Empty_IsZero()
|
||||
{
|
||||
using var db = await NewOrdersDb();
|
||||
var store = new OplogStore(db, new ReplicationOptions());
|
||||
|
||||
await AssertDepthMatchesCount(db, store);
|
||||
Assert.Equal(0, await store.GetOplogDepthAsync(default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OplogDepth_AllAcked_EmptiesToZero_NotToTheSeqRange()
|
||||
{
|
||||
using var db = await NewOrdersDb();
|
||||
await WriteRows(db, 5);
|
||||
var store = new OplogStore(db, new ReplicationOptions());
|
||||
await store.RecordPeerAckAsync(await store.GetMaxSeqAsync(default), default);
|
||||
|
||||
await AssertDepthMatchesCount(db, store);
|
||||
Assert.Equal(0, await store.GetOplogDepthAsync(default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OplogDepth_AfterCapPrune_ReportsRetainedRows_NotTheWholeSeqRange()
|
||||
{
|
||||
// The reason the formula clamps its floor to MIN(seq) - 1. A cap prune leaves the retained
|
||||
// rows far above an ack watermark that never moved, so a naive MAX(seq) - last_acked would
|
||||
// report the entire seq range forever and re-flag the cap on every maintenance tick.
|
||||
using var db = await NewOrdersDb();
|
||||
await WriteRows(db, 40);
|
||||
var store = new OplogStore(db, new ReplicationOptions { MaxOplogRows = 5 });
|
||||
|
||||
Assert.True(await store.EnforceCapsAsync(default));
|
||||
|
||||
await AssertDepthMatchesCount(db, store);
|
||||
Assert.Equal(5, await store.GetOplogDepthAsync(default));
|
||||
// And the next tick is clean: depth is not over cap any more.
|
||||
Assert.False(await store.EnforceCapsAsync(default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OplogDepth_AfterAgePrune_MatchesCount()
|
||||
{
|
||||
using var db = await NewOrdersDb();
|
||||
await WriteRows(db, 6);
|
||||
// Backdate the oldest three past the age cap; HLCs rise with seq, so this is a prefix.
|
||||
var expired = DateTimeOffset.UtcNow.AddDays(-30).ToUnixTimeMilliseconds() << 16;
|
||||
await db.ExecuteAsync(
|
||||
"UPDATE __localdb_oplog SET hlc = @h WHERE seq IN (SELECT seq FROM __localdb_oplog ORDER BY seq LIMIT 3)",
|
||||
new { h = expired });
|
||||
var store = new OplogStore(db, new ReplicationOptions());
|
||||
|
||||
Assert.True(await store.EnforceCapsAsync(default));
|
||||
|
||||
await AssertDepthMatchesCount(db, store);
|
||||
Assert.Equal(3, await store.GetOplogDepthAsync(default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OplogDepth_PartialAck_MatchesCount()
|
||||
{
|
||||
using var db = await NewOrdersDb();
|
||||
await WriteRows(db, 9);
|
||||
var store = new OplogStore(db, new ReplicationOptions());
|
||||
var batch = await store.ReadBatchAboveAsync(0, 100, default);
|
||||
|
||||
await store.RecordPeerAckAsync(batch[3].Seq, default);
|
||||
|
||||
await AssertDepthMatchesCount(db, store);
|
||||
Assert.Equal(5, await store.GetOplogDepthAsync(default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OplogDepth_AckPruneThenMoreWrites_MatchesCount()
|
||||
{
|
||||
using var db = await NewOrdersDb();
|
||||
await WriteRows(db, 4);
|
||||
var store = new OplogStore(db, new ReplicationOptions());
|
||||
await store.RecordPeerAckAsync(await store.GetMaxSeqAsync(default), default);
|
||||
|
||||
// The steady state of a converged pair: oplog pruned empty, watermark high, new writes above it.
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (91,'S',1),(92,'S',2)");
|
||||
|
||||
await AssertDepthMatchesCount(db, store);
|
||||
Assert.Equal(2, await store.GetOplogDepthAsync(default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OplogDepth_LargeBacklog_IsConstantTime()
|
||||
{
|
||||
// 200k rows: a COUNT(*) over the backlog runs ~200k VM steps per call (measured: 100,020 at
|
||||
// 50k rows), the seek-based formula runs 58 regardless of size. The loop below costs tens of
|
||||
// milliseconds at seek shape and seconds at scan shape, so the budget separates them by a
|
||||
// wide margin in both directions.
|
||||
using var db = await NewOrdersDb();
|
||||
await db.ExecuteAsync(
|
||||
"INSERT INTO __localdb_oplog (table_name, pk_json, row_json, hlc, node_id, is_tombstone) " +
|
||||
"WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 200000) " +
|
||||
"SELECT 'orders', '{\"id\":' || x || '}', '{\"id\":' || x || '}', x << 16, 'n', 0 FROM c");
|
||||
var store = new OplogStore(db, new ReplicationOptions());
|
||||
|
||||
Assert.Equal(200_000, await store.GetOplogDepthAsync(default));
|
||||
|
||||
using var conn = db.CreateConnection();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = OplogStore.DepthSql;
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
for (var i = 0; i < 2_000; i++)
|
||||
Assert.Equal(200_000L, (long)cmd.ExecuteScalar()!);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < 1_500,
|
||||
$"2000 depth reads over a 200k backlog took {sw.ElapsedMilliseconds} ms — that is scan-shaped, not seek-shaped.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerState_RoundTrips()
|
||||
{
|
||||
|
||||
@@ -70,6 +70,97 @@ public sealed class RegistrationTests : IDisposable
|
||||
Assert.Contains("BLOB", ex.Message);
|
||||
}
|
||||
|
||||
// ---- Cascading-delete foreign-key parents (P4-7) --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Register_CascadeDeleteFkParent_Throws()
|
||||
{
|
||||
// The hazard: applying a remote row uses INSERT OR REPLACE, whose implicit DELETE fires
|
||||
// ON DELETE CASCADE while the capture guard is RAISED — so the cascaded child rows are
|
||||
// deleted locally and never captured, and the peer keeps them forever.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync(
|
||||
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, order_id INTEGER " +
|
||||
"REFERENCES orders(id) ON DELETE CASCADE)");
|
||||
|
||||
var ex = Assert.Throws<LocalDbRegistrationException>(() => db.RegisterReplicated("orders"));
|
||||
Assert.Contains("orders", ex.Message);
|
||||
Assert.Contains("order_lines", ex.Message);
|
||||
Assert.Contains("ON DELETE CASCADE", ex.Message);
|
||||
|
||||
// Refused means refused: no triggers installed, nothing in the registry.
|
||||
Assert.Empty(await Triggers(db));
|
||||
Assert.False(db.ReplicatedTables.ContainsKey("orders"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_CascadeDeleteFkChild_IsAllowed()
|
||||
{
|
||||
// Only the PARENT is refused. The child side is safe: replacing a child row cascades
|
||||
// nothing, and the child's own deletes are captured normally.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync(
|
||||
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, order_id INTEGER " +
|
||||
"REFERENCES orders(id) ON DELETE CASCADE)");
|
||||
|
||||
db.RegisterReplicated("order_lines");
|
||||
|
||||
Assert.True(db.ReplicatedTables.ContainsKey("order_lines"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ON DELETE SET NULL", "ON DELETE SET NULL")]
|
||||
[InlineData("ON DELETE SET DEFAULT", "ON DELETE SET DEFAULT")]
|
||||
public async Task Register_OtherRowMutatingDeleteActions_AlsoThrow(string clause, string expected)
|
||||
{
|
||||
// SET NULL / SET DEFAULT mutate child rows on the same implicit DELETE and are captured
|
||||
// just as little — same divergence, so the same refusal.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync(
|
||||
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, order_id INTEGER DEFAULT 0 " +
|
||||
$"REFERENCES orders(id) {clause})");
|
||||
|
||||
var ex = Assert.Throws<LocalDbRegistrationException>(() => db.RegisterReplicated("orders"));
|
||||
Assert.Contains(expected, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_NonCascadingFkParent_IsAllowed()
|
||||
{
|
||||
// The default (NO ACTION / RESTRICT) mutates no child row: the implicit DELETE simply
|
||||
// fails the statement, which the applier already surfaces as a dead-lettered entry. Noisy,
|
||||
// not silent — so there is nothing to refuse.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync(
|
||||
"CREATE TABLE order_lines (id INTEGER PRIMARY KEY, order_id INTEGER REFERENCES orders(id))");
|
||||
await db.ExecuteAsync("CREATE TABLE restricted (id INTEGER PRIMARY KEY, " +
|
||||
"order_id INTEGER REFERENCES orders(id) ON DELETE RESTRICT)");
|
||||
|
||||
db.RegisterReplicated("orders");
|
||||
|
||||
Assert.Equal(3, (await Triggers(db)).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_UnrelatedTablesWithCascades_DoNotBlockRegistration()
|
||||
{
|
||||
// The scan must key on the table being registered, not on "the database contains a cascade".
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
await db.ExecuteAsync(
|
||||
"CREATE TABLE widget_parts (id INTEGER PRIMARY KEY, widget_id INTEGER " +
|
||||
"REFERENCES widgets(id) ON DELETE CASCADE)");
|
||||
|
||||
db.RegisterReplicated("orders");
|
||||
|
||||
Assert.True(db.ReplicatedTables.ContainsKey("orders"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_IsIdempotent_RegeneratesTriggers()
|
||||
{
|
||||
@@ -128,4 +219,409 @@ public sealed class RegistrationTests : IDisposable
|
||||
Assert.Equal(new[] { "id" }, table.PkColumns);
|
||||
Assert.Equal(new[] { "id", "sku", "qty" }, table.Columns.Select(c => c.Name).ToArray());
|
||||
}
|
||||
|
||||
// ---- Deregistration ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_DropsTriggers_PrunesOplogAndLedger_LeavesDataAlone()
|
||||
{
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
db.RegisterReplicated("orders");
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)");
|
||||
|
||||
var removed = db.DeregisterReplicated("orders");
|
||||
|
||||
Assert.True(removed);
|
||||
Assert.Empty(await Triggers(db));
|
||||
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='orders'"));
|
||||
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version WHERE table_name='orders'"));
|
||||
Assert.False(db.ReplicatedTables.ContainsKey("orders"));
|
||||
|
||||
// The consumer's own data is never touched — this removes CAPTURE, not rows.
|
||||
Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM orders"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_StopsCapture()
|
||||
{
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
db.RegisterReplicated("orders");
|
||||
db.DeregisterReplicated("orders");
|
||||
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
await db.ExecuteAsync("UPDATE orders SET qty = 9 WHERE id = 1");
|
||||
await db.ExecuteAsync("DELETE FROM orders WHERE id = 1");
|
||||
|
||||
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog"));
|
||||
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_IsIdempotent_SecondCallReportsNothingToDo()
|
||||
{
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
db.RegisterReplicated("orders");
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
|
||||
Assert.True(db.DeregisterReplicated("orders"));
|
||||
Assert.False(db.DeregisterReplicated("orders"));
|
||||
Assert.False(db.DeregisterReplicated("orders"));
|
||||
Assert.Empty(await Triggers(db));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_NeverRegisteredTable_IsANoOp()
|
||||
{
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
|
||||
Assert.False(db.DeregisterReplicated("orders"));
|
||||
Assert.False(db.DeregisterReplicated("ghost"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_ClearsTriggersLeftByAnEarlierProcess()
|
||||
{
|
||||
// The primary use: a database file registered by an OLDER build, opened by one that has
|
||||
// decided this node does not replicate. Nothing in memory says the table was ever
|
||||
// registered — the evidence is entirely on disk.
|
||||
using (var first = Create())
|
||||
{
|
||||
await CreateOrders(first);
|
||||
first.RegisterReplicated("orders");
|
||||
await first.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
}
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
using var second = Create();
|
||||
Assert.Equal(3, (await Triggers(second)).Count);
|
||||
Assert.False(second.ReplicatedTables.ContainsKey("orders"));
|
||||
|
||||
Assert.True(second.DeregisterReplicated("orders"));
|
||||
|
||||
Assert.Empty(await Triggers(second));
|
||||
Assert.Equal(0, await CountAsync(second, "SELECT COUNT(*) FROM __localdb_oplog"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_LeavesOtherTablesCapturing()
|
||||
{
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
db.RegisterReplicated("orders");
|
||||
db.RegisterReplicated("widgets");
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (1,'W')");
|
||||
|
||||
db.DeregisterReplicated("orders");
|
||||
await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (2,'W2')");
|
||||
|
||||
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='orders'"));
|
||||
Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name='widgets'"));
|
||||
Assert.True(db.ReplicatedTables.ContainsKey("widgets"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_ThenRegisterWithBaseline_RestoresTheLedger()
|
||||
{
|
||||
// Pruning the ledger is only safe because re-registration can re-baseline; this is that
|
||||
// round trip.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
db.RegisterReplicated("orders");
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)");
|
||||
|
||||
db.DeregisterReplicated("orders");
|
||||
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
||||
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
Assert.Equal(2, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version WHERE table_name='orders'"));
|
||||
}
|
||||
|
||||
// ---- Baselining ----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithoutBaseline_LeavesPreexistingRowsUnledgered()
|
||||
{
|
||||
// The documented CDC contract, unchanged: opting in is what changes behaviour, not upgrading.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
|
||||
db.RegisterReplicated("orders");
|
||||
|
||||
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
||||
Assert.False(await NeedsSnapshot(db));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithBaseline_SeedsAtHlcZero_AndFlagsSnapshot()
|
||||
{
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1),(2,'B',2)");
|
||||
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
var seeded = await db.QueryAsync(
|
||||
"SELECT pk_json, hlc, node_id, is_tombstone FROM __localdb_row_version ORDER BY pk_json",
|
||||
r => (Pk: r.GetString(0), Hlc: r.GetInt64(1), Node: r.GetString(2), Tomb: r.GetInt64(3)));
|
||||
|
||||
Assert.Equal(2, seeded.Count);
|
||||
Assert.Equal(["{\"id\":1}", "{\"id\":2}"], seeded.Select(s => s.Pk));
|
||||
// HLC 0 is the floor: below every genuine stamp (physical ms << 16), so it can never beat one.
|
||||
Assert.All(seeded, s => Assert.Equal(0, s.Hlc));
|
||||
Assert.All(seeded, s => Assert.Equal(db.NodeId, s.Node));
|
||||
Assert.All(seeded, s => Assert.Equal(0, s.Tomb));
|
||||
|
||||
// Without the flag the seeding would be inert: no gap exists at the handshake, so the peer
|
||||
// would be told it needs no snapshot and the baselined rows would never travel.
|
||||
Assert.True(await NeedsSnapshot(db));
|
||||
// ...and it is flagged for THIS TABLE ONLY. Through 0.2.x this set the one database-wide
|
||||
// bit, so baselining `orders` re-streamed every other registered table with it.
|
||||
Assert.False(await NeedsFullSnapshot(db));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithBaseline_FlagsOnlyTheBaselinedTable()
|
||||
{
|
||||
// The per-table flag's reason to exist: opting one table into replication late must not
|
||||
// schedule a full re-stream of its unrelated neighbours.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (1,'W')");
|
||||
|
||||
db.RegisterReplicated("widgets");
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
Assert.True(await NeedsSnapshot(db, "orders"));
|
||||
Assert.False(await NeedsSnapshot(db, "widgets"));
|
||||
Assert.False(await NeedsFullSnapshot(db));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_DropsThePendingSnapshotFlag()
|
||||
{
|
||||
// A flag naming a table nothing streams any more can never clear itself — the streamer skips
|
||||
// unregistered tables — so it would sit in the file and re-appear as unasked-for debt if the
|
||||
// table were registered again.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
Assert.True(await NeedsSnapshot(db));
|
||||
|
||||
db.DeregisterReplicated("orders");
|
||||
|
||||
Assert.False(await NeedsSnapshot(db));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithBaseline_IsIdempotent_AndDoesNotReflagSnapshot()
|
||||
{
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
// Stand in for the snapshot having been delivered and acknowledged.
|
||||
await db.ExecuteAsync("DELETE FROM __localdb_snapshot_state");
|
||||
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
Assert.Equal(1, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
||||
// Nothing new was seeded, so no resync is owed: passing true every boot is free after the first.
|
||||
Assert.False(await NeedsSnapshot(db));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithBaseline_NeverOverwritesACapturedVersion()
|
||||
{
|
||||
// The LWW-safety core: a row with a real, captured version must keep it. A floor that
|
||||
// clobbered genuine versions would make every baselined node look newer than its peer.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
db.RegisterReplicated("orders");
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
var captured = (await db.QueryAsync(
|
||||
"SELECT hlc FROM __localdb_row_version WHERE pk_json='{\"id\":1}'", r => r.GetInt64(0)))[0];
|
||||
Assert.True(captured > 0);
|
||||
|
||||
// A second, pre-existing row that capture never saw (inserted with triggers removed).
|
||||
db.DeregisterReplicated("orders");
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2,'B',2)");
|
||||
await db.ExecuteAsync(
|
||||
"INSERT INTO __localdb_row_version (table_name, pk_json, hlc, node_id, is_tombstone) " +
|
||||
"VALUES ('orders', '{\"id\":1}', @h, 'genuine', 0)", new { h = captured });
|
||||
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
var versions = await db.QueryAsync(
|
||||
"SELECT pk_json, hlc, node_id FROM __localdb_row_version ORDER BY pk_json",
|
||||
r => (Pk: r.GetString(0), Hlc: r.GetInt64(1), Node: r.GetString(2)));
|
||||
Assert.Equal(2, versions.Count);
|
||||
Assert.Equal(captured, versions[0].Hlc);
|
||||
Assert.Equal("genuine", versions[0].Node);
|
||||
Assert.Equal(0, versions[1].Hlc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithBaseline_SeedsPkJsonIdenticalToWhatCaptureWrites()
|
||||
{
|
||||
// A seeded key that differs by so much as a space from the capture triggers' json_object
|
||||
// output would be a DIFFERENT ledger row, and the baseline would silently never merge.
|
||||
using var db = Create();
|
||||
await db.ExecuteAsync("CREATE TABLE kv (a INTEGER, b TEXT, v TEXT, PRIMARY KEY (a, b))");
|
||||
await db.ExecuteAsync("INSERT INTO kv (a, b, v) VALUES (1, 'x', 'seeded')");
|
||||
|
||||
db.RegisterReplicated("kv", baselineExistingRows: true);
|
||||
await db.ExecuteAsync("UPDATE kv SET v = 'captured' WHERE a = 1 AND b = 'x'");
|
||||
|
||||
// One ledger row, not two: the capture upsert landed on the seeded key.
|
||||
var rows = await db.QueryAsync(
|
||||
"SELECT pk_json, hlc FROM __localdb_row_version", r => (Pk: r.GetString(0), Hlc: r.GetInt64(1)));
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("{\"a\":1,\"b\":\"x\"}", rows[0].Pk);
|
||||
Assert.True(rows[0].Hlc > 0, "the genuine capture must have replaced the floor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithBaseline_FloorLosesToEveryGenuineRemoteWrite()
|
||||
{
|
||||
// Proved through the real LWW path rather than by reasoning about the comparison: an
|
||||
// absurdly small remote HLC of 1 still beats the baseline floor of 0.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'BASELINE',1)");
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
var result = await new Replication.Internal.LwwApplier(db).ApplyBatchAsync(
|
||||
[new Replication.Internal.OplogEntryRecord(
|
||||
1, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"REMOTE\",\"qty\":9}", 1, "peer", false)],
|
||||
default);
|
||||
|
||||
Assert.Equal(1, result.Applied);
|
||||
var sku = await db.QueryAsync("SELECT sku FROM orders WHERE id = 1", r => r.GetString(0));
|
||||
Assert.Equal("REMOTE", sku[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithBaseline_TieBetweenTwoBaselinedNodes_ResolvesByNodeIdOrdinal()
|
||||
{
|
||||
// Two nodes that baselined the same key both hold HLC 0 — genuinely unordered history. The
|
||||
// node-id tie-break is what keeps that convergent instead of oscillating.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'MINE',1)");
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
var applier = new Replication.Internal.LwwApplier(db);
|
||||
var lower = await applier.ApplyBatchAsync(
|
||||
[new Replication.Internal.OplogEntryRecord(
|
||||
1, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"LOWER\",\"qty\":9}", 0, "!" + db.NodeId, false)],
|
||||
default);
|
||||
Assert.Equal(1, lower.Discarded);
|
||||
|
||||
var higher = await applier.ApplyBatchAsync(
|
||||
[new Replication.Internal.OplogEntryRecord(
|
||||
2, "orders", "{\"id\":1}", "{\"id\":1,\"sku\":\"HIGHER\",\"qty\":9}", 0, db.NodeId + "z", false)],
|
||||
default);
|
||||
Assert.Equal(1, higher.Applied);
|
||||
|
||||
var sku = await db.QueryAsync("SELECT sku FROM orders WHERE id = 1", r => r.GetString(0));
|
||||
Assert.Equal("HIGHER", sku[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithBaseline_EmptyTable_SeedsNothing_FlagsNothing()
|
||||
{
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
Assert.Equal(0, await CountAsync(db, "SELECT COUNT(*) FROM __localdb_row_version"));
|
||||
Assert.False(await NeedsSnapshot(db));
|
||||
}
|
||||
|
||||
// ---- Deregistration and the HLC crash anchor ------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_FlushesHlcAnchor_SoAnUngracefulReopenCannotRegress()
|
||||
{
|
||||
// The clock recovers as MAX(hlc_high_water, oplog HLCs, ledger HLCs, last_seen_hlc), and
|
||||
// deregistering the only registered table empties both ledgers. Without the in-transaction
|
||||
// flush, an ungraceful exit right afterwards leaves hlc_high_water at 0 and the reopened
|
||||
// clock restarts from wall-clock time — below stamps the peer already holds.
|
||||
var crashed = Create();
|
||||
try
|
||||
{
|
||||
await CreateOrders(crashed);
|
||||
crashed.RegisterReplicated("orders");
|
||||
await crashed.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'A',1)");
|
||||
|
||||
// A peer HLC an hour ahead, merged as the applier would merge it: this is what makes the
|
||||
// clock outrun wall-clock time, and therefore what a wall-clock restart would regress.
|
||||
var peerHlc = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() << 16;
|
||||
crashed.Clock.Observe(peerHlc);
|
||||
var preDereg = crashed.Clock.Current;
|
||||
Assert.True(preDereg >= peerHlc);
|
||||
|
||||
crashed.DeregisterReplicated("orders");
|
||||
|
||||
Assert.Equal(preDereg, await CountAsync(crashed, "SELECT hlc_high_water FROM __localdb_meta WHERE id = 1"));
|
||||
|
||||
// The evidence the clock would otherwise have recovered from is gone.
|
||||
Assert.Equal(0, await CountAsync(crashed, "SELECT COUNT(*) FROM __localdb_oplog"));
|
||||
Assert.Equal(0, await CountAsync(crashed, "SELECT COUNT(*) FROM __localdb_row_version"));
|
||||
Assert.Equal(0, await CountAsync(crashed, "SELECT last_seen_hlc FROM __localdb_peer_state WHERE id = 1"));
|
||||
|
||||
// Ungraceful reopen: a second instance over the same file while the first was NEVER
|
||||
// disposed, so nothing but the deregistration itself can have written the anchor.
|
||||
using var reopened = Create();
|
||||
Assert.True(reopened.Clock.Current >= preDereg,
|
||||
$"recovered clock {reopened.Clock.Current} regressed below the pre-deregistration stamp {preDereg}");
|
||||
Assert.True(reopened.Clock.Next() > preDereg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
crashed.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deregister_HlcFlush_DoesNotCountAsWorkDone()
|
||||
{
|
||||
// The flush is an UPDATE on every call, including the ones that clean up nothing; it must
|
||||
// not turn the "was there anything to do?" answer into a permanent true.
|
||||
using var db = Create();
|
||||
await CreateOrders(db);
|
||||
|
||||
Assert.False(db.DeregisterReplicated("orders"));
|
||||
Assert.False(db.DeregisterReplicated("ghost"));
|
||||
}
|
||||
|
||||
private static async Task<long> CountAsync(SqliteLocalDb db, string sql) =>
|
||||
(await db.QueryAsync(sql, r => r.GetInt64(0)))[0];
|
||||
|
||||
/// <summary>Whether <paramref name="table"/> individually owes the peer a snapshot (schema v3).</summary>
|
||||
private static async Task<bool> NeedsSnapshot(SqliteLocalDb db, string table = "orders") =>
|
||||
(await db.QueryAsync(
|
||||
"SELECT COUNT(*) FROM __localdb_snapshot_state WHERE table_name = @t AND needs_snapshot <> 0",
|
||||
r => r.GetInt64(0), new { t = table }))[0] != 0;
|
||||
|
||||
/// <summary>
|
||||
/// The DATABASE-WIDE flag — "every registered table". Baselining must never set it: that is the
|
||||
/// whole point of the per-table flags, and setting it would restore the 0.2.x behaviour silently.
|
||||
/// </summary>
|
||||
private static async Task<bool> NeedsFullSnapshot(SqliteLocalDb db) =>
|
||||
(await db.QueryAsync(
|
||||
"SELECT needs_snapshot FROM __localdb_peer_state WHERE id = 1", r => r.GetInt64(0)))[0] != 0;
|
||||
}
|
||||
|
||||
@@ -67,10 +67,15 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
return new Side(db, store, applier, session);
|
||||
}
|
||||
|
||||
private static (SyncDuplex A, SyncDuplex B) DuplexPair()
|
||||
// capacity models a transport that applies backpressure (which the gRPC duplex now is, via its
|
||||
// bounded inbox); null is the historical unbounded pipe.
|
||||
private static (SyncDuplex A, SyncDuplex B) DuplexPair(int? capacity = null)
|
||||
{
|
||||
var aToB = Channel.CreateUnbounded<SyncMessage>();
|
||||
var bToA = Channel.CreateUnbounded<SyncMessage>();
|
||||
Channel<SyncMessage> New() => capacity is int c
|
||||
? Channel.CreateBounded<SyncMessage>(c)
|
||||
: Channel.CreateUnbounded<SyncMessage>();
|
||||
var aToB = New();
|
||||
var bToA = New();
|
||||
var a = new SyncDuplex { Send = (m, ct) => aToB.Writer.WriteAsync(m, ct).AsTask(), Inbox = bToA.Reader };
|
||||
var b = new SyncDuplex { Send = (m, ct) => bToA.Writer.WriteAsync(m, ct).AsTask(), Inbox = aToB.Reader };
|
||||
return (a, b);
|
||||
@@ -84,9 +89,19 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
return (duplex, toSession.Writer, fromSession.Reader);
|
||||
}
|
||||
|
||||
private static Handshake MatchingHandshake(SqliteLocalDb sessionDb, string peerNodeId, long lastAppliedRemoteSeq = 0)
|
||||
// supportsPartialSnapshot defaults to FALSE, which is exactly what a 0.1.x/0.2.x peer puts on
|
||||
// the wire (the field does not exist in its proto, so proto3 decodes the default). Scripted
|
||||
// peers are therefore old peers unless a test says otherwise.
|
||||
private static Handshake MatchingHandshake(
|
||||
SqliteLocalDb sessionDb, string peerNodeId, long lastAppliedRemoteSeq = 0, bool supportsPartialSnapshot = false)
|
||||
{
|
||||
var h = new Handshake { NodeId = peerNodeId, LibSchemaVersion = 1, LastAppliedRemoteSeq = lastAppliedRemoteSeq };
|
||||
var h = new Handshake
|
||||
{
|
||||
NodeId = peerNodeId,
|
||||
LibSchemaVersion = 1,
|
||||
LastAppliedRemoteSeq = lastAppliedRemoteSeq,
|
||||
SupportsPartialSnapshot = supportsPartialSnapshot,
|
||||
};
|
||||
foreach (var t in sessionDb.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal))
|
||||
h.Tables.Add(new TableDigest { TableName = t.Name, Digest = t.Digest });
|
||||
return h;
|
||||
@@ -109,14 +124,34 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
|
||||
// Completes the two-phase handshake against a real session: the peer never requests a snapshot.
|
||||
private static async Task DriveHandshakeAsync(
|
||||
SqliteLocalDb sessionDb, ChannelWriter<SyncMessage> toSession, ChannelReader<SyncMessage> fromSession, CancellationToken ct)
|
||||
SqliteLocalDb sessionDb, ChannelWriter<SyncMessage> toSession, ChannelReader<SyncMessage> fromSession,
|
||||
CancellationToken ct, bool supportsPartialSnapshot = false, long lastAppliedRemoteSeq = 0)
|
||||
{
|
||||
await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.Handshake, ct);
|
||||
await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(sessionDb, "peer") }, ct);
|
||||
await toSession.WriteAsync(
|
||||
new SyncMessage
|
||||
{
|
||||
Handshake = MatchingHandshake(
|
||||
sessionDb, "peer", lastAppliedRemoteSeq, supportsPartialSnapshot),
|
||||
}, ct);
|
||||
await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.HandshakeAck, ct);
|
||||
await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, ct);
|
||||
}
|
||||
|
||||
// Reads outbound messages until (and including) the first of the given kind.
|
||||
private static async Task<List<SyncMessage>> DrainThroughAsync(
|
||||
ChannelReader<SyncMessage> reader, SyncMessage.MsgOneofCase last, CancellationToken ct)
|
||||
{
|
||||
var all = new List<SyncMessage>();
|
||||
while (true)
|
||||
{
|
||||
var msg = await NextAsync(reader, ct);
|
||||
all.Add(msg);
|
||||
if (msg.MsgCase == last)
|
||||
return all;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SwallowAsync(Task task)
|
||||
{
|
||||
try { await task; }
|
||||
@@ -154,6 +189,12 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
return r[0];
|
||||
}
|
||||
|
||||
/// <summary>The tables individually flagged as owing the peer a snapshot (schema v3).</summary>
|
||||
private static async Task<List<string>> PendingSnapshotTables(SqliteLocalDb db) =>
|
||||
(await db.QueryAsync(
|
||||
"SELECT table_name FROM __localdb_snapshot_state WHERE needs_snapshot <> 0 ORDER BY table_name",
|
||||
x => x.GetString(0))).ToList();
|
||||
|
||||
private static async Task<long> OplogCount(SqliteLocalDb db)
|
||||
{
|
||||
var r = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", x => x.GetInt64(0));
|
||||
@@ -170,6 +211,50 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
IsTombstone = false,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task MutualSnapshot_OverABoundedTransport_ConvergesWithoutDeadlock()
|
||||
{
|
||||
// BOTH sides owe a snapshot — the ordinary outcome of enabling replication late on a pair,
|
||||
// since baselining flags needs_snapshot on whichever node baselined and both nodes do.
|
||||
//
|
||||
// The snapshot used to be streamed from INSIDE the handshake, before the receive loop
|
||||
// existed, so neither side drained its inbox while sending. That exchange completed only
|
||||
// because the inbox was unbounded and could hold the peer's ENTIRE snapshot in memory —
|
||||
// which is the memory profile this design gave up on. Over a transport that pushes back
|
||||
// (which the gRPC duplex now is, with its bounded inbox) the old shape deadlocks outright.
|
||||
// Streaming the snapshot alongside an already-running receive loop is what makes both the
|
||||
// bound and this test possible.
|
||||
var options = new ReplicationOptions { MaxBatchSize = 2, FlushInterval = TimeSpan.FromMilliseconds(20) };
|
||||
var a = await NewSideAsync(options);
|
||||
var b = await NewSideAsync(options);
|
||||
|
||||
const int rowsPerSide = 60;
|
||||
for (var i = 1; i <= rowsPerSide; i++)
|
||||
{
|
||||
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'A', @id)", new { id = i });
|
||||
await b.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'B', @id)", new { id = 100 + i });
|
||||
}
|
||||
await a.Store.SetNeedsSnapshotAsync(true, default);
|
||||
await b.Store.SetNeedsSnapshotAsync(true, default);
|
||||
|
||||
// Capacity 1 each way: the tightest possible bound, so a side that stops draining its inbox
|
||||
// to send stalls at once instead of getting away with it on buffer slack.
|
||||
var (da, db) = DuplexPair(capacity: 1);
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var runA = a.Session.RunAsync(da, cts.Token);
|
||||
var runB = b.Session.RunAsync(db, cts.Token);
|
||||
|
||||
await WaitForAsync(
|
||||
async () => (await ReadOrders(a.Db)).Count == 2 * rowsPerSide
|
||||
&& (await ReadOrders(b.Db)).Count == 2 * rowsPerSide,
|
||||
RunTimeout);
|
||||
Assert.Equal(await ReadOrders(a.Db), await ReadOrders(b.Db));
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(runA);
|
||||
await SwallowAsync(runB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WipedPeer_AfterEverythingWasAckedAndPruned_StillGetsSnapshot()
|
||||
{
|
||||
@@ -292,6 +377,93 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
await SwallowAsync(runB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapPrune_IntoALiveSessionsUnsentRange_FaultsIt_AndTheReconnectSnapshots()
|
||||
{
|
||||
// P4-1. The pump reads `seq > sentThruSeq`, which resumes SILENTLY above a hole a cap prune
|
||||
// punched — so it keeps shipping later deltas, the peer acks them, and the pair is durably
|
||||
// divergent. `needs_snapshot` (set in the prune's own transaction) heals only a RECONNECT,
|
||||
// and nothing forced one: on a stable LAN with a slow-but-alive peer the session, and the
|
||||
// divergence, could last for days. Faulting is what turns the persisted flag into a snapshot.
|
||||
var a = await NewSideAsync();
|
||||
for (var i = 1; i <= 6; i++)
|
||||
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'A', @id)", new { id = i });
|
||||
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
// The peer holds everything so far: no snapshot is owed, and the pump idles at seq 6.
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token, lastAppliedRemoteSeq: 6);
|
||||
|
||||
// Write three more rows and prune all but the newest, in ONE transaction — which is what a
|
||||
// cap prune is, and what makes this deterministic: the pump's reader can only ever see the
|
||||
// before state (nothing above seq 6) or the after state (seqs 7 and 8 already gone). It can
|
||||
// never catch rows 7/8 in between and legitimately advance past them.
|
||||
await using (var tx = await a.Db.BeginTransactionAsync(cts.Token))
|
||||
{
|
||||
await tx.ExecuteAsync(
|
||||
"INSERT INTO orders (id, sku, qty) VALUES (7,'PRUNED',7),(8,'PRUNED',8),(9,'KEPT',9)", null, cts.Token);
|
||||
await tx.ExecuteAsync("UPDATE __localdb_peer_state SET needs_snapshot = 1 WHERE id = 1", null, cts.Token);
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM __localdb_oplog WHERE seq < (SELECT MAX(seq) FROM __localdb_oplog)", null, cts.Token);
|
||||
await tx.CommitAsync(cts.Token);
|
||||
}
|
||||
|
||||
var fault = await Assert.ThrowsAsync<LocalDbOplogGapException>(() => run);
|
||||
Assert.Contains("6", fault.Message, StringComparison.Ordinal);
|
||||
|
||||
// And it faulted BEFORE shipping anything from above the hole: a delta for seq 9 would have
|
||||
// moved the peer's watermark past rows 7 and 8, which is the divergence itself.
|
||||
while (fromSession.TryRead(out var stray))
|
||||
Assert.NotEqual(SyncMessage.MsgOneofCase.DeltaBatch, stray.MsgCase);
|
||||
|
||||
// The reconnect is what heals: the prune's needs_snapshot makes the handshake owe a full
|
||||
// snapshot, which carries the pruned rows' current state to a real peer.
|
||||
var a2 = NewSessionOver(a.Db);
|
||||
var b = await NewSideAsync();
|
||||
var (da, db) = DuplexPair();
|
||||
var run2A = a2.Session.RunAsync(da, cts.Token);
|
||||
var run2B = b.Session.RunAsync(db, cts.Token);
|
||||
|
||||
await WaitForAsync(async () => (await ReadOrders(b.Db)).Count == 9, RunTimeout);
|
||||
Assert.Equal(await ReadOrders(a.Db), await ReadOrders(b.Db));
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run2A);
|
||||
await SwallowAsync(run2B);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AckPruningToEmpty_DoesNotFaultALiveSession()
|
||||
{
|
||||
// The other half of the horizon check: ordinary ack-pruning deletes seq <= last_acked_seq,
|
||||
// which by construction sits at or below the sent watermark, so a converged pair that has
|
||||
// pruned its whole oplog must keep streaming. A gap test written against `needs_snapshot`,
|
||||
// or one that treated any risen horizon as a fault, would kill healthy sessions here.
|
||||
var a = await NewSideAsync();
|
||||
var b = await NewSideAsync();
|
||||
for (var i = 1; i <= 4; i++)
|
||||
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'A', @id)", new { id = i });
|
||||
|
||||
var (da, db) = DuplexPair();
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var runA = a.Session.RunAsync(da, cts.Token);
|
||||
var runB = b.Session.RunAsync(db, cts.Token);
|
||||
|
||||
await WaitForAsync(async () => (await ReadOrders(b.Db)).Count == 4, RunTimeout);
|
||||
await WaitForAsync(async () => await OplogCount(a.Db) == 0, RunTimeout);
|
||||
|
||||
// Still live, still incremental: a post-prune write reaches B as an ordinary delta.
|
||||
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (5, 'POST', 5)");
|
||||
await WaitForAsync(async () => (await ReadOrders(b.Db)).Any(r => r.Id == 5), RunTimeout);
|
||||
Assert.False(runA.IsFaulted, runA.Exception?.ToString());
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(runA);
|
||||
await SwallowAsync(runB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_MergesNotTruncates()
|
||||
{
|
||||
@@ -536,6 +708,261 @@ public sealed class SnapshotResyncTests : IDisposable
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
// ---- Per-table snapshot scoping (0.3.0) ----------------------------------------------
|
||||
|
||||
private const string WidgetsSql = "CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)";
|
||||
|
||||
/// <summary>
|
||||
/// A node with two registered tables where exactly one — <c>orders</c> — was baselined.
|
||||
/// <c>widgets</c> is registered BEFORE its rows are written, so ordinary capture ledgers them;
|
||||
/// <c>orders</c> is registered after, so only baselining can. That asymmetry is what makes the
|
||||
/// scope observable: a full snapshot carries both tables' ledgers, a scoped one carries orders
|
||||
/// alone — and both tables hold rows either way, so an empty widgets stream cannot be mistaken
|
||||
/// for "there was nothing to send".
|
||||
/// </summary>
|
||||
private async Task<Side> NewBaselinedSideAsync(ReplicationOptions? options = null)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
||||
_paths.Add(path);
|
||||
var db = new SqliteLocalDb(new LocalDbOptions { Path = path });
|
||||
_disposables.Add(db);
|
||||
await db.ExecuteAsync(OrdersSql);
|
||||
await db.ExecuteAsync(WidgetsSql);
|
||||
|
||||
// Predates registration: invisible to CDC, so only baselining puts it in the ledger.
|
||||
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1,'PRE',1),(2,'PRE',2)");
|
||||
|
||||
db.RegisterReplicated("widgets");
|
||||
await db.ExecuteAsync("INSERT INTO widgets (id, name) VALUES (1,'W1'),(2,'W2')");
|
||||
|
||||
db.RegisterReplicated("orders", baselineExistingRows: true);
|
||||
|
||||
return NewSessionOver(db, options);
|
||||
}
|
||||
|
||||
/// <summary>The peer of <see cref="NewBaselinedSideAsync"/>: the same two tables, empty, owing nothing.</summary>
|
||||
private async Task<Side> NewTwoTableSideAsync(ReplicationOptions? options = null)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
||||
_paths.Add(path);
|
||||
var db = new SqliteLocalDb(new LocalDbOptions { Path = path });
|
||||
_disposables.Add(db);
|
||||
await db.ExecuteAsync(OrdersSql);
|
||||
await db.ExecuteAsync(WidgetsSql);
|
||||
db.RegisterReplicated("orders");
|
||||
db.RegisterReplicated("widgets");
|
||||
return NewSessionOver(db, options);
|
||||
}
|
||||
|
||||
private static List<string> SnapshotTablesIn(IEnumerable<SyncMessage> messages) =>
|
||||
messages.Where(m => m.MsgCase == SyncMessage.MsgOneofCase.SnapshotBatch)
|
||||
.Select(m => m.SnapshotBatch.TableName)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task Baselining_OneTable_StreamsOnlyThatTable()
|
||||
{
|
||||
// The whole point of the per-table flag. Through 0.2.x the flag was one bit for the whole
|
||||
// database, so baselining `orders` re-streamed `widgets` (and everything else) in full — in
|
||||
// both directions at once on a pair enabling replication late, which on a database holding
|
||||
// an event log at its retention cap is the largest transfer this library performs.
|
||||
var a = await NewBaselinedSideAsync();
|
||||
Assert.Equal(["orders"], await PendingSnapshotTables(a.Db));
|
||||
Assert.Equal(0, await NeedsSnapshot(a.Db)); // NOT the database-wide flag
|
||||
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token, supportsPartialSnapshot: true);
|
||||
var frames = await DrainThroughAsync(fromSession, SyncMessage.MsgOneofCase.SnapshotComplete, cts.Token);
|
||||
|
||||
var begin = frames.Single(m => m.MsgCase == SyncMessage.MsgOneofCase.SnapshotBegin).SnapshotBegin;
|
||||
Assert.Equal(["orders"], begin.Tables);
|
||||
Assert.Equal(["orders"], SnapshotTablesIn(frames));
|
||||
// The baselined rows really are in there — the scope narrowed the snapshot, it did not empty it.
|
||||
var pks = frames.Where(m => m.MsgCase == SyncMessage.MsgOneofCase.SnapshotBatch)
|
||||
.SelectMany(m => m.SnapshotBatch.Rows).Select(r => r.PkJson).Order(StringComparer.Ordinal);
|
||||
Assert.Equal(["{\"id\":1}", "{\"id\":2}"], pks);
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Baselining_AgainstAPeerWithoutPartialSupport_StreamsEveryTable()
|
||||
{
|
||||
// Mixed-version interop, the hard constraint. A pre-0.3.0 peer ignores SnapshotBegin.tables
|
||||
// and would advance last_applied_remote_seq to as_of_seq on complete — which for a SCOPED
|
||||
// snapshot silently discards every delta below as_of for the tables it left out. There is no
|
||||
// way to express "partial" to that peer, so it is served the 0.2.x answer: everything.
|
||||
var a = await NewBaselinedSideAsync();
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
// supportsPartialSnapshot omitted == what a 0.1.x/0.2.x handshake decodes to.
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token);
|
||||
var frames = await DrainThroughAsync(fromSession, SyncMessage.MsgOneofCase.SnapshotComplete, cts.Token);
|
||||
|
||||
var begin = frames.Single(m => m.MsgCase == SyncMessage.MsgOneofCase.SnapshotBegin).SnapshotBegin;
|
||||
Assert.Empty(begin.Tables); // empty == full, the only scope the old wire can express
|
||||
Assert.Equal(["orders", "widgets"], SnapshotTablesIn(frames));
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PartialSnapshot_DebtClearsOnlyWhenThePeerAcknowledges()
|
||||
{
|
||||
// Crash safety: an ungraceful exit part-way through a snapshot must re-stream it, so the
|
||||
// flag cannot come down when the frames are merely enqueued. The peer's SnapshotAck is
|
||||
// written after its own durable watermark write, so it means "I hold these rows".
|
||||
var a = await NewBaselinedSideAsync();
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token, supportsPartialSnapshot: true);
|
||||
var frames = await DrainThroughAsync(fromSession, SyncMessage.MsgOneofCase.SnapshotComplete, cts.Token);
|
||||
var complete = frames[^1].SnapshotComplete;
|
||||
|
||||
// The entire snapshot is on the wire and unacknowledged: the debt must still stand.
|
||||
Assert.Equal(["orders"], await PendingSnapshotTables(a.Db));
|
||||
|
||||
await toSession.WriteAsync(new SyncMessage { SnapshotAck = new SnapshotAck { AsOfSeq = complete.AsOfSeq } }, cts.Token);
|
||||
await WaitForAsync(async () => (await PendingSnapshotTables(a.Db)).Count == 0, RunTimeout);
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_ToAPeerWithoutPartialSupport_ClearsOnEnqueue()
|
||||
{
|
||||
// The 0.2.x behaviour, kept deliberately: a peer that sends no SnapshotAck would otherwise
|
||||
// leave the debt standing forever and re-stream the snapshot on every reconnect.
|
||||
var a = await NewBaselinedSideAsync();
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token);
|
||||
await DrainThroughAsync(fromSession, SyncMessage.MsgOneofCase.SnapshotComplete, cts.Token);
|
||||
|
||||
await WaitForAsync(async () => (await PendingSnapshotTables(a.Db)).Count == 0, RunTimeout);
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PartialSnapshot_DoesNotAdvanceTheReceiversWatermark()
|
||||
{
|
||||
// The receiver's half of the same invariant. last_applied_remote_seq is ONE number for the
|
||||
// whole peer oplog; moving it to a partial snapshot's as_of would declare the untouched
|
||||
// tables applied through that point, and their deltas below it would never be resent.
|
||||
var a = await NewSideAsync();
|
||||
await a.Store.SetLastAppliedRemoteSeqAsync(10, 0);
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token, supportsPartialSnapshot: true);
|
||||
|
||||
var begin = new SnapshotBegin { AsOfSeq = 900 };
|
||||
begin.Tables.Add("orders");
|
||||
await toSession.WriteAsync(new SyncMessage { SnapshotBegin = begin }, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage
|
||||
{
|
||||
SnapshotBatch = new SnapshotBatch { TableName = "orders", Rows = { SnapRow(1, "SNAP", 6_000_000) } },
|
||||
}, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = 900 } }, cts.Token);
|
||||
|
||||
// The ack proves the complete was processed — and that a 0.3.0 receiver answers one at all.
|
||||
var ack = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.SnapshotAck, cts.Token);
|
||||
Assert.Equal(900, ack.SnapshotAck.AsOfSeq);
|
||||
|
||||
Assert.Equal(10, await LastAppliedRemoteSeq(a.Db)); // NOT 900
|
||||
Assert.Single(await ReadOrders(a.Db)); // the rows still merged
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullSnapshot_ToAnOldPeer_DrawsNoSnapshotAck()
|
||||
{
|
||||
// Symmetry check on the negotiation: a 0.3.0 receiver must not put a message a pre-0.3.0
|
||||
// sender has never heard of on the wire back to it.
|
||||
var a = await NewSideAsync();
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var run = a.Session.RunAsync(duplex, cts.Token);
|
||||
|
||||
await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 300 } }, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage
|
||||
{
|
||||
SnapshotBatch = new SnapshotBatch { TableName = "orders", Rows = { SnapRow(1, "SNAP", 6_000_000) } },
|
||||
}, cts.Token);
|
||||
await toSession.WriteAsync(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = 300 } }, cts.Token);
|
||||
|
||||
// A full snapshot from an old peer still advances the watermark — that is the 0.2.x contract.
|
||||
await WaitForAsync(async () => await LastAppliedRemoteSeq(a.Db) == 300, RunTimeout);
|
||||
// Barrier: an ack for this delta proves everything before it was processed, so an absent
|
||||
// SnapshotAck is absent rather than merely late.
|
||||
await toSession.WriteAsync(new SyncMessage
|
||||
{
|
||||
DeltaBatch = new DeltaBatch { Entries = { new OplogEntry
|
||||
{
|
||||
Seq = 301, TableName = "orders", PkJson = "{\"id\":2}",
|
||||
RowJson = "{\"id\":2,\"sku\":\"BARRIER\",\"qty\":1}", Hlc = 6_000_001, NodeId = "peer", IsTombstone = false,
|
||||
} } },
|
||||
}, cts.Token);
|
||||
await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token);
|
||||
Assert.False(fromSession.TryPeek(out var stray) && stray.MsgCase == SyncMessage.MsgOneofCase.SnapshotAck);
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BaselinedTable_ReachesARealPeer_WithoutRestreamingTheOthers()
|
||||
{
|
||||
// End to end over two real databases: the baselined table's pre-existing rows arrive via the
|
||||
// scoped snapshot, the untouched table's rows arrive as ordinary deltas, and the debt clears.
|
||||
var options = new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20) };
|
||||
var a = await NewBaselinedSideAsync(options);
|
||||
var b = await NewTwoTableSideAsync(options);
|
||||
|
||||
var (da, db) = DuplexPair();
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var runA = a.Session.RunAsync(da, cts.Token);
|
||||
var runB = b.Session.RunAsync(db, cts.Token);
|
||||
|
||||
async Task<bool> Converged() =>
|
||||
(await ReadOrders(b.Db)).Count == 2
|
||||
&& (await b.Db.QueryAsync("SELECT COUNT(*) FROM widgets", x => x.GetInt64(0)))[0] == 2;
|
||||
|
||||
await WaitForAsync(Converged, RunTimeout);
|
||||
Assert.Equal(await ReadOrders(a.Db), await ReadOrders(b.Db));
|
||||
await WaitForAsync(async () => (await PendingSnapshotTables(a.Db)).Count == 0, RunTimeout);
|
||||
|
||||
// A partial snapshot speaks for nothing outside its tables, so neither side may skip the
|
||||
// deltas it covered: B's watermark tracks the oplog, not the snapshot's as-of.
|
||||
Assert.Equal(0, await NeedsSnapshot(a.Db));
|
||||
|
||||
cts.Cancel();
|
||||
await SwallowAsync(runA);
|
||||
await SwallowAsync(runB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sender_ClearsNeedsSnapshotFlag()
|
||||
{
|
||||
|
||||
@@ -208,6 +208,50 @@ public sealed class SyncSessionTests : IDisposable
|
||||
Assert.Empty(await ReadOrders(b.Db));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_FaultTeardown_StillTransmitsTheLocalHandshake()
|
||||
{
|
||||
// Schema-mismatch detection is symmetric: each side computes its verdict FROM the other's
|
||||
// Handshake. So a session that faults on the peer's handshake must still flush its own —
|
||||
// otherwise the healthy peer learns nothing and waits out its receive cap (tests) or
|
||||
// misclassifies the mismatch as a generic disconnect and reconnect-loops (production).
|
||||
//
|
||||
// The race being pinned: with the peer's handshake already buffered, the path from
|
||||
// enqueueing our Handshake on the control lane to the validation throw has no yields,
|
||||
// while the writer's wakeup sits in the thread-pool queue — a teardown that cancels the
|
||||
// writer can therefore kill the Handshake before it ever transmits. Preloading makes the
|
||||
// sprint as tight as possible, and running the scenarios concurrently starves the pool
|
||||
// the way a busy test suite does.
|
||||
const int Iterations = 40;
|
||||
var sides = new List<Side>(Iterations);
|
||||
for (var i = 0; i < Iterations; i++)
|
||||
sides.Add(await NewSideAsync());
|
||||
|
||||
using var cts = new CancellationTokenSource(RunTimeout);
|
||||
var runs = new List<(Task Run, ChannelReader<SyncMessage> FromSession)>(Iterations);
|
||||
foreach (var side in sides)
|
||||
{
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
var mismatched = MatchingHandshake(side.Db, "peer");
|
||||
mismatched.Tables[0].Digest = "not-the-local-digest";
|
||||
await toSession.WriteAsync(new SyncMessage { Handshake = mismatched }, cts.Token);
|
||||
runs.Add((side.Session.RunAsync(duplex, cts.Token), fromSession));
|
||||
}
|
||||
|
||||
var lost = 0;
|
||||
foreach (var (run, fromSession) in runs)
|
||||
{
|
||||
await Assert.ThrowsAsync<LocalDbSchemaMismatchException>(() => run);
|
||||
var sent = false;
|
||||
while (fromSession.TryRead(out var msg))
|
||||
sent |= msg.MsgCase == SyncMessage.MsgOneofCase.Handshake;
|
||||
if (!sent)
|
||||
lost++;
|
||||
}
|
||||
|
||||
Assert.True(lost == 0, $"{lost}/{Iterations} sessions tore down without transmitting their own Handshake");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_SchemaVersionMismatch_FailsClosed()
|
||||
{
|
||||
@@ -553,8 +597,9 @@ public sealed class SyncSessionTests : IDisposable
|
||||
await a.Store.SetNeedsSnapshotAsync(true, default);
|
||||
var (duplex, toSession, fromSession) = ScriptedPeer();
|
||||
var sent = new TaskCompletionSource();
|
||||
a.Session.SnapshotSender = async (send, token) =>
|
||||
a.Session.SnapshotSender = async (scope, send, token) =>
|
||||
{
|
||||
Assert.False(scope.IsPartial); // the database-wide flag always means every table
|
||||
sent.TrySetResult();
|
||||
await send(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = 7 } }, token);
|
||||
return 7;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Threading.Channels;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.LocalDb.Contracts;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
|
||||
namespace ZB.MOM.WW.LocalDb.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The passive service's single-stream guard under a HALF-OPEN link: the dialling node is gone, but
|
||||
/// nothing on this side knows it, so the handler sits inside <c>MoveNext</c> forever holding the
|
||||
/// guard. Through 0.3.1 that made the pair unrecoverable without a restart — every reconnect from
|
||||
/// the rebooted peer was refused <c>AlreadyExists</c>, indefinitely on an idle pair, while this node
|
||||
/// went on reporting itself connected. The guard is there to serialize sessions, not to fence out a
|
||||
/// caller the auth interceptor already vouched for, so the newest dialler wins.
|
||||
/// </summary>
|
||||
public sealed class SyncStreamGuardTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly List<string> _paths = [];
|
||||
private readonly List<IDisposable> _disposables = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
d.Dispose();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (var p in _paths)
|
||||
{
|
||||
if (File.Exists(p)) File.Delete(p);
|
||||
if (File.Exists(p + "-wal")) File.Delete(p + "-wal");
|
||||
if (File.Exists(p + "-shm")) File.Delete(p + "-shm");
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDbSyncService NewService()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
||||
_paths.Add(path);
|
||||
var db = new Internal.SqliteLocalDb(new LocalDbOptions { Path = path });
|
||||
_disposables.Add(db);
|
||||
db.ExecuteAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT)").GetAwaiter().GetResult();
|
||||
db.RegisterReplicated("orders");
|
||||
|
||||
return new LocalDbSyncService(
|
||||
db,
|
||||
Options.Create(new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20) }),
|
||||
NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
private static ServerCallContext ContextFor(CancellationToken ct) => new FakeCallContext(ct);
|
||||
|
||||
[Fact]
|
||||
public async Task WedgedIncumbent_IsDisplacedByANewStream()
|
||||
{
|
||||
var service = NewService();
|
||||
// Eviction is normally generous; the test only needs it to not be the thing under test.
|
||||
service.TakeoverTimeout = Timeout;
|
||||
|
||||
// The incumbent's peer crashed: its stream will never deliver another message, and neither
|
||||
// end times out on silence — a converged pair is legitimately quiet. The handler parks in
|
||||
// the handshake's first read, holding the guard.
|
||||
using var incumbentCts = new CancellationTokenSource();
|
||||
var incumbentOut = new RecordingWriter();
|
||||
var incumbent = service.Sync(new SilentReader(), incumbentOut, ContextFor(incumbentCts.Token));
|
||||
|
||||
// It really is running and really did claim the guard (its handshake is on the wire).
|
||||
await incumbentOut.NextAsync(Timeout);
|
||||
Assert.False(incumbent.IsCompleted);
|
||||
|
||||
// The rebooted peer dials again. Pre-fix this threw AlreadyExists and the pair stayed wedged.
|
||||
using var newcomerCts = new CancellationTokenSource();
|
||||
var newcomerOut = new RecordingWriter();
|
||||
var newcomer = service.Sync(new SilentReader(), newcomerOut, ContextFor(newcomerCts.Token));
|
||||
|
||||
// The incumbent is torn down — gracefully, not as a fault of this node.
|
||||
await incumbent.WaitAsync(Timeout);
|
||||
// ...and the new stream is genuinely served: its own handshake goes out.
|
||||
var handshake = await newcomerOut.NextAsync(Timeout);
|
||||
Assert.Equal(SyncMessage.MsgOneofCase.Handshake, handshake.MsgCase);
|
||||
Assert.False(newcomer.IsCompleted);
|
||||
|
||||
newcomerCts.Cancel();
|
||||
await SwallowAsync(newcomer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisplacedIncumbent_ReleasesTheGuard_SoAThirdStreamIsAlsoServed()
|
||||
{
|
||||
// Takeover must leave the guard in a state that can be taken over AGAIN — a handover that
|
||||
// leaked the slot would simply move the wedge one reconnect down the line.
|
||||
var service = NewService();
|
||||
service.TakeoverTimeout = Timeout;
|
||||
|
||||
using var firstCts = new CancellationTokenSource();
|
||||
var firstOut = new RecordingWriter();
|
||||
var first = service.Sync(new SilentReader(), firstOut, ContextFor(firstCts.Token));
|
||||
await firstOut.NextAsync(Timeout);
|
||||
|
||||
using var secondCts = new CancellationTokenSource();
|
||||
var secondOut = new RecordingWriter();
|
||||
var second = service.Sync(new SilentReader(), secondOut, ContextFor(secondCts.Token));
|
||||
await first.WaitAsync(Timeout);
|
||||
await secondOut.NextAsync(Timeout);
|
||||
|
||||
using var thirdCts = new CancellationTokenSource();
|
||||
var thirdOut = new RecordingWriter();
|
||||
var third = service.Sync(new SilentReader(), thirdOut, ContextFor(thirdCts.Token));
|
||||
await second.WaitAsync(Timeout);
|
||||
Assert.Equal(SyncMessage.MsgOneofCase.Handshake, (await thirdOut.NextAsync(Timeout)).MsgCase);
|
||||
|
||||
thirdCts.Cancel();
|
||||
await SwallowAsync(third);
|
||||
}
|
||||
|
||||
private static async Task SwallowAsync(Task task)
|
||||
{
|
||||
try { await task.WaitAsync(Timeout); }
|
||||
catch (OperationCanceledException) { }
|
||||
catch (RpcException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The minimum <see cref="ServerCallContext"/> the handler actually reads: its cancellation
|
||||
/// token. (Grpc.Core.Api ships no test double, and the ASP.NET Core one is not reachable outside
|
||||
/// a live call.)
|
||||
/// </summary>
|
||||
private sealed class FakeCallContext(CancellationToken ct) : ServerCallContext
|
||||
{
|
||||
protected override string MethodCore => "/localdb_sync.v1.LocalDbSync/Sync";
|
||||
protected override string HostCore => "test";
|
||||
protected override string PeerCore => "test";
|
||||
protected override DateTime DeadlineCore => DateTime.MaxValue;
|
||||
protected override Metadata RequestHeadersCore => [];
|
||||
protected override CancellationToken CancellationTokenCore => ct;
|
||||
protected override Metadata ResponseTrailersCore { get; } = [];
|
||||
protected override Status StatusCore { get; set; }
|
||||
protected override WriteOptions? WriteOptionsCore { get; set; }
|
||||
protected override AuthContext AuthContextCore => new(null, new Dictionary<string, List<AuthProperty>>());
|
||||
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>A stream that never delivers anything and never ends — a half-open link.</summary>
|
||||
private sealed class SilentReader : IAsyncStreamReader<SyncMessage>
|
||||
{
|
||||
public SyncMessage Current => throw new InvalidOperationException("no message");
|
||||
|
||||
public async Task<bool> MoveNext(CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingWriter : IServerStreamWriter<SyncMessage>
|
||||
{
|
||||
private readonly Channel<SyncMessage> _written = Channel.CreateUnbounded<SyncMessage>();
|
||||
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
public Task WriteAsync(SyncMessage message) => WriteAsync(message, CancellationToken.None);
|
||||
|
||||
public Task WriteAsync(SyncMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
_written.Writer.TryWrite(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<SyncMessage> NextAsync(TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
return await _written.Reader.ReadAsync(cts.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,20 @@ set -euo pipefail
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# Named solution, named output, and a cd to the repo root, all for one reason: `dotnet pack`
|
||||
# with neither a project argument nor a fixed working directory packs whatever solution
|
||||
# happens to sit in the CALLER's directory — and then pushes it to a live feed under this
|
||||
# script's name. Invoked by absolute path from another repo's checkout, the relative form
|
||||
# packed and published 25 unrelated packages (LocalDb's copy of this script, fixed in
|
||||
# 0038d46; recovered with `dotnet nuget delete`, but only because the token happened to
|
||||
# carry delete rights). A publish script must be unambiguous about WHAT it publishes no
|
||||
# matter where it is run.
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
dotnet pack ZB.MOM.WW.Secrets.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
dotnet nuget push "$REPO_ROOT/artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
|
||||
@@ -6,4 +6,10 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# cd to the repo root and name the solution + an absolute output path: `dotnet pack` with
|
||||
# neither a project argument nor a fixed working directory packs whatever solution happens
|
||||
# to sit in the CALLER's directory (the bug behind the LocalDb feed incident, 0038d46).
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet pack ZB.MOM.WW.Theme.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
@@ -16,9 +16,20 @@ set -euo pipefail
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# Named solution, named output, and a cd to the repo root, all for one reason: `dotnet pack`
|
||||
# with neither a project argument nor a fixed working directory packs whatever solution
|
||||
# happens to sit in the CALLER's directory — and then pushes it to a live feed under this
|
||||
# script's name. Invoked by absolute path from another repo's checkout, the relative form
|
||||
# packed and published 25 unrelated packages (LocalDb's copy of this script, fixed in
|
||||
# 0038d46; recovered with `dotnet nuget delete`, but only because the token happened to
|
||||
# carry delete rights). A publish script must be unambiguous about WHAT it publishes no
|
||||
# matter where it is run.
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
dotnet pack ZB.MOM.WW.Theme.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
dotnet nuget push "$REPO_ROOT/artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
|
||||
+35
-15
@@ -50,7 +50,7 @@ collide (see [§7](#7-consolidated-host-port-map)).
|
||||
| **fixture host** | `10.100.0.35` | Debian/Linux + Docker | OtOpcUa driver **integration-test fixtures** + a test SQL Server | 22 |
|
||||
| **VD03 (DEV)** | `wonder-app-vd03.zmr.zimmer.com` | Windows | **DEV** all-in-one box: ScadaBridge (single-node) + OtOpcUa + MxGateway + HistorianGateway + Overview | **2222** |
|
||||
| **VQ03 (QA)** | `wonder-app-vq03.zmr.zimmer.com` (`10.220.158.2`) | Windows | **QA node A** — ScadaBridge central-a + site-a, OtOpcUa node-a, **plus the singleton services**: MxGateway, HistorianGateway, Overview | **2222** |
|
||||
| **VQ04 (QA)** | `wonder-app-vq04.zmr.zimmer.com` (`10.220.158.3`) | Windows | **QA node B** — ScadaBridge central-b + site-b, OtOpcUa node-b. **No** MxGateway/HistorianGateway/Overview | **2222** |
|
||||
| **VQ04 (QA)** | `wonder-app-vq04.zmr.zimmer.com` (`10.220.158.3`) | Windows | **QA node B** — ScadaBridge central-b + site-b, OtOpcUa node-b, **plus (since 2026-08-14) its own MxGateway, HistorianGateway and Overview** (cloned from VQ03 incl. API-key stores; Galaxy SQL points at VQ03 — no local SQL Server) | **2222** |
|
||||
| **wonder-sql-vq03** | `wonder-sql-vq03.zmr.zimmer.com` | Windows | **QA data tier** — SQL Server (`1433`) + AVEVA Historian gRPC TLS (`32565`); mirrors `wonder-sql-vd03` | _(not probed)_ |
|
||||
| **gitea** | `gitea.dohertylan.com` (`10.100.0.228`) | Linux | Git remotes + NuGet feed (`/api/packages/dohertj2/nuget`) | 22 |
|
||||
|
||||
@@ -85,7 +85,7 @@ Host windev
|
||||
| **windev** (Win host) | `ssh windev` | Configured alias; user `dohertj2`, key `id_ed25519`, port 22 |
|
||||
| **fixture host** | `ssh dohertj2@10.100.0.35` | In `known_hosts`; **no** config alias — pass user explicitly; port 22, key-based |
|
||||
| **VD03 (DEV)** | `ssh dohertj2@wonder-app-vd03.zmr.zimmer.com -p 2222` | In `known_hosts` on **port 2222** (servecli, not OpenSSH); user/key not pinned in config — confirm before use. Durable key `~/.ssh/servecli_wonder`. **servecli does not survive a reboot** — if it's down, use RDP |
|
||||
| **VQ03 / VQ04 (QA)** | `ssh dohertj2@wonder-app-vq0{3,4}.zmr.zimmer.com -p 2222` | ⚠️ **Not working 2026-08-13** — see the banner note below |
|
||||
| **VQ03 / VQ04 (QA)** | `psbridge --server wonder-app-vq0{3,4}.zmr.zimmer.com:6150 <cmd>` | **The supported path since 2026-08-14** (servecli `2222` is retired on these boxes — see the banner note below). Root jail `E:\psrem`; see the `psbridge` skill |
|
||||
| **local Linux VMs** | `ssh orb` / `orb` | OrbStack-managed |
|
||||
|
||||
> ⚠️ **A TCP-open `2222` does NOT mean SSH works.** On 2026-08-13 all three wonder boxes
|
||||
@@ -281,9 +281,11 @@ dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
|
||||
- **Worker model:** the Server spawns one `ZB.MOM.WW.MxGateway.Worker.exe` (x86) **per gRPC session**;
|
||||
IPC over a named pipe (`\\.\pipe\mxgateway-<session>` + a per-session `MXGATEWAY_WORKER_NONCE`);
|
||||
heartbeat 5s / grace 15s; max 64 concurrent sessions. The worker exits when the session closes.
|
||||
- **Deployed hosts:** `10.100.0.48` (windev), `wonder-app-vd03` (DEV) and `wonder-app-vq03` (QA)
|
||||
all serve gRPC on `:5120` (per `docs/GatewayConfiguration.md`; the VQ03 port was probed
|
||||
2026-08-13). **QA runs MxGateway on VQ03 only** — VQ04 has no gateway.
|
||||
- **Deployed hosts:** `10.100.0.48` (windev), `wonder-app-vd03` (DEV), `wonder-app-vq03` and
|
||||
(since 2026-08-14) `wonder-app-vq04` (QA) all serve gRPC on `:5120` (per
|
||||
`docs/GatewayConfiguration.md`). **QA runs MxGateway on BOTH nodes** — VQ04's was cloned from
|
||||
VQ03 (same API-key store + pepper, so the same tokens verify on either; its Galaxy SQL and its
|
||||
alarm subscription `\\wonder-app-vq03\Galaxy!NAM` both point at VQ03).
|
||||
|
||||
### 5.3 Who connects to it
|
||||
|
||||
@@ -354,14 +356,18 @@ DEV gRPC on `wonder-app-vd03:5120`, QA on `wonder-app-vq03:5120`. SSH: windev/fi
|
||||
|
||||
### 7.1 QA port map: VQ03 and VQ04
|
||||
|
||||
Established **2026-08-13** from two independent live sources, neither of them a config file — treat
|
||||
connection strings and service names as unverified. Rows are marked with how each was established:
|
||||
Established **2026-08-13** from two independent live sources, neither of them a config file, and
|
||||
**updated 2026-08-14** when the three singleton services were cloned onto VQ04 (that update IS from
|
||||
the boxes' live NSSM config + health probes, so its rows are verified). Rows are marked with how
|
||||
each was established:
|
||||
|
||||
- **`scan`** — TCP port scan of both hosts, plus a `/health/ready` GET where one was served.
|
||||
- **`dash`** — read off the **QA Overview dashboard** (`vq03:5320`), which was already polling the
|
||||
QA fleet and reports each instance's endpoint, health entries and active/standby state. Stronger
|
||||
evidence than a scan for these rows: the dashboard is successfully talking to the service, not
|
||||
merely finding an open socket. These ports were never scanned by me.
|
||||
- **`svc` / `live`** — added 2026-08-14: read from the boxes' own service registry (NSSM
|
||||
`AppEnvironmentExtra` / `Win32_Service`) or exercised end-to-end during the VQ04 singleton setup.
|
||||
|
||||
| Port | VQ03 | VQ04 | How | Service |
|
||||
|---|---|---|---|---|
|
||||
@@ -372,21 +378,35 @@ connection strings and service names as unverified. Rows are marked with how eac
|
||||
| 8084 | ✅ | ✅ | dash | ScadaBridge **Site** health listener (no UI; `/` → 404) |
|
||||
| 8081 | ✅ | ✅ | scan | ScadaBridge Central Akka remoting |
|
||||
| 8082 / 8083 | ✅ | ✅ | scan | ScadaBridge Site Akka / gRPC control plane (h2c — rejects an HTTP/1.1 GET) |
|
||||
| 5120 / 5130 | ✅ | ✗ | scan | **MxAccessGateway** gRPC / dashboard |
|
||||
| 5220 / 5221 | ✅ | ✗ | scan | **HistorianGateway** dashboard / gRPC h2c |
|
||||
| 5320 | ✅ | ✗ | scan | **Overview** dashboard (the QA fleet's own single pane) |
|
||||
| 1433 | ✅ | ✗ | scan | SQL Server |
|
||||
| 2222 | ⚠️ | ⚠️ | scan | servecli SSH — **TCP-open but NOT serving SSH** (see below) |
|
||||
| 5120 / 5130 | ✅ | ✅ | scan | **MxAccessGateway** gRPC / dashboard (VQ04 added 2026-08-14, cloned from VQ03) |
|
||||
| 5220 / 5221 | ✅ | ✅ | scan | **HistorianGateway** dashboard / gRPC h2c (VQ04 added 2026-08-14) |
|
||||
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same 10-instance registry) |
|
||||
| 1433 | ✅ | ✗ | scan | SQL Server (**ZB Galaxy repo DB — VQ03 only**; VQ04's gateways point their Galaxy connection strings here) |
|
||||
| 3893 | ✅ | ✅ | svc | glauth LDAP (`ScadaBridge-LDAP` service, loopback-only, `dc=scadabridge,dc=local` — one per box, configs identical) |
|
||||
| **6150** | ✅ | ✅ | live | **psbridge** remote-admin bridge (`server.exe`, root jail `E:\psrem`) — **the supported access path**; see §2.1 |
|
||||
| 2222 | ⚠️ | ⚠️ | scan | servecli SSH — **retired; TCP-open but NOT serving SSH** (see below) |
|
||||
|
||||
**Live state at capture** (`8 instances across 4 applications`, all Up):
|
||||
**Live state at the 2026-08-13 capture** (`8 instances across 4 applications`, all Up; since
|
||||
2026-08-14 the registry is **10 instances** — MxAccessGateway and HistorianGateway run on both
|
||||
nodes, verified Healthy at setup):
|
||||
|
||||
| Application | VQ03 | VQ04 |
|
||||
|---|---|---|
|
||||
| OtOpcUa (cluster `MAIN`) | node-a — **Standby** (`active 503`) | node-b — **Active** (`active 200`) |
|
||||
| ScadaBridge `central` | central-a — **Active** | central-b — Standby |
|
||||
| ScadaBridge `site` | site-a — **Active** | site-b — Standby |
|
||||
| MxAccessGateway | Active | — |
|
||||
| HistorianGateway | Up | — |
|
||||
| MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) |
|
||||
| HistorianGateway | Up | Up (since 2026-08-14) |
|
||||
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 10-instance registry) |
|
||||
|
||||
**The 2026-08-14 VQ04 clone, in one paragraph:** MxAccessGw + HistorianGateway + ZbOverview were
|
||||
copied from VQ03 (same `E:\ApiInstall\...` layout, NSSM `AppEnvironmentExtra` config, LocalSystem)
|
||||
**including the API-key stores and peppers, so the same `mxgw_…`/`histgw_…` tokens verify on both
|
||||
boxes** — proven with positive + negative controls. The only config deltas: both apps' Galaxy SQL
|
||||
connection strings point at VQ03 (`Server=wonder-app-vq03…;Database=ZB` — VQ04 has no local SQL
|
||||
Server) and mxgw's alarm subscription stays `\\wonder-app-vq03\Galaxy!NAM`. **So VQ04's gateways
|
||||
depend on VQ03 for Galaxy browse + alarms**; Historian/Runtime-DB traffic goes to `wonder-sql-vq03`
|
||||
independently. Deploy record: `E:\ApiInstall\_DEPLOY-NOTES-20260814-singletons-added.txt` on VQ04.
|
||||
|
||||
Three things worth carrying forward:
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# VD03 NAM AD LDAP cutover — plan (NOT executed)
|
||||
|
||||
**Written 2026-08-13. Status: PLAN ONLY.** Swap every auth-bearing service on
|
||||
`wonder-app-vd03` from its current login posture (mostly login-disabled) to the real
|
||||
Zimmer NAM Active Directory, with the service-account password held as a
|
||||
`${secret:}` reference in each app's encrypted Secrets store — never in config.
|
||||
|
||||
Grounded in: [`ldap_setup.md`](../../ldap_setup.md) (directory facts + the 2026-08-13
|
||||
end-to-end verification via the HistorianGateway docker container),
|
||||
[`ldap_mapping.md`](../../ldap_mapping.md) (the `UG_WW_*` → role mapping, §4),
|
||||
and the wonder deploy records (memory: `wonder-upgrade-program`).
|
||||
|
||||
## Scope
|
||||
|
||||
| Service on VD03 | In scope | LDAP config surface | Login state today |
|
||||
|---|---|---|---|
|
||||
| OtOpcUa AdminUI | ✅ | `Security:Ldap` (+ `GroupToRole` subkey, merged with a DB overlay) | LDAP enabled; target = TBD in preflight (data plane is API-key, **not** LDAP — untouched) |
|
||||
| ScadaBridge (central UI) | ✅ | `ScadaBridge:Security:Ldap` in `appsettings.Central.json`; roles in the **`LdapGroupMapping` DB table**, not config | **Login DISABLED** (`DisableLogin=true` + `AllowOutsideDevelopment=true` — the two-key set) |
|
||||
| MxAccessGateway dashboard | ✅ | `MxGateway:Ldap` + `MxGateway:Dashboard:GroupToRole` (gRPC stays API-key, untouched) | LDAP enabled per 08-12 notes; target = TBD in preflight |
|
||||
| HistorianGateway dashboard | ✅ | `Ldap` (root section) + `Dashboard:GroupToRole` (gRPC stays API-key, untouched) | **Login DISABLED** (`Dashboard:DisableLogin=true`, deliberately open) |
|
||||
| Overview | ❌ exempt | — | No login **by design** (holds no credentials; references no Auth package) |
|
||||
|
||||
Out of scope: windev's OtOpcUa/mxgw instances, the QA pair (VQ03/VQ04 — do it as a
|
||||
follow-on once VD03 has soaked; QA is where the 2-node/failover behaviour actually gets
|
||||
exercised), and OtOpcUa's OPC UA data plane (API-key auth since Gitea #520).
|
||||
|
||||
> ⚠️ **ignitionoee (router): LDAP setup ON HOLD** — explicit decision 2026-08-13. Its role
|
||||
> vocabulary is not the canonical set (its admin role is literally `"Admin"`, not
|
||||
> `"Administrator"`), so no existing `UG_WW_*` group maps onto it — a NAM cutover there
|
||||
> would first require **minting new AD groups**. Do not wire its LDAP as part of this plan
|
||||
> or its follow-ons; it keeps its current auth until the groups exist and are decided.
|
||||
|
||||
## The target LDAP block (identical shape in all four apps)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Enabled": true,
|
||||
"Server": "uswap-vdc-nam1.nam.zimmer.com",
|
||||
"Port": 636,
|
||||
"Transport": "Ldaps",
|
||||
"AllowInsecure": false, // see TLS decision below
|
||||
"FallbackServers": [ "uswap-vdc-nam2.nam.zimmer.com:636" ],
|
||||
"SearchBase": "DC=nam,DC=zimmer,DC=com",
|
||||
"ServiceAccountDn": "svc_igntadmin@nam.zimmer.com",
|
||||
"ServiceAccountPassword": "${secret:nam-ldap-svc-password}",
|
||||
"UserNameAttribute": "sAMAccountName",
|
||||
"DisplayNameAttribute": "name",
|
||||
"GroupAttribute": "memberOf"
|
||||
}
|
||||
```
|
||||
|
||||
Facts already proven live (2026-08-13, HG docker → real NAM AD): the UPN bind as
|
||||
`svc_igntadmin` works (**not** `svc_igntnapp` — that is Ignition's account), the
|
||||
domain-root SearchBase works **on Auth 0.2.1 only**, `memberOf` DNs collapse to bare
|
||||
CNs so `GroupToRole` keys are plain `UG_WW_*` names, and `FallbackServers` walks both
|
||||
DCs on system-side failures. Users log in with the **bare sAMAccountName**.
|
||||
|
||||
## Hard prerequisite: every deployed build must carry Auth 0.2.1
|
||||
|
||||
**None of the four deployed builds has it** (pins landed 2026-08-13, after the last
|
||||
deploy round): OtOpcUa runs `c51c5998`, ScadaBridge `20f2ae05`, mxgw the 08-12 build
|
||||
(Auth 0.2.0), HG `e9c31da` (Auth **0.1.5**). On anything below 0.2.1 a domain-root
|
||||
SearchBase fails **every** login as `ServiceAccountBindFailed` (AD continuation
|
||||
referrals), and on 0.1.5 there is no `FallbackServers` at all. Narrowing SearchBase
|
||||
below the root is NOT an acceptable workaround — user objects span multiple OUs.
|
||||
|
||||
So **Phase 1 is a redeploy of all four apps at their current default-branch tips**
|
||||
(each already pins 0.2.1 and is pushed: OtOpcUa `08e33fc7`, ScadaBridge `d28824d9`,
|
||||
mxgw `1cb14d2`, HG `e42ff31`). This folds into the standing wonder-upgrade practice —
|
||||
HG is behind `main` anyway. Run the standard per-app preflight (migration-delta check,
|
||||
appsettings SHA capture, env backup) from the `wonder-upgrade-program` memory.
|
||||
|
||||
## Decisions to confirm before executing
|
||||
|
||||
1. **Service account.** Use `svc_igntadmin` (proven), or request a dedicated
|
||||
`svc_zbmomww` via ServiceNow first (the `UG_WW_*` groups are ticket-provisioned, so
|
||||
the process exists). Reusing the Ignition admin account couples our uptime to their
|
||||
rotations. **Recommended: start with `svc_igntadmin`, file the dedicated-account
|
||||
ticket in parallel, swap later by rotating one secret per store.**
|
||||
2. **Enabling login on services that are deliberately open.** ScadaBridge and HG run
|
||||
login-disabled on this box today, on purpose. This cutover flips both to
|
||||
authenticated. Confirm that is wanted on the DEV box (it is the point of the
|
||||
exercise, but it changes who can reach those dashboards).
|
||||
3. **Who gets in: VD03 = DEV tier ⇒ map only `_DEV` groups** (ldap_mapping §3 — a
|
||||
deployment maps only its own tier; mapping PROD groups here would make every DEV
|
||||
grant a cross-tier grant). Verify the intended users are actually members:
|
||||
`UG_WW_APP_ADMIN_DEV` (7), `UG_WW_HIST_ADMIN_DEV` (7), `UG_WW_APP_IDE_DEV` (6),
|
||||
`UG_WW_APP_IDE_RO_DEV` (2). If someone who needs access is in none, that is a
|
||||
ServiceNow group-membership request **before** cutover, not a mapping widen.
|
||||
(`BTMGR_RO` has no DEV group — omit.) Check with
|
||||
`ldapprobe --insecure --svc svc_igntadmin@nam.zimmer.com --self-bind <sAMAccountName>`.
|
||||
4. **TLS.** Try `AllowInsecure: false` first — the box is on the corporate network and
|
||||
Windows machine stores on managed boxes usually carry `zimmer-rootca` (installing it
|
||||
is sufficient; the only chain error observed is `UntrustedRoot`, and the RSA-1024
|
||||
hazard is OpenSSL-container-specific, not Windows). If the preflight cert check
|
||||
fails: install `zimmer-rootca` into LocalMachine\Root (preferred) or fall back to
|
||||
`AllowInsecure: true` (DEV-acceptable; transport still encrypted).
|
||||
5. **ScadaBridge plant-floor/operator rows** (`*_OPERATOR`, `SITE_ADMIN_*`,
|
||||
`SUPERVISOR`/`ENGINEER`): ldap_mapping flags these ⚠ DECIDE and they are not
|
||||
env-scoped. **Recommended: leave them out of this cutover** — seed only the
|
||||
platform rows (below) and add operator rows when a real site use-case lands.
|
||||
|
||||
## Phase 0 — preflight (read-only, no changes)
|
||||
|
||||
From the box (servecli :2222; note servecli never survives a reboot — restart it first
|
||||
if dead):
|
||||
|
||||
- **Network:** `Test-NetConnection uswap-vdc-nam1.nam.zimmer.com -Port 636` (and nam2).
|
||||
- **TLS trust:** from PowerShell, open an `SslStream` to nam1:636 (or
|
||||
`certutil -verify` the exported chain) to learn whether `zimmer-rootca` is trusted →
|
||||
settles decision 4.
|
||||
- **Service-account credential, once, without lockout risk** (bad binds count on both
|
||||
DCs per app attempt; policy is 5/15 min): a single bind test from the Mac —
|
||||
`LDAPTLS_REQCERT=never ldapsearch -H ldaps://uswap-vdc-nam1.nam.zimmer.com:636 -x -D
|
||||
svc_igntadmin@nam.zimmer.com -W -b "DC=nam,DC=zimmer,DC=com" -s base dn`.
|
||||
- **Capture current state per app:** SHA the live appsettings files; record the NSSM
|
||||
registry env (`Parameters\AppEnvironmentExtra` for mxgw/HG; the OtOpcUa/ScadaBridge
|
||||
service envs) — **check no `Ldap__*` keys lurk in service env, which would silently
|
||||
override the JSON edits**; record current `DisableLogin` posture.
|
||||
- **Secrets plumbing:** confirm the four stores + file-based KEKs are healthy (they
|
||||
were authored + round-trip-verified 2026-08-12; KEK backups at
|
||||
`E:\ApiInstall\_kek-backup-20260812T070057`). Stores live outside content roots
|
||||
(OtOpcUa `E:\ApiInstall\OtOpcUa-data\`, mxgw + HG under their `data\`,
|
||||
ScadaBridge per its overlays).
|
||||
|
||||
## Phase 1 — redeploy all four apps at Auth-0.2.1 tips
|
||||
|
||||
Standard wonder deploy recipe per app (memory: `wonder-app-vd03-mxgateway-deploy` +
|
||||
per-app records). Self-contained win-x64 publishes (box has no .NET 10 runtime).
|
||||
Expected deltas are small (each app's tip is its 08-12 deployed SHA + the 0.2.1 pin
|
||||
± UI-sweep commits for HG); still run the migration-delta check — HG's jump
|
||||
(`e9c31da → e42ff31`) carries the Theme 0.4.1/Health 0.3.0/Secrets 0.6.2 sweep, so
|
||||
give it the full preflight (its Secrets 0.6.2 store-path guard will validate the
|
||||
box's paths — they were fixed 08-12, so this should pass, but it is the guard that
|
||||
bit OtOpcUa last round).
|
||||
|
||||
**Gate:** all services healthy on their current (pre-LDAP-swap) config before touching
|
||||
auth. Do not combine the redeploy and the auth swap in one restart — two variables.
|
||||
|
||||
## Phase 2 — seed the secret into each app's store
|
||||
|
||||
Secret name: **`nam-ldap-svc-password`** (same name in every store, so the config
|
||||
reference is identical everywhere and rotation is a mechanical sweep).
|
||||
|
||||
Use the on-box CLI `E:\ApiInstall\_tools\secret` (0.6.2, self-contained). **Trap
|
||||
(proven 08-12): bare CLI verbs read the CLI's own env, NOT the target app's config** —
|
||||
target each store per-invocation:
|
||||
|
||||
```powershell
|
||||
$env:Secrets__SqlitePath = "<that app's store path>"
|
||||
$env:Secrets__MasterKey__Source = "File"
|
||||
$env:Secrets__MasterKey__FilePath = "<that app's KEK file>"
|
||||
E:\ApiInstall\_tools\secret\secret.exe set nam-ldap-svc-password # prompts; never a literal arg
|
||||
```
|
||||
|
||||
Stores to seed (verify paths from the live NSSM/registry env captured in Phase 0):
|
||||
|
||||
| Store | Needed by |
|
||||
|---|---|
|
||||
| OtOpcUa (`E:\ApiInstall\OtOpcUa-data\...`) | AdminUI LDAP login |
|
||||
| ScadaBridge **central** | central UI LDAP login (the `${secret:}` ref lives in `appsettings.Central.json`) |
|
||||
| ScadaBridge **site** | not strictly needed (site does no LDAP) — seed anyway for symmetry; the two stores share one KEK but are separate stores |
|
||||
| MxGateway | dashboard LDAP login |
|
||||
| HistorianGateway | dashboard LDAP login |
|
||||
|
||||
Verify each with `secret.exe get nam-ldap-svc-password` (value round-trip) under the
|
||||
same per-invocation targeting. **Rotation note for the runbook:** one password now
|
||||
lives in 4–5 stores; a rotation is N `set` operations plus zero config changes.
|
||||
|
||||
## Phase 3 — config edits (one app at a time, restart, verify, then next)
|
||||
|
||||
Order: **HG → mxgw → OtOpcUa → ScadaBridge** (proven-pattern first, DB-mapping app
|
||||
last). Back up every file edited; edits go in the box's live overlay files, and the
|
||||
next deploy must re-apply them (add to the deploy-notes convention — the deploy
|
||||
process restores box appsettings, so once these edits ARE the box appsettings they
|
||||
survive; the risk is a fresh-install overwrite, same as every other box-local key).
|
||||
|
||||
### 3a. HistorianGateway
|
||||
- `Ldap` section ← the target block above.
|
||||
- `Dashboard:DisableLogin` → `false`; `Dashboard:GroupToRole` ←
|
||||
`UG_WW_HIST_ADMIN_DEV: Administrator`, `UG_WW_APP_ADMIN_DEV: Administrator`,
|
||||
`UG_WW_APP_IDE_RO_DEV: Viewer`.
|
||||
- Restart `ZbHistorianGateway`; run the Phase 4 checks.
|
||||
|
||||
### 3b. MxAccessGateway
|
||||
- `MxGateway:Ldap` ← target block.
|
||||
- `MxGateway:Dashboard:GroupToRole` ← `UG_WW_APP_ADMIN_DEV: Administrator`,
|
||||
`UG_WW_APP_IDE_DEV: Viewer`, `UG_WW_APP_IDE_RO_DEV: Viewer` (only two roles exist;
|
||||
Designer collapses to Viewer per ldap_mapping §4).
|
||||
- Restart; verify. (gRPC/API-key surface untouched — do NOT disturb the restored
|
||||
API-key store in `data\`.)
|
||||
|
||||
### 3c. OtOpcUa (AdminUI only)
|
||||
- `Security:Ldap` ← target block (keep any existing `GroupToRole` DB-overlay rows in
|
||||
mind — config merges with the DB overlay; check the RoleGrants page after).
|
||||
- `Security:Ldap:GroupToRole` ← `UG_WW_APP_ADMIN_DEV: Administrator`,
|
||||
`UG_WW_APP_IDE_DEV: Designer`, `UG_WW_APP_IDE_RO_DEV: Viewer`,
|
||||
`UG_WW_HIST_ADMIN_DEV: Viewer` (historization targets are authored here — see
|
||||
ldap_mapping §4 rationale).
|
||||
- Restart the AdminUI/central service. Driver-only nodes are unaffected (no LDAP).
|
||||
- The OPC UA data plane keeps its `otua_` API keys — no consumer impact.
|
||||
|
||||
### 3d. ScadaBridge (central)
|
||||
- `appsettings.Central.json`: `ScadaBridge:Security:Ldap` ← target block; **remove the
|
||||
two disable-login keys** (`DisableLogin` + `AllowOutsideDevelopment`) — note the trap:
|
||||
removing only `AllowOutsideDevelopment` while leaving `DisableLogin=true`
|
||||
**crash-loops** (`DisableLoginGuard`); remove both.
|
||||
- Role rows in the central DB (`LdapGroupMapping` — the only app whose mapping is data,
|
||||
and the only one that can grant two roles per group):
|
||||
```sql
|
||||
INSERT INTO LdapGroupMapping (LdapGroupName, Role) VALUES
|
||||
('UG_WW_APP_ADMIN_DEV', 'Administrator'),
|
||||
('UG_WW_APP_IDE_DEV', 'Designer'),
|
||||
('UG_WW_APP_IDE_DEV', 'Deployer'), -- two rows on purpose (§2.1)
|
||||
('UG_WW_APP_IDE_RO_DEV', 'Viewer');
|
||||
```
|
||||
(Operator/site rows deliberately deferred — decision 5.)
|
||||
- Restart central; site service untouched.
|
||||
|
||||
## Phase 4 — verification (per app, immediately after its restart)
|
||||
|
||||
The log **failure stage** is the diagnostic (proven live, recorded in ldap_setup.md):
|
||||
|
||||
1. **Negative control first** (bogus user + bogus password): expect a clean
|
||||
"invalid/not authorized" with **zero** `WRN ... failed system-side` lines. A
|
||||
`service-bind`-stage WRN = wrong secret value; a `search`-stage WRN = empty
|
||||
password (the `${secret:}` ref did not resolve — check the store/KEK targeting);
|
||||
connect-stage = network/TLS.
|
||||
2. **Real login** with an account in a mapped `_DEV` group → correct role visible in
|
||||
the UI. And one with **no** mapped group → denied (fail-closed zero-group rule).
|
||||
3. **`${secret:}` positive control:** the value must come from the store, not residue —
|
||||
e.g. confirm no `Ldap__ServiceAccountPassword` in service env, and that
|
||||
`secret.exe get` matches what auth uses (a wrong-value canary set+revert is the
|
||||
strong form).
|
||||
4. **Failover spot-check (once, on HG):** block/blackhole nam1:636 briefly →
|
||||
login still works, WRN for nam1 + `sticky preference moved` to nam2; restore.
|
||||
Do not drill on every app — it is the same library and it doubles bad-bind
|
||||
exposure against lockout policy if anything is misconfigured.
|
||||
5. Mind the lockout arithmetic throughout: every failed app attempt = a failed bind
|
||||
on **both** DCs (endpoint walk). Stop and diagnose after 2 failures, don't retry.
|
||||
|
||||
## Rollback
|
||||
|
||||
Per app, in reverse: restore the backed-up appsettings (which restores the previous
|
||||
login posture, including ScadaBridge's two-key disable and HG's `DisableLogin=true`),
|
||||
restart. The seeded secrets are inert once nothing references them — leave them.
|
||||
ScadaBridge: `DELETE FROM LdapGroupMapping WHERE LdapGroupName LIKE 'UG_WW_%'` only if
|
||||
a clean slate is wanted; unreferenced rows grant nothing while login is disabled.
|
||||
Phase 1's redeploys are independent and should NOT be rolled back for an auth problem.
|
||||
|
||||
## Follow-ups this creates
|
||||
|
||||
- QA pair (VQ03/VQ04) cutover with `_QA` groups — the same plan, plus real
|
||||
failover/pair semantics; QA is where `FallbackServers` + sticky actually matter.
|
||||
- Dedicated service account (decision 1) → rotate `nam-ldap-svc-password` in N stores.
|
||||
- Revert the Mac's HG docker container from its temporary NAM flip back to the
|
||||
glauth-redundant pair (uncommitted working-tree change, independent of this plan).
|
||||
- ldap_mapping.md is still untracked in scadaproj — commit it when its DECIDE items
|
||||
settle; this plan depends on its §4 tables.
|
||||
- ignitionoee LDAP: **held** (see the Scope flag) until new `UG_WW_*` groups matching its
|
||||
role vocabulary are minted and mapped — a group-creation request, not a config task.
|
||||
+35
-2
@@ -142,6 +142,40 @@ Two refinements to the TLS notes above, from the actual chain a .NET client sees
|
||||
Cert observed on nam1: `CN=USWAP-VDC-NAM1.nam.zimmer.com`, SAN matches, sha256RSA,
|
||||
valid 2026-03-20 → 2027-03-20, chain `leaf ← zimmer-issueca1 ← zimmer-rootca`.
|
||||
|
||||
### End-to-end app login verified 2026-08-13 (HistorianGateway docker → real NAM AD)
|
||||
|
||||
A real ZB.MOM.WW app authenticated a real user against the production directory for the
|
||||
first time: the local HistorianGateway container (Auth `0.2.1`) with its dashboard LDAP
|
||||
temporarily flipped from the glauth-redundant pair to the two NAM DCs — LDAPS 636,
|
||||
`AllowInsecure` (the container trust store lacks `zimmer-rootca`), domain-root
|
||||
`SearchBase`, `sAMAccountName` login, `memberOf` → bare-CN `UG_WW_*` keys in
|
||||
`Dashboard:GroupToRole`. Login succeeded and the group mapping resolved to a dashboard
|
||||
role. The flip is an **uncommitted working-tree change** in
|
||||
`HistorianGateway/docker/docker-compose.yml` (revert: `git checkout -- docker/docker-compose.yml`
|
||||
+ remove the `Ldap__ServiceAccountPassword` line from `histgw.secrets.env`).
|
||||
|
||||
Three findings worth keeping:
|
||||
|
||||
- **The service account for our use is `svc_igntadmin@nam.zimmer.com` — not
|
||||
`svc_igntnapp`.** `svc_igntnapp` is *Ignition's* account (the "Bind identity" section
|
||||
above describes Ignition's config); binding it with our held password fails
|
||||
`data 52e` (bad credential). ldapprobe's built-in default is `svc_igntnapp` — pass
|
||||
`--svc svc_igntadmin@nam.zimmer.com` when probing with the held password.
|
||||
- **The failover log's failure *stage* is a precise diagnostic.** Seen live:
|
||||
`failed system-side at search` on both DCs = the service password is **empty**
|
||||
(AD treats a passwordless simple bind as anonymous, accepts it, then refuses the
|
||||
search); `failed system-side at service-bind` = the credential is **wrong** (52e);
|
||||
a clean "invalid or not authorized" with **no** WRN lines = the directory answered
|
||||
and the user is unknown or matched zero mapped groups (fail-closed).
|
||||
- **Failed logins double against lockout policy** (5 attempts / 15 min): a bad
|
||||
*service* credential costs one failed bind on **each** DC per attempt, because the
|
||||
endpoint walk retries system-side failures on the fallback. Validate a candidate
|
||||
service password with a single `ldapsearch -H ldaps://uswap-vdc-nam1… -x -D <upn> -W`
|
||||
before letting an app loop on it.
|
||||
|
||||
The Debian-based .NET 10 container handshook LDAPS 636 to both DCs without the
|
||||
`SECLEVEL` workaround mentioned above being needed.
|
||||
|
||||
### `UG_WW_*` — an existing Wonderware group convention (observed 2026-08-12)
|
||||
|
||||
**This partly overturns the "Groups / roles" section above.** That section is accurate
|
||||
@@ -244,8 +278,7 @@ Three gaps to settle before this is a design rather than a sketch:
|
||||
`0.2.1`** and restore-verified from a scratch consumer (the assembly restored *from the
|
||||
feed* carries the fix; `0.2.0` does not). **ADOPTED by all four apps 2026-08-13** —
|
||||
OtOpcUa `master @ 08e33fc7`, ScadaBridge `main @ d28824d9`, MxAccessGateway
|
||||
`main @ 1cb14d2`, HistorianGateway `e42ff31` (on its `dashboard-ui-sweep-theme-0.4.1`
|
||||
branch) — each built + tested green; commits are local, not yet pushed. `FallbackServers`
|
||||
`main @ 1cb14d2`, HistorianGateway `e42ff31` (merged to `main`) — each built + tested green, all pushed to origin. `FallbackServers`
|
||||
failover was live-gated the same day against the local redundant GLAuth pair
|
||||
([`infra/glauth-redundant/`](infra/glauth-redundant/)): HistorianGateway dashboard login
|
||||
drill PASS (sticky failover both directions).
|
||||
|
||||
Reference in New Issue
Block a user