Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
8.6 KiB
ZB.MOM.WW.LocalDb — design
Date: 2026-07-17 Status: Approved (brainstorming session, all sections user-validated)
A new shared library: a localized embedded SQLite database cache with optional bidirectional async replication with a second node via gRPC.
Decisions (user-validated)
| Question | Decision |
|---|---|
| Use case | Generic shared ZB.MOM.WW.* library any of the four apps can adopt; consumers define their own tables |
| Engine | Embedded local DB, Microsoft.Data.Sqlite as the default (and only v1) provider, behind an ILocalDbEngine seam so a libSQL provider can be added later without API change. (libSQL was evaluated: its only relevant wins here are encryption-at-rest and a future Turso-native sync path; its experimental .NET binding is not worth putting on the critical path.) |
| Replication | Our own protocol over gRPC between two app nodes; fully async; local writes never block on the peer. Optional — a node that never enables replication is just a fast local DB. |
| Conflict resolution | Last-writer-wins via hybrid logical clocks (HLC); deterministic convergence on both sides |
| API shape | Arbitrary consumer SQL + CDC — consumers own their tables and SQL; the lib change-captures registered tables |
| CDC mechanism | Triggers + single shared oplog (evaluated vs update hook / preupdate hook / session extension — triggers win on crash-consistency (same transaction, same fsync), non-bypassability, and engine portability; hook-based capture saves trigger VDBE execution but pays per-row P/Invoke marshaling, roughly a wash; the session extension is faster only because it skips the durable capture write, which a store-and-forward replicator cannot give up) |
| Replication scope | Opt-in per table (RegisterReplicated), PK required, validated at registration |
| Timestamps | All UTC — HLC physical component is UTC unix-milliseconds; every stored and wire timestamp is UTC; no local time anywhere in the lib |
| Name / conventions | ZB.MOM.WW.LocalDb; plain directory in scadaproj (not a nested repo); family conventions (Auth API-key peer auth, Telemetry meters, Configuration validators, TreatWarningsAsErrors) |
Packages
| Package | Contents |
|---|---|
ZB.MOM.WW.LocalDb |
Embedded DB host: open/create local DB file, consumer SQL surface (ILocalDb: ExecuteAsync / QueryAsync / transactions), RegisterReplicated("table") (PK validation + trigger install + oplog schema), HybridLogicalClock, AddZbLocalDb() DI |
ZB.MOM.WW.LocalDb.Replication |
gRPC sync engine: SyncService (passive side), SyncClient + BackgroundService (initiator side), watermarks, LWW apply, tombstone pruning, snapshot full-resync, AddZbLocalDbReplication() / MapZbLocalDbSync() |
ZB.MOM.WW.LocalDb.Contracts |
localdb_sync.v1 proto + generated types (packable; peer could be non-.NET later) |
Repo shape mirrors Secrets/Health: src/, tests/, ZB.MOM.WW.LocalDb.slnx,
Directory.Build.props, Directory.Packages.props; packed to the Gitea NuGet feed.
Data model (lib-owned schema)
One oplog per database (not per table) — preserves cross-table transaction ordering and keeps watermark logic trivial:
__localdb_oplog—(seq INTEGER PK AUTOINCREMENT, table_name, pk_json, row_json NULL, hlc, node_id, is_tombstone). Updates store the full new row; deletes store a tombstone (row_json = NULL). Full-row capture keeps LWW apply idempotent and makes snapshot resync trivial.__localdb_peer_state— the peer's acked watermark (last_acked_seq), last-seen HLC, sync status timestamps (one row; one peer).__localdb_meta—node_id(GUID minted on first open), persisted HLC high-water (restarts never go backward), lib schema version.- Per registered table: 3 AFTER triggers (INSERT / UPDATE / DELETE). Re-registering after a schema change regenerates them. Trigger bodies check a session-scoped
__localdb_applyingguard so replication applies do not re-capture.
Sync protocol (localdb_sync.v1, one bidirectional stream)
The initiator node (configured with the peer address) opens a long-lived Sync stream;
the passive node maps the gRPC service. Both directions flow on the one stream:
- Handshake — exchange
node_id, lib schema version, registered-table digests (name + PK-columns hash; mismatch → fail-closed typed error), and each side's last-acked watermark. - Delta streaming — each side pushes oplog batches above the peer's watermark (flush-interval + max-batch-size knobs); acks carry the applied watermark back, driving pruning.
- LWW apply — per incoming row, compare incoming HLC to the local row's latest oplog HLC; newer wins (
INSERT OR REPLACE/ delete), older is discarded but still acked. Applies are batched in one transaction; watermark advances in the same transaction (idempotent replay after crash). - Resync — a reconnecting peer whose watermark predates the pruned horizon gets a snapshot: full contents of registered tables streamed chunked, then deltas resume. Same stream, distinct message types.
Auth: ZB.MOM.WW.Auth.ApiKeys keyId/Bearer on the channel; TLS in prod, h2c in dev.
Error handling
- Fail-closed on schema mismatch at handshake; no partial sync.
- PK-less registration →
LocalDbRegistrationExceptionat startup, not sync time. - Peer down → capped exponential backoff; oplog growth bounded by size/age caps; when exceeded, mark peer as needing snapshot resync and prune anyway (disk safety over delta continuity).
- Apply failures → batch rolls back atomically, watermark does not advance; poison entries go to a dead-letter table + counter metric rather than wedging the stream (same honesty pattern as HistorianGateway store-forward).
- Clock skew → HLC absorbs it; configurable max-drift guard (warn, optionally fail-closed).
Observability
ZB.MOM.WW.Telemetry meters: localdb.oplog.depth, localdb.sync.lag, applied/conflict/
dead-letter counters. ZB.MOM.WW.Configuration validators on all options.
Performance envelope (estimates; benchmark spike in the plan)
- Local batched writes with capture: ~25k–100k rows/sec (roughly half of no-trigger SQLite; the dominant cost is the durable oplog insert, which any crash-safe design pays).
- Single-row autocommit: ~5k–20k writes/sec (commit-cost dominated; trigger overhead marginal).
- Replicated apply: ~10k–50k rows/sec batched; sustained ingest above the peer's apply rate grows lag, never blocks local writes.
- Steady-state replication lag: flush-interval-bound, typically 50–500 ms.
Testing
- Unit: HLC ordering/monotonicity (incl. restart persistence + skew), trigger SQL generation, LWW decision table, watermark/pruning, digest computation.
- Integration (offline): two real SQLite DBs in one process over an in-memory gRPC pipe — bidirectional convergence (concurrent writes → identical final state), delete/update races, offline accumulation → reconnect → convergence, prune-then-reconnect → snapshot resync.
- Property-style: random interleaved op sequences on both nodes → both DBs identical after quiesce.
- Family conventions:
TreatWarningsAsErrors,dotnet testgreen offline, no live dependencies.
Explicitly out of scope (v1)
- libSQL provider (seam exists; add later without API change)
- More than two nodes / mesh topologies
- Per-table pluggable conflict resolvers (LWW only)
- TTL/eviction (consumer concern via their own SQL)
Implemented — deltas from this design
Shipped deviations from the design above (all built + tested; see ZB.MOM.WW.LocalDb/README.md):
- Engine: Microsoft.Data.Sqlite is the default behind an engine seam; libSQL deferred (already noted above).
- PK-column updates: fully supported as OLD-pk tombstone + new-row insert (delete+insert), an upgrade from the original per-design limitation.
- Clock drift: an over-drift entry is per-entry dead-lettered (when
FailClosedOnDrift) rather than faulting the session — the session never dies from drift. - Snapshot trigger: each node computes its own
needs_snapshot(pruned-horizon / flag);HandshakeAck.snapshot_requiredis now informational, not the decision authority. onReadyAPI shape:AddZbLocalDb(..., Action<ILocalDb> onReady)for table creation +RegisterReplicated, instead of a registration-builder object.- Outbound path: a single-writer, two-lane design (unbounded control lane preempts a bounded data lane) rather than an undifferentiated write path.
- Maintenance: a dedicated
MaintenanceBackgroundService(both roles) enforces oplog caps + tombstone retention on an interval. - Snapshot sender returns the as-of oplog seq it covers, so the pump skips already-covered tail deltas.