Skip to content

feat(turso): experimental CDC tail/apply API for external replication - #10

Merged
luthermonson merged 4 commits into
mainfrom
feat/turso-cdc
Jul 15, 2026
Merged

feat(turso): experimental CDC tail/apply API for external replication#10
luthermonson merged 4 commits into
mainfrom
feat/turso-cdc

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

Summary

Companion to ephpm Phase 2 (roadmap) — adds the CDC tail/apply API ephpm's replication layer needs from litewire. Experimental, additive, no changes to the Backend/BackendConn traits, no consumer other than ephpm's opt-in [db.sqlite.replication] cdc_experimental = true knob.

Headline empirical finding: Turso 0.7.0 CDC captures DDL. CREATE TABLE, CREATE INDEX, ALTER TABLE ADD COLUMN, DROP TABLE all appear in the same turso_cdc stream as row DML, encoded as mutations on sqlite_schema. The after-image's sql column carries the DDL text — the applier re-executes it. This means the replication design is a single ordered stream (no schema-sync side channel), which is a strictly simpler architecture than Phase 1 anticipated.

What lands

  • Turso::raw_connection() — bare turso::Connection for enabling CDC pragma and tailing.
  • cdc::enable_cdc() / cdc::CdcTailer / cdc::apply_batch() / cdc::decode_sqlite_record().
  • The tailer holds back partial transactions (waits for the v2 COMMIT delimiter) — replicas never see uncommitted state.
  • The applier is exactly-once by construction: __litewire_cdc_watermark advances in the same replica transaction as the replayed rows; commit_change_id <= current_watermark short-circuits to a no-op.
  • DML replay decodes the SQLite record format (stable, 20-year-old format, ~150 lines of Rust with unit tests) rather than depending on turso_core's private record API.

Test plan

  • cargo test -p litewire-turso — 25/25 pass on turso = "=0.7.0":
    • Base backend suite (10) — unchanged
    • tests/litewire_basics.rs (2) — unchanged
    • tests/cdc_ddl_capture.rs (7) — CDC schema version, DDL capture for CREATE TABLE/INDEX/DROP TABLE/ALTER TABLE, INSERT+COMMIT delimiter, cross-txn ordering
    • tests/cdc_replication.rs (8) — full tail→apply pipeline between two independent file-backed factories: DDL replicates, DML INSERT/UPDATE/DELETE match primary, watermark monotonic, resume-from-watermark ships only new batches, apply is idempotent across double-replay, uncommitted transactions do not surface
  • cargo clippy -p litewire-turso --all-targets -- -D warnings clean
  • cargo fmt -p litewire-turso clean

Corrections to the Phase 1 design doc

Building the actual implementation surfaced three corrections in ephpm's docs/turso-phase2-cdc-design.md (updated in the companion ephpm PR):

  1. v2 column order is (change_id, change_time, change_txn_id, change_type, table_name, id, before, after, updates)change_txn_id is column 3, not column 9.
  2. change_type values are INSERT=1, UPDATE=0, DELETE=-1, COMMIT=2 (not 0=DELETE, 1=INSERT).
  3. turso_cdc_version.version is TEXT"v2", not integer 2.

Scope caveats

Documented in cdc.rs module docs:

  • Turso 0.7.0 does not support multi-process access to the same file — replicas run in separate processes with their own files, communicating only via CDC batches over ephpm's cluster data plane.
  • Bootstrap of a fresh replica (getting the pre-CDC data) is the caller's concern — snapshot file copy is the Phase 2 pattern.
  • turso_cdc retention/pruning is the caller's concern — the tailer reads but never deletes.

Companion to ephpm's Phase 2 (CDC-native SQLite replication). Adds a
`cdc` module on top of the existing `litewire-turso` Backend and the
`Turso::raw_connection()` seam callers need to enable capture on write
sessions.

New public API (all experimental, gated by the existing crate-level
experimental status — no default, no consumer other than ephpm's opt-in
`cdc_experimental` config knob):

