Skip to content

feat(cdc): native SQLite CDC source (db.cdc.sqlite)#351

Open
skhaz wants to merge 5 commits into
mainfrom
feat/sqlite-cdc
Open

feat(cdc): native SQLite CDC source (db.cdc.sqlite)#351
skhaz wants to merge 5 commits into
mainfrom
feat/sqlite-cdc

Conversation

@skhaz

@skhaz skhaz commented Jun 16, 2026

Copy link
Copy Markdown
Member

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.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 an optional per-consumer snapshot bootstrap) through the existing, engine-agnostic cdc Lua module (cdc.stream / list_sources / source).

  • Single capture owner per database. Ownership is a token registry keyed by the canonical file; a second source on the same DB is refused, pre-existing hooks are never clobbered, and Update/Stop hand off ownership cleanly (release is token-guarded; a stale owner never clears the new owner's hooks on the shared single writer connection). The ConnectHook re-binds the current owner across ConnMaxLifetime reconnects.
  • Per-consumer snapshot. A subscriber requesting snapshot=true gets 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/error events bypass a consumer's op filter.
  • Bounded, never stalls the writer. The commit hook never blocks: transactions are handed off non-blockingly, and both queued transactions and per-transaction rows/bytes are bounded. On overflow the source faults — it drops buffered data and emits one terminal 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.
  • Session-scoped cursor. Each Start() mints an epoch; lsn is a per-session monotonic sequence, documented as not a durable replay cursor. No wippy_cdc_offsets table, no durable checkpoint.
  • Correct identity. The SQLite database name is captured (schema); writes to temp/attached databases are not mis-reported as main. Column metadata is invalidated when PRAGMA schema_version changes (e.g. ALTER TABLE), with a defensive column-count guard.

Layering: service/sql exposes 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/cdc gains the db.cdc.sqlite kind, 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_hook build tag (a compile-time SQLite feature) and CGO (the mattn/go-sqlite3 driver). 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 how db.sql.sqlite already runs.

Setup

Build with the tag (wired into the Makefile): make build-wippy-local.

- { name: db,  kind: db.sql.sqlite, file: /path/app.db, lifecycle: { auto_start: true } }
- { name: cdc, kind: db.cdc.sqlite, db_resource: app:db, lifecycle: { auto_start: true } }

Lua:

local s = cdc.stream("app:cdc", { tables = {"users"}, ops = {"insert"}, snapshot = true })
local change = s:channel():receive()
-- { op, table, schema, before, after, source, lsn }  (op="error" carries a gap reason)

Testing

  • make lint (tags race,sqlite_preupdate_hook): 0 issues. Cross-platform CI green (Linux + Windows).
  • 19 new test functions + 6 benchmarks (-race), covering every blocker:
    • Snapshot: late subscriber gets a consistent image then live; op-filtered consumer still receives snapshot rows; snapshot covers multiple tables; snapshot with concurrent live writes observes every row with no gap (convergence); restart re-snapshots (no durable checkpoint).
    • Ownership: single-owner token registry (refusal + token-guarded release); second source on the same DB is refused; Stop cleans up even when the caller context is already cancelled (new source can then claim).
    • Overload: overflow faults and emits one terminal gap while the writer is not stalled; the application commit still succeeds and all rows are durably written; a subscriber joining a faulted source gets a terminal error.
    • Identity: ALTER TABLE mid-stream yields correct column names; writes to a temp database are not reported as main.
    • Fan-out: per-subscriber table filtering; control events (snapshot/error) bypass op/table filters.
    • Unit: schemaAllowed, normalizeSchema, opString, approxRowSize.
  • Full-stack live E2E (TestLuaSeesRealRunningSQLiteSourceAndItsChanges): registry entry → supervisor auto-start → Source.Start → preupdate hook → drain → Lua cdc.stream → dispatcher → subscribe → channel receive, asserting op/source/schema/table/after on a real WAL SQLite file.
  • Performance matrix (write-throughput + p50/p99/p999 commit latency): hooks disabled vs enabled-no-subscribers vs one subscriber vs large BLOB vs large transaction vs saturated subscriber. Capture adds ~1.5 µs p50 on tiny commits; a saturated (never-draining) subscriber does not stall the writer (≈ enabled-no-subscriber), and the CDC driver adds no steady-state overhead when no source is active.

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.
@skhaz
skhaz requested a review from wolfy-j June 16, 2026 18:33
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.
@wolfy-j

wolfy-j commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Still in a pipeline, since it's not in a rush, i'll keep it there for some time.

wolfy-j and others added 3 commits July 5, 2026 21:45
# 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 wolfy-j left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants