feat(cdc): native SQLite CDC source (db.cdc.sqlite)#351
Conversation
Why: The runtime could stream row changes from Postgres (db.cdc.postgres) but had no equivalent for SQLite. SQLite has no logical-replication slot, so the native mechanism is the preupdate hook, which observes row changes on the connection the runtime writes through. What: Adds a db.cdc.sqlite registry kind backed by a supervised Source that installs SQLite preupdate + commit/rollback hooks on the target db.sql.sqlite pool's writer connection and emits insert/update/delete (plus a gap-free snapshot bootstrap) through the existing, engine-agnostic cdc Lua module (cdc.stream/list_sources/source). How: - service/sql: a build-tagged hook seam (sqlite_preupdate_hook) registers a ConnectHook-enabled driver and installs/clears hooks on a raw *sqlite3.SQLiteConn; the factory selects the driver transparently. - service/cdc/sqlite: the Source buffers preupdate rows per transaction and flushes atomically on commit via a bounded handoff to a drain goroutine. Column names/affinity resolve over a dedicated read-only connection, and checkpoints write through a separate plain-driver connection, so the writer-blocking commit hook can never deadlock against schema resolution or checkpoint writes. A laggard subscriber is closed rather than allowed to stall the writer. Durable snapshot/offset state lives in wippy_cdc_offsets in the source DB. - api/service/cdc: db.cdc.sqlite kind, SQLiteConfig, and a composite inspector/streamer so the Lua module observes both engines. - boot: kind-specific listeners (db.cdc.postgres + db.cdc.sqlite) feed the composite. Capture is in-process and live-only: changes made while the runtime is down, or by an external writer, are not captured. The checkpoint exists for snapshot-gating and idempotent dedupe, not replay. Building requires the sqlite_preupdate_hook tag (added to the Makefile); without it the source fails loudly instead of silently capturing nothing.
Why: PR #351 bolted SQLite CDC specifics onto the generalized service/sql driver (a build-tagged preupdate-hook file, a mutable driver-name global, and CDC-only errors), and the package dispatched engines through kind switches. Adding a database therefore meant editing core dispatch code, and the generalized driver carried engine- and CDC-specific knowledge it should not have. What: service/sql core now exposes only two public seams, RegisterEngine and RegisterDriver, and dispatches purely via engineFor(kind); the manager, factory, and ConnPool.UpdateConfig no longer switch on engine kind. An EngineConfig contract lets the generic create/update lifecycle validate and read lifecycle settings without knowing the concrete type. Built-in engines move into self-registering sub-packages that use only the public API: engine/standard (Postgres and MySQL, each with its own DSN builder, removing the buildDSN/getDriver kind switch), engine/sqlite (DSN, WAL, single-writer tuning), and engine/all (blank-imports the built-ins). Boot blank-imports engine/all as the single wiring point. The database/sql driver override is applied centrally in createPool, so engines stay override-agnostic. All SQLite CDC hook code (custom sqlite3_wippy driver, sink registry, preupdate scan, install/clear-on-raw, CDC errors) moves to service/cdc/sqlite/hook.go and registers its driver through RegisterDriver, leaving service/sql with zero CDC knowledge. Adding a database is now a new self-registering package that touches no existing core file.
|
Still in a pipeline, since it's not in a rush, i'll keep it there for some time. |
# Conflicts: # boot/components/service/storage/cdc.go # service/sql/conn.go # service/sql/conn_test.go # service/sql/factory.go # service/sql/factory_test.go # service/sql/manager.go # service/sql/manager_test.go # test.sh
Address the #351 review: the source claimed stronger CDC, snapshot, checkpoint and cursor semantics than it provided. Redesign to be honest and safe, keeping the preupdate hook + row-decode work. - Single capture owner per database via a token registry keyed by the canonical file: a second source is refused, pre-existing hooks are not clobbered, and release is token-guarded so a stopping source never clears the new owner's hooks on the shared single writer connection. - Per-consumer snapshot bootstrap with a real consistency fence (brief writer-connection fence before latching a read transaction), streamed on a helper goroutine; late subscribers get a consistent image then live changes. snapshot/error events bypass a consumer's op filter. - Bounded and non-blocking: the commit hook never blocks; queued transactions and per-transaction rows/bytes are bounded. On overflow the source faults and emits one terminal op=error gap event while the application commit still succeeds. Laggard subscribers get a terminal error and are dropped, never backpressuring the writer. - Session-scoped {epoch, sequence} cursor; drop the wippy_cdc_offsets table and all durable-checkpoint code (zero schema intrusion). - Capture the SQLite database name (schema); writes to temp/attached databases are not reported as main. Invalidate column metadata on PRAGMA schema_version change, with a defensive column-count guard. - Fix a Start/run race on the status channel surfaced by the supervisor path under -race. - Surface engine/file/db_resource/epoch/faulted and the snapshot option and error field through the Lua cdc module.
…urrent-write snapshot coverage - Unit: schemaAllowed/normalizeSchema/opString/approxRowSize and the control-event (snapshot/error) filter-bypass in subscription.matches. - Integration: per-subscriber table filtering fan-out; snapshot covering multiple tables; snapshot with concurrent live writes observes every row with no gap (convergence guarantee).
wolfy-j
left a comment
There was a problem hiding this comment.
Blocking correctness findings in snapshot mode:\n\n1. Subscribe registers the subscriber for live events before fenceAndBegin establishes the snapshot. Any commit in that window is both buffered as live and included in the snapshot, so the consumer receives duplicates after the snapshot phase. TestIntegrationSnapshotWithConcurrentWritesNoGap hides this by storing IDs in a set and never asserting exactly-once delivery. The API needs an explicit barrier/cursor protocol (or documented/tested at-least-once semantics with usable dedupe identity) before claiming “consistent image, then live changes, with no gaps.”\n2. A snapshot subscription made before Source.Start silently calls finishSnapshot() and continues without any snapshot. The manager exposes the source before asynchronous supervisor startup/update completes, so this is reachable, not theoretical. It must wait/fail explicitly; silently degrading semantics is not acceptable.\n\nThe preupdate hook, bounded writer path, and ownership token work are thoughtful, but snapshot handoff is the central CDC contract and currently is not sound.
Why
The runtime streams Postgres row changes via
db.cdc.postgres(#323) but had no SQLite equivalent. SQLite has no logical-replication slot, so the native mechanism is the preupdate hook, which observes row changes on the connection the runtime writes through.This is live, in-process observation — not durable CDC. Changes made while the runtime is down, or by an external process writing the file, are not captured. Cursors are session-scoped
{epoch, sequence}, not a replayable log position. There is zero schema intrusion: no tables are created in the application database.What
Adds a
db.cdc.sqliteregistry kind backed by a supervisedSourcethat installs SQLite preupdate + commit/rollback hooks on the targetdb.sql.sqlitepool's writer connection and emitsinsert/update/delete(plus an optional per-consumersnapshotbootstrap) through the existing, engine-agnosticcdcLua module (cdc.stream/list_sources/source).Update/Stophand off ownership cleanly (release is token-guarded; a stale owner never clears the new owner's hooks on the shared single writer connection). TheConnectHookre-binds the current owner acrossConnMaxLifetimereconnects.snapshot=truegets a consistent point-in-time image of the allowed tables, then live changes, with no gaps. Consistency is guaranteed by briefly fencing on the single pool writer connection before latching a read transaction on a dedicated read-only connection; rows stream on a helper goroutine so other consumers are never stalled.snapshot/errorevents bypass a consumer's op filter.op="error"gap event to all subscribers — while the application commit always succeeds (the hook always returns 0). A laggard subscriber gets a terminal error and is dropped, never backpressuring the writer.Start()mints anepoch;lsnis a per-session monotonic sequence, documented as not a durable replay cursor. Nowippy_cdc_offsetstable, no durable checkpoint.schema); writes totemp/attached databases are not mis-reported asmain. Column metadata is invalidated whenPRAGMA schema_versionchanges (e.g.ALTER TABLE), with a defensive column-count guard.Layering:
service/sqlexposes a build-tagged driver seam (RegisterDriver); the SQLite pool owns the concrete driver and connection hooks, and the CDC source consumes them — it never reaches back through driver globals.api/service/cdcgains thedb.cdc.sqlitekind,SQLiteConfig, and a composite inspector/streamer so the Lua module observes both engines. The SQL-engine registry refactor is included here (the CDC driver seam depends on it).Limitation (by design)
Capture is in-process and live-only. This is the trade for zero schema intrusion and lowest overhead. Durable snapshot/restart semantics would require a runtime-managed outbox with consumer acknowledgements, or trigger/outbox tables (schema intrusion) — out of scope for v1.
Portability
Requires the
sqlite_preupdate_hookbuild tag (a compile-time SQLite feature) and CGO (themattn/go-sqlite3driver). Without the tag the source fails loudly instead of silently capturing nothing. WAL mode has same-host / networked-filesystem constraints (a single machine must own the-wal/-shm); this matches howdb.sql.sqlitealready runs.Setup
Build with the tag (wired into the Makefile):
make build-wippy-local.Lua:
Testing
make lint(tagsrace,sqlite_preupdate_hook): 0 issues. Cross-platform CI green (Linux + Windows).-race), covering every blocker:Stopcleans up even when the caller context is already cancelled (new source can then claim).ALTER TABLEmid-stream yields correct column names; writes to a temp database are not reported asmain.snapshot/error) bypass op/table filters.schemaAllowed,normalizeSchema,opString,approxRowSize.TestLuaSeesRealRunningSQLiteSourceAndItsChanges): registry entry → supervisor auto-start →Source.Start→ preupdate hook → drain → Luacdc.stream→ dispatcher → subscribe → channel receive, assertingop/source/schema/table/afteron a real WAL SQLite file.