- `Turso::raw_connection()` — opens a bare `turso::Connection` on the
  factory's database. Callers use this to (a) `PRAGMA
  capture_data_changes_conn('full')` on primary write sessions and
  (b) tail `turso_cdc` on either side.
- `cdc::enable_cdc(&conn)` — the pragma enablement, wrapped.
- `cdc::CdcTailer::new(factory, after_change_id).poll_batch() ->
  Option<TxnBatch>` — yields one **complete** transaction at a time
  (partial txns held back until the v2 COMMIT record appears).
- `cdc::apply_batch(replica_conn, &batch)` — exactly-once apply on a
  replica connection. Runs BEGIN IMMEDIATE, replays each row, advances
  a `__litewire_cdc_watermark` table in the same transaction. If
  `batch.commit_change_id() <= current_watermark`, apply is a no-op
  (idempotent across crash-and-retry).
- `cdc::decode_sqlite_record(&blob)` — public decoder for the stable
  SQLite record format, used internally to reconstruct column values
  from CDC before/after images without depending on `turso_core`'s
  private record API.

Empirical findings that shaped this API (tests/cdc_ddl_capture.rs):

- **DDL is captured in the same stream** — `CREATE TABLE`,
  `CREATE INDEX`, `ALTER TABLE ADD COLUMN`, `DROP TABLE` all emit rows
  with `table_name = "sqlite_schema"`. The `after`-image's `sql` column
  holds the CREATE text; the applier re-executes it as-is. This means
  no schema-sync side channel is needed.
- v2 `turso_cdc` column order is
  `(change_id, change_time, change_txn_id, change_type, table_name, id,
   before, after, updates)` — the design-doc guess had `change_txn_id`
  in the wrong slot.
- `change_type` values are `INSERT=1, UPDATE=0, DELETE=-1, COMMIT=2`.
- `turso_cdc_version.version` is TEXT `"v2"`, not an integer.

Test coverage (all pass on turso = "=0.7.0" as of this commit):

- `tests/cdc_ddl_capture.rs` (7 tests) — DDL and DML capture, schema
  version check, COMMIT-record delimiter behavior, cross-txn ordering.
- `tests/cdc_replication.rs` (8 tests) — full tail/apply loop between
  two independent file-backed factories: DDL replicates, DML
  INSERT/UPDATE/DELETE match primary, watermark advances monotonically,
  resume from persisted watermark ships only new batches, idempotency
  across double-apply, uncommitted transactions do not surface.

Scope caveats documented in cdc.rs module docs: multi-process file
locking (Turso 0.7.0 limitation), bootstrap of fresh replicas (caller's
concern — snapshot file copy), and retention/pruning of `turso_cdc`
rows (caller's concern; the tailer reads but never deletes).

The Backend/BackendConn traits are unchanged — `cdc` is a sidecar API
on the `Turso` factory, not a change to the litewire seam.
@luthermonson
luthermonson marked this pull request as ready for review July 15, 2026 04:20
@luthermonson
luthermonson merged commit 0d99ba6 into main Jul 15, 2026
3 checks passed
luthermonson added a commit to ephpm/ephpm that referenced this pull request Jul 20, 2026
…ngine)

Alternative to the sqld sidecar clustered path for `[db.sqlite] engine =
"turso"`, gated behind a new opt-in knob. **Strictly additive** —
default config unchanged, sqld remains the production clustered default
for `engine = "sqlite"`. Turso engine still marked EXPERIMENTAL/Beta.

`[db.sqlite.replication] cdc_experimental = true` (default `false`).
When combined with `engine = "turso"` and `[cluster] enabled = true`,
selects the Phase 2 CDC-native replication path instead of failing the
old startup error. Without the flag, that combination still errors and
now points at the knob.

`cdc_listen` (default `"0.0.0.0:5015"`) is the TCP address the primary
serves CDC batches on. Ignored when `cdc_experimental = false`.

Both fields follow add-config-knob discipline: read and acted upon in
this same PR, unit-tested in `validate_sqlite_engine` and the ephpm-config
suite, doc-commented on the struct, and enumerated in reference/config.md.

Each node opens two `Turso` factories against the same DB file (verified
safe in one process by
`litewire-turso/tests/multi_factory_same_file.rs`):

- **wire factory** — served to litewire. On the primary, opts every
  session into CDC via the new `TursoBuilder::enable_cdc_on_connect(true)`
  so writes coming in through the MySQL/PG/Hrana frontends are captured.
- **mgmt factory** — used by the primary tail loop (`CdcTailer` polling
  `turso_cdc` → `broadcast::Sender<Arc<TxnBatch>>`) or the replica apply
  loop (`apply_batch` keyed to `__litewire_cdc_watermark`).

Primary spawns a TCP subscriber server on `cdc_listen`; every subscriber
gets its own `broadcast::Receiver` and receives length-prefixed
JSON-framed batches (base64 for the SQLite record blobs — v1 chooses
debuggability over a few bytes). Replicas open a TCP connection with an
auto-reconnect loop and apply batches via `apply_batch`.

Failover reuses the existing `SqliteElection`; the `cdc_listen` address
is what gets advertised for lookup by the new-primary auto path.

**Turso 0.7.0 CDC captures DDL.** `CREATE TABLE`/`CREATE INDEX`/
`ALTER TABLE ADD COLUMN`/`DROP TABLE` all appear in the same
`turso_cdc` stream as row DML, encoded as mutations on `sqlite_schema`
where the `after`-image's `sql` column carries the CREATE text.
Verified by `litewire-turso/tests/cdc_ddl_capture.rs` (7 tests). This
finding is the *reason* the replication architecture is a single
ordered stream — no schema-sync side channel needed, WordPress/Laravel
runtime DDL flows through the normal path.

- ephpm-config: 79 tests pass (default + new-fields parse).
- ephpm-server unit tests: 189 pass — includes 3 new `turso_cdc` tests
  (wire-batch round-trip, frame codec, oversized-frame rejection) plus
  updated `validate_sqlite_engine` tests for the four
  (engine × clustered × cdc_experimental) combinations.
- ephpm-server integration test (`turso_cdc_e2e`): 2 tests pass:
  1. `two_node_cdc_replicates_ddl_and_dml_end_to_end` — two Turso
     engines in one process, a real TCP socket between them; primary
     runs CREATE TABLE + INSERT + INSERT + UPDATE + DELETE via the
     wire factory; replica converges to 1 row with the updated value
     within 5s. Watermark on replica > 0 confirms exactly-once apply.
  2. `replica_reconnect_does_not_double_apply` — after the replica
     task is aborted and a fresh one started, the watermark check
     causes already-applied batches to no-op; the row count stays at 5.
- clippy pedantic -D warnings: clean.
- nightly rustfmt: clean.

- Fresh-replica snapshot bootstrap (v1: operator seeds DB file).
- Persisted subscriber watermark across primary restart.
- CDC traffic encryption (v1: trusted-network or TLS-tunneled).
- `turso_cdc` retention pruning.
- 2-node podman/kind e2e running the ephpm binary against a real MySQL
  client (the in-process integration test proves the pipeline; the
  compose lift is test orchestration).
- Wire-frontend capture for sessions that skip the factory flag (v1
  gotcha: `raw_connection()` callers bypass CDC).

**ephpm/litewire#10** — feat/turso-cdc adds the `CdcTailer`,
`apply_batch`, `enable_cdc_on_connect`, and `TursoConnection`
re-export. `Cargo.toml` temporarily pins litewire to that branch head
(`677795f805ed`). **After litewire#10 merges: re-pin `litewire` to the
main merge commit and `cargo update -p litewire` before merging this
PR.**

Building the actual pipeline forced three corrections to the Phase 1
source-only read of the CDC schema:

1. v2 `turso_cdc` column order is
   `(change_id, change_time, change_txn_id, change_type, table_name, id,
   before, after, updates)` — `change_txn_id` is column 3, not column 9.
2. `change_type` values: `INSERT=1, UPDATE=0, DELETE=-1, COMMIT=2`.
3. `turso_cdc_version.version` is TEXT `"v2"`, not integer `2`.

We also do NOT adopt `turso_sync_engine` in v1: its coroutine execution
model doesn't compose with tokio, and its use of `turso_core` primitives
would require opening a second Database handle on the same file through
a completely different code path.
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.

1 participant