Skip to content

Commit ac91634

Browse files
authored
Complete Phase F: wire incremental, cross-engine, and CDC end-to-end (#6)
1 parent 95abeb4 commit ac91634

45 files changed

Lines changed: 4708 additions & 758 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- `mysql` and `mariadb` drivers as thin wrappers that inject the fork-specific binary names and declare capabilities honestly (`Parallel: false` — the dump tools are single-threaded). Registered via side-effect import in `internal/app/drivers.go`.
2323
- Integration suites for both engines run on the Phase D `RunDriverSuite` harness via testcontainers (`mysql:8.0`, `mariadb:11`).
2424
- CI installs the Postgres/MySQL/MariaDB client tools and runs the full integration suite on the Ubuntu runner (where Docker is available); unit tests continue to run on Linux/macOS/Windows.
25-
- Phase F advanced-transfer machinery (some CLI paths gated pending follow-up wiring — see the `docs/INCREMENTAL.md`, `docs/CROSS_ENGINE.md`, `docs/CDC.md` Status sections):
25+
- Phase F advanced transfer — all four modes work end-to-end (incremental, cross-engine, CDC, bounded streaming); live DB paths are integration-tested in CI. See `docs/INCREMENTAL.md`, `docs/CROSS_ENGINE.md`, `docs/CDC.md`.
2626
- **Working today:** every dump is prefixed with a 4 KB `SIPH` JSON envelope (type, base/parent IDs, WAL/binlog positions, checksum); `restore` resolves the base→incremental chain and applies it in order, with `--up-to <id>` to stop early (cycle + broken-chain detection). Sync now streams through a bounded `jobs.Stream` (default 64×1 MB) instead of `io.Pipe`, exposing a `FillPercent` backpressure metric and propagating backup failures to the restore side via `CloseErr` (no truncated dump committed as clean).
27-
- **Machinery in place, CLI gated (follow-up):** incremental backup capture (Postgres WAL replication slot + LSN; MySQL/MariaDB binlog position) — `backup --incremental` returns a clear "not yet wired" error; cross-engine type-mapping (canonical schema + JSONL emit/consume with per-engine identifier quoting and placeholders) — `sync --cross-engine` is capability-gated off until typed schema introspection exists; CDC continuous mode (state-file persistence + resume + capability gating) ships as a polling scaffold, not real logical-replication streaming.
28-
- Known follow-up gates before incremental backup ships to users: Postgres orphan-replication-slot cleanup, validation of the `pg_receivewal`/`mysqlbinlog` streaming invocations against live servers (compile-checked only here).
27+
- **Incremental backup wired end-to-end (`backup --incremental --base <id>`):** captures a **bounded** change set since the base dump's recorded end position, serialized as engine-neutral JSONL `CanonicalChange` records. Postgres bounds the pgoutput logical-decoding stream by `pg_current_wal_lsn()` captured at backup time; MySQL/MariaDB bound the binlog-tool decode by the current binlog file+offset. The incremental dump's envelope carries this capture's end position so the next incremental resumes exactly there. `restore` replays incremental links via `ApplyChange` (base links still restore natively). Postgres adds an orphan replication-slot sweep (`SweepOrphanSlots`) run before each capture — drops inactive `siphon_*` physical slots while preserving the persistent logical resume slot. `Incremental` capability is now `true` for all three drivers. Live-server behavior is integration-tested in CI (`wal_level=logical`); compile-checked locally.
28+
- **CDC continuous sync wired end-to-end (`siphon cdc <from> <to>` / `sync --continuous`):** unbounded `ChangeStreamer.StreamChanges``CanonicalTransfer.ApplyChange` on the target, with an initial schema+data snapshot→stream handoff on first run (consistent start position captured before the snapshot) and resume-from-saved-position on restart (stable per-(source,target) job ID). Works same-engine and cross-engine (changes are engine-neutral `CanonicalChange`s) and honors `--table` in both the initial snapshot and the streamed changes. Persists the streamer's delivered position on exit (no ahead-of-stream periodic checkpoint, which could resume past un-applied changes after a crash); at-least-once delivery is safe because `ApplyChange` is idempotent (INSERT upsert, UPDATE/DELETE by PK). ctx cancel is the normal clean stop. `CDC` capability is now `true` for all three drivers. Same-engine, cross-engine, and resume paths are integration-tested in CI.
29+
- **Cross-engine sync wired end-to-end (`sync --cross-engine`, e.g. Postgres → MySQL):** typed schema introspection via `driver.SchemaInspector` (`information_schema`/`pg_catalog`, incl. primary keys) builds a `CanonicalSchema`; the source emits canonical JSONL rows and the target re-creates tables (with primary keys) and inserts them, translating types via `MapToNative` — all with per-engine identifier quoting and parameterized values. Honors `--table`. Unmapped source column types now fail fast with an explicit error instead of silently coercing to `text` (which would corrupt binary/BLOB families). `CrossEngineSource`/`CrossEngineTarget` capabilities are now `true` for all three drivers. Scope is data + table structure + primary keys (secondary indexes, FKs, triggers, views, functions not translated). Integration-tested in CI (Postgres → MySQL); compile-checked locally.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_
1717
---
1818

1919
> [!WARNING]
20-
> **Pre-1.0 — active development.** Postgres, MySQL, and MariaDB backup/restore/sync/verify/inspect work end-to-end today (Phases B + E), and bare `siphon` opens an interactive multi-panel dashboard (Phase C). The driver layer is hardened with a shared cross-driver test harness, capability gating, and connection retry (Phase D). Incremental backups, cross-engine sync, and ops features are on the [roadmap](#roadmap). APIs, flags, and the on-disk dump format may change before 1.0. Track progress via the milestone tags (`phase-a`, `phase-b`, `phase-c`, `phase-d`, `phase-e`, …).
20+
> **Pre-1.0 — active development.** Postgres, MySQL, and MariaDB backup/restore/sync/verify/inspect work end-to-end today (Phases B + E), and bare `siphon` opens an interactive multi-panel dashboard (Phase C). The driver layer is hardened with a shared cross-driver test harness, capability gating, and connection retry (Phase D). Advanced transfer is complete (Phase F): incremental backup/restore, cross-engine sync, and CDC continuous replication all work end-to-end. Ops features (cloud storage, secret backends, retention, telemetry) are on the [roadmap](#roadmap). APIs, flags, and the on-disk dump format may change before 1.0. Track progress via the milestone tags (`phase-a` `phase-f`).
2121
2222
## Table of contents
2323

@@ -54,7 +54,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_
5454
| **C** — TUI dashboard | Multi-panel Bubble Tea dashboard (profiles · dumps · jobs) with live job progress, backup/restore modal forms, and snapshot tests | ✅ Complete |
5555
| **D** — Driver hardening | Shared cross-driver test harness (`RunDriverSuite`), capability-gating helper (`RequireCapability`), Postgres connection-probe retry, and a `docs/DRIVERS.md` contributor guide | ✅ Complete |
5656
| **E** — MySQL + MariaDB | Both drivers via a shared `_mysqlcommon` package (shared `Conn`, `mysqldump`/`mariadb-dump` backup, client-pipe restore), exercised by the Phase D `RunDriverSuite` harness | ✅ Complete |
57-
| **F** — Advanced transfer | Bounded-buffer streaming sync + chain-walking incremental restore work today; the dump envelope, incremental WAL/binlog capture, cross-engine type-mapping, and CDC state machinery are in place but their CLI paths (`backup --incremental`, `sync --cross-engine`, CDC) are gated pending follow-up wiring — see [docs/INCREMENTAL.md](docs/INCREMENTAL.md), [docs/CROSS_ENGINE.md](docs/CROSS_ENGINE.md), [docs/CDC.md](docs/CDC.md) | 🟡 Partial |
57+
| **F** — Advanced transfer | All four advanced-transfer modes work end-to-end: bounded-buffer streaming sync; **incremental** backup/restore (`backup --incremental --base <id>` captures a bounded change set via Postgres logical decoding / MySQL-MariaDB binlog, `restore` replays the base→incremental chain, Postgres orphan-slot sweep); **cross-engine** sync (`sync --cross-engine` — typed `SchemaInspector` introspection → canonical type-mapping, e.g. Postgres → MySQL); and **CDC** (`siphon cdc` / `sync --continuous` — unbounded change streaming with snapshot→stream handoff, resumable, same- and cross-engine). Live DB paths are integration-tested in CI — see [docs/INCREMENTAL.md](docs/INCREMENTAL.md), [docs/CROSS_ENGINE.md](docs/CROSS_ENGINE.md), [docs/CDC.md](docs/CDC.md) | ✅ Complete |
5858
| **G** — Ops features | Cloud storage, secret backends, profile groups + 2FA, team mode, audit log, retention, telemetry | ⏳ Planned |
5959
| **H** — Distribution | GoReleaser, Homebrew tap, Scoop bucket, install script, docs site | ⏳ Planned |
6060

@@ -222,7 +222,7 @@ Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md), keep
222222

223223
Adding a new database engine? See [docs/DRIVERS.md](docs/DRIVERS.md) for the driver contributor guide.
224224

225-
Concept docs (honest as-built status — several paths are documented follow-ups): [docs/INCREMENTAL.md](docs/INCREMENTAL.md) (incremental restore works; the backup path is a follow-up), [docs/CROSS_ENGINE.md](docs/CROSS_ENGINE.md) (type-map matrix; `--cross-engine` is gated off pending typed introspection), and [docs/CDC.md](docs/CDC.md) (scaffold only; not yet runnable).
225+
Concept docs: [docs/INCREMENTAL.md](docs/INCREMENTAL.md) (incremental backup + restore), [docs/CROSS_ENGINE.md](docs/CROSS_ENGINE.md) (cross-engine sync + the type-map matrix), and [docs/CDC.md](docs/CDC.md) (continuous CDC sync, same- and cross-engine). All three work end-to-end; live DB behavior is integration-tested in CI.
226226

227227
## License
228228

docs/CDC.md

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22

33
CDC ("continuous sync" / follow mode) keeps a target database in step with a
44
source by streaming changes as they happen, rather than re-running a full
5-
backup/restore. In siphon this is **scaffolding today** — the resume-state
6-
plumbing and capability gating are in place, but continuous streaming is a
7-
future deliverable and does not run.
5+
backup/restore. siphon streams the source's logical change stream and applies
6+
each change to the target continuously, until interrupted. It works
7+
**same-engine** (Postgres→Postgres, MySQL→MySQL) and **cross-engine**
8+
(Postgres→MySQL) alike, because changes are carried as engine-neutral
9+
`CanonicalChange`s and replayed natively on the target.
810

911
## Table of contents
1012

11-
- [What exists](#what-exists)
13+
- [How it works](#how-it-works)
1214
- [The CLI surface](#the-cli-surface)
1315
- [Status](#status)
1416
- [Resume state](#resume-state)
1517
- [Limitations](#limitations)
1618

17-
## What exists
19+
## How it works
1820

1921
`internal/app/cdc.go` holds:
2022

@@ -24,49 +26,60 @@ future deliverable and does not run.
2426
- **`saveCDCState` / `loadCDCState`** — JSON state files under a per-user
2527
directory resolved from `SIPHON_STATE_HOME`, then `XDG_STATE_HOME`, then
2628
`$HOME/.local/state/siphon/cdc`.
27-
- **`RunCDC`** — a capability-gated entry point. It resolves the source and
28-
target profiles, then requires both drivers to advertise `CapCDC`. Its body is
29-
a **polling scaffold**: a 10-second ticker that persists state each tick and
30-
resumes from a prior run's state on restart. It is **not** real change
31-
streaming.
29+
- **`RunCDC`** — the capability-gated entry point. Both drivers must advertise
30+
`CapCDC`. It connects source and target, then:
31+
1. **First run (no prior state):** captures the source's current stream
32+
position (`BasePositioner.CurrentPosition`), takes an initial schema+data
33+
snapshot via the canonical transfer pipe (`InspectSchema`
34+
`EmitCanonical``ConsumeCanonical`), persists the start position, then
35+
streams changes committed after it.
36+
2. **Restart (prior state exists):** resumes from the saved position, no
37+
snapshot.
38+
3. **Stream loop:** `ChangeStreamer.StreamChanges` (unbounded) emits each
39+
`CanonicalChange`; `RunCDC` applies it via `CanonicalTransfer.ApplyChange`
40+
on the target and persists the streamer's delivered position on exit.
41+
42+
The `job_id` is a stable hash of `(from, to)`, so re-running the same continuous
43+
sync resumes from the same state file.
3244

3345
`CDCState` save/load round-trip, the state-dir resolution, and the
34-
no-capability rejection are unit-tested (`internal/app/cdc_test.go`).
46+
no-capability rejection are unit-tested (`internal/app/cdc_test.go`). End-to-end
47+
streaming (same-engine, cross-engine, and resume) is covered by integration
48+
tests (`internal/app/cdc_integration_test.go`, `-tags integration`).
3549

3650
## The CLI surface
3751

38-
There is **no `siphon cdc` subcommand wired today.** `RunCDC` exists as an
39-
application function but is not registered as a Cobra command
40-
(`internal/cli/root.go` wires `backup`, `restore`, `sync`, `verify`, `inspect`,
41-
`profile`, `dumps`, `config`, `schedule`, `tunnel` — no `cdc`).
42-
43-
`sync --continuous` exposes the flag but does not run CDC; it returns a clear
44-
error pointing at the (not-yet-wired) `siphon cdc` follow-up:
52+
Two equivalent entry points:
4553

4654
```bash
55+
# Dedicated command
56+
siphon cdc <from> <to>
57+
siphon cdc --from pg-prod --to pg-replica
58+
59+
# sync follow mode (equivalent)
4760
siphon sync --from pg-prod --to pg-replica --continuous
48-
# Error: continuous CDC sync is not wired here; use `siphon cdc` (Phase F Task 10)
4961
```
5062

63+
Both stream continuously until interrupted. Press Ctrl-C to stop cleanly —
64+
ctx cancellation is the normal termination signal; the final position is
65+
persisted on exit so a later run resumes without a gap.
66+
5167
## Status
5268

5369
| Capability | Status |
5470
| --- | --- |
5571
| `CDCState` persistence (save/load) | ✅ Works (unit-tested) |
5672
| State-dir resolution (`SIPHON_STATE_HOME` / `XDG_STATE_HOME`) | ✅ Works (unit-tested) |
57-
| Resume from prior state on restart | ✅ Works (in the scaffold loop) |
58-
| Capability gating on `CapCDC` | ✅ Works |
59-
| `siphon cdc` CLI subcommand | ❌ Not wired (no Cobra command) |
60-
| Continuous change streaming | ⚠️ Scaffold only (polling tick; not real CDC) |
61-
62-
CDC does **not run today**. No driver declares `CapCDC` true, so `RunCDC` is
63-
rejected with `ErrDriverUnsupported`. Even if a driver enabled it, the loop is a
64-
polling scaffold, not logical-replication streaming.
73+
| Resume from prior state on restart | ✅ Works |
74+
| Capability gating on `CapCDC` | ✅ Works (true on postgres, mysql, mariadb) |
75+
| `siphon cdc` CLI subcommand | ✅ Wired |
76+
| `sync --continuous` follow mode | ✅ Routes to `RunCDC` |
77+
| Initial snapshot → stream handoff | ✅ Works |
78+
| Continuous change streaming (same + cross-engine) | ✅ Works |
6579

6680
## Resume state
6781

68-
When CDC streaming is built, `RunCDC` resumes from the last persisted position.
69-
State files live at:
82+
`RunCDC` resumes from the last persisted position. State files live at:
7083

7184
```text
7285
$SIPHON_STATE_HOME/cdc/<job-id>.state # if SIPHON_STATE_HOME is set
@@ -77,12 +90,25 @@ $HOME/.local/state/siphon/cdc/<job-id>.state # default
7790
Each file is JSON: source/target profiles plus the last applied position (LSN
7891
for Postgres, binlog file + offset for MySQL/MariaDB).
7992

93+
**Checkpoint/resume granularity is "since the streamer's last delivered
94+
position."** RunCDC persists the position the change streamer reports when the
95+
stream stops — a position tied to what was actually delivered, never ahead of
96+
it. There is deliberately **no** ahead-of-stream periodic checkpoint: writing
97+
the source's *current* WAL/binlog end mid-stream would, after a crash, resume
98+
past changes that were streamed but never applied — silent data loss. After a
99+
crash it resumes from the last persisted position and re-applies the tail. This
100+
is safe because delivery is **at-least-once** and `ApplyChange` is
101+
**idempotent**: INSERT is idempotent (upsert), and UPDATE/DELETE target by
102+
primary key. Re-applying the tail is therefore a no-op — no gaps, no duplicates.
103+
80104
## Limitations
81105

82-
- **Not enabled on any driver**`CapCDC` is `false` everywhere, so CDC is
83-
rejected up front.
84-
- **No real streaming** — the scaffold polls on a ticker. Real logical-
85-
replication streaming (`pglogrepl` for Postgres, binlog tailing for
86-
MySQL/MariaDB) is deferred to a future revision.
87-
- **No CLI entry point**`RunCDC` is internal application scaffolding, not a
88-
user-facing command yet.
106+
- **Postgres source requires `wal_level=logical`** and sufficient
107+
`max_replication_slots` / `max_wal_senders`; MySQL/MariaDB source requires
108+
row-based binlogging. These are the same prerequisites as incremental backup.
109+
- **At-least-once, not exactly-once** — see resume granularity above. Correct
110+
because apply is idempotent; a target with non-idempotent side effects
111+
(triggers, etc.) is out of scope.
112+
- **Snapshot consistency window** — the start position is captured before the
113+
snapshot, so changes committed during the snapshot are re-streamed and
114+
re-applied idempotently rather than lost.

0 commit comments

Comments
 (0)