Skip to content

Commit dc1b49f

Browse files
committed
fix: apply CodeRabbit review fixes for Phase F
- Apply post-review fixes to incremental/CDC/cross-engine data paths. - Stop advancing the PG decode position from keepalive ServerWALEnd: track server WAL end separately so a keepalive crossing the bound cannot end a bounded incremental before the changes at that LSN are decoded and emitted (silent data loss); the catch-up stop now uses the separate server position. - Remove the CDC ahead-of-stream periodic checkpoint: persisting the source's current cursor mid-stream could resume past un-applied changes after a crash. Checkpoint only the streamer's delivered position on exit; idempotent ApplyChange makes the replayed tail safe. - Honor --table in cross-engine sync and CDC (snapshot + streamed changes) via shared filterSchemaTables/tableAllowed helpers. - Surface mysqlbinlog tool failures: capture stderr, return non-zero exits that are not an intentional stop, and error when a bounded scan ends before reaching its stop position. - Distinguish SQL NULL from the string 'NULL' in the binlog parser via a typed colVal token; strip type comments only outside quoted strings. - Fail fast on unmapped Postgres/MySQL column types instead of silently coercing to text (would corrupt binary/BLOB families cross-engine). - Preflight incremental-replay support before a Clean base restore; validate base profile/driver compatibility and a non-empty position before an incremental backup. - Replay JSONL via json.Decoder to drop the 8 MiB Scanner line cap. - Guard empty column sets in BuildUpdateSQL/BuildDeleteSQL; require both endpoints in the cdc CLI; restrict the orphan-slot sweep to physical slots. - Update CDC docs and CHANGELOG to match the new checkpoint and table behavior.
1 parent 68d1858 commit dc1b49f

16 files changed

Lines changed: 414 additions & 176 deletions

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
- 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).
2727
- **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). Checkpoints state every 100 changes plus on clean exit; 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. `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.
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.

docs/CDC.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ each change to the target continuously, until interrupted. It works
3737
snapshot.
3838
3. **Stream loop:** `ChangeStreamer.StreamChanges` (unbounded) emits each
3939
`CanonicalChange`; `RunCDC` applies it via `CanonicalTransfer.ApplyChange`
40-
on the target and checkpoints state periodically.
40+
on the target and persists the streamer's delivered position on exit.
4141

4242
The `job_id` is a stable hash of `(from, to)`, so re-running the same continuous
4343
sync resumes from the same state file.
@@ -90,10 +90,14 @@ $HOME/.local/state/siphon/cdc/<job-id>.state # default
9090
Each file is JSON: source/target profiles plus the last applied position (LSN
9191
for Postgres, binlog file + offset for MySQL/MariaDB).
9292

93-
**Checkpoint/resume granularity is "since the last checkpoint."** RunCDC
94-
checkpoints every 100 applied changes plus on clean exit. After a crash it
95-
resumes from the last checkpoint and re-applies any changes that landed after
96-
it. This is safe because delivery is **at-least-once** and `ApplyChange` is
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
97101
**idempotent**: INSERT is idempotent (upsert), and UPDATE/DELETE target by
98102
primary key. Re-applying the tail is therefore a no-op — no gaps, no duplicates.
99103

internal/app/backup.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,32 @@ func runIncrementalBackup(ctx context.Context, d Deps, opt BackupOpts, resolved
237237
}
238238
}
239239

240+
// Reject a base that does not belong to this profile/driver: an incremental
241+
// chain is only meaningful against the same engine and source, and a
242+
// cross-driver base would produce changes the target cannot replay.
243+
if base.Driver != resolved.Driver || base.Profile != opt.Profile {
244+
return &errs.Error{
245+
Op: "app.backup.incremental",
246+
Code: errs.CodeUser,
247+
Cause: errs.ErrIncompatibleEngine,
248+
Hint: "base dump " + opt.BaseID + " was created for profile " + base.Profile + " (" + base.Driver + "), not " + opt.Profile + " (" + resolved.Driver + ")",
249+
}
250+
}
251+
240252
since, err := basePosition(d, opt.BaseID)
241253
if err != nil {
242254
return err
243255
}
256+
// A base with no recorded change-stream position cannot anchor an incremental;
257+
// capturing from a zero cursor would silently start at "now" and drop every
258+
// change committed since the base. Require a real position.
259+
if since.LSN == "" && since.BinlogFile == "" {
260+
return &errs.Error{
261+
Op: "app.backup.incremental",
262+
Code: errs.CodeUser,
263+
Hint: "base dump " + opt.BaseID + " has no recorded change-stream position; take a fresh full backup and use that as --base",
264+
}
265+
}
244266

245267
inc, ok := conn.(driver.IncrementalBackuper)
246268
if !ok {

internal/app/cdc.go

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,6 @@ func cdcJobID(from, to string) string {
9393
return "cdc-" + hex.EncodeToString(sum[:8])
9494
}
9595

96-
// cdcCheckpointEvery bounds how many applied changes pass between resume-state
97-
// checkpoints. At-least-once delivery plus idempotent ApplyChange makes coarse
98-
// checkpointing safe: on restart we resume from the last checkpoint and re-apply
99-
// any changes that landed after it, which is a no-op for INSERT (idempotent),
100-
// UPDATE, and DELETE (both by primary key).
101-
const cdcCheckpointEvery = 100
102-
10396
// RunCDC starts (or resumes) a continuous, unbounded sync: it tails the source's
10497
// logical change stream and applies each engine-neutral CanonicalChange to the
10598
// target. It works same-engine and cross-engine alike because CanonicalChange is
@@ -110,10 +103,11 @@ const cdcCheckpointEvery = 100
110103
// changes committed after that position. On a restart it resumes from the saved
111104
// position with no snapshot.
112105
//
113-
// Resume granularity is "since the last checkpoint": StreamChanges reports the
114-
// position reached after each applied change via the emit callback, and RunCDC
115-
// checkpoints every cdcCheckpointEvery changes plus on clean exit. Re-applying the
116-
// tail after a crash is safe (see cdcCheckpointEvery).
106+
// Resume granularity is "since the last clean exit": RunCDC persists the
107+
// streamer's returned final position when the stream stops (the position is tied
108+
// to what was actually delivered, never ahead of it). Re-applying the tail after
109+
// a crash is safe because ApplyChange is idempotent (INSERT upserts; UPDATE and
110+
// DELETE target by primary key).
117111
//
118112
// ctx cancellation is the normal stop signal (matching the bounded-stream
119113
// convention): on cancel RunCDC persists final state and returns nil; only a
@@ -187,7 +181,7 @@ func RunCDC(parent context.Context, d Deps, opt SyncOpts) (<-chan jobs.Event, st
187181
if err != nil {
188182
return err
189183
}
190-
if snapErr := cdcSnapshot(ctx, srcConn, dstXfer, src.Driver); snapErr != nil {
184+
if snapErr := cdcSnapshot(ctx, srcConn, dstXfer, src.Driver, opt.Tables); snapErr != nil {
191185
return snapErr
192186
}
193187
state.setPosition(from)
@@ -199,6 +193,12 @@ func RunCDC(parent context.Context, d Deps, opt SyncOpts) (<-chan jobs.Event, st
199193

200194
applied := 0
201195
emit2 := func(ch canonical.CanonicalChange) error {
196+
// Honor --table: the streamer surfaces changes for every table (the
197+
// source's publication/binlog is database-wide), so skip any change
198+
// outside the requested set before applying it to the target.
199+
if !tableAllowed(ch.Table, opt.Tables) {
200+
return nil
201+
}
202202
if applyErr := dstXfer.ApplyChange(ctx, ch); applyErr != nil {
203203
return applyErr
204204
}
@@ -207,12 +207,13 @@ func RunCDC(parent context.Context, d Deps, opt SyncOpts) (<-chan jobs.Event, st
207207
Phase: jobs.PhaseProgress,
208208
Message: fmt.Sprintf("applied %s on %s (%d total)", ch.Op, ch.Table, applied),
209209
})
210-
if applied%cdcCheckpointEvery == 0 {
211-
if pos, posErr := srcPositioner.CurrentPosition(ctx); posErr == nil {
212-
state.setPosition(pos)
213-
_ = saveCDCState(state)
214-
}
215-
}
210+
// No periodic mid-stream checkpoint here: srcPositioner.CurrentPosition
211+
// returns the source's CURRENT WAL/binlog end, which is ahead of the
212+
// change we just applied. Persisting it would, after a crash, resume
213+
// PAST changes that streamed but were never applied — silent data loss.
214+
// We checkpoint only the streamer's returned final position on exit
215+
// (below), which is tied to what was actually delivered; at-least-once
216+
// redelivery on resume is safe because ApplyChange is idempotent.
216217
return nil
217218
}
218219

@@ -239,7 +240,7 @@ func RunCDC(parent context.Context, d Deps, opt SyncOpts) (<-chan jobs.Event, st
239240
// the canonical transfer pipe (the same pattern as runCrossEngineSync). Both the
240241
// source's SchemaInspector+CanonicalTransfer and the target's CanonicalTransfer
241242
// are required.
242-
func cdcSnapshot(ctx context.Context, srcConn driver.Conn, dstXfer driver.CanonicalTransfer, srcDriverName string) error {
243+
func cdcSnapshot(ctx context.Context, srcConn driver.Conn, dstXfer driver.CanonicalTransfer, srcDriverName string, tables []string) error {
243244
srcInsp, ok := srcConn.(driver.SchemaInspector)
244245
if !ok {
245246
return cdcUnsupported(srcDriverName, "SchemaInspector")
@@ -253,6 +254,9 @@ func cdcSnapshot(ctx context.Context, srcConn driver.Conn, dstXfer driver.Canoni
253254
if err != nil {
254255
return err
255256
}
257+
// Snapshot only the requested tables; the streamed-change phase applies the
258+
// same filter in emit2.
259+
schema = filterSchemaTables(schema, tables)
256260

257261
stream := jobs.NewStream(64)
258262
errCh := make(chan error, 1)

internal/app/restore.go

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package app
22

33
import (
4-
"bufio"
54
"context"
65
"encoding/json"
6+
"errors"
77
"io"
88
"os"
99

@@ -58,6 +58,22 @@ func Restore(parent context.Context, d Deps, opt RestoreOpts) (<-chan jobs.Event
5858
}
5959
defer func() { _ = conn.Close() }()
6060

61+
// Preflight: if the chain contains any incremental link, the driver must
62+
// support change replay (CanonicalTransfer). Assert this BEFORE applying
63+
// the base — a Clean base restore wipes the target, so discovering the
64+
// unsupported-driver error only when we reach the first incremental link
65+
// would leave the target destroyed and the chain half-applied.
66+
if chainHasIncremental(chain) {
67+
if _, ok := conn.(driver.CanonicalTransfer); !ok {
68+
return &errs.Error{
69+
Op: "restore",
70+
Code: errs.CodeUser,
71+
Cause: errs.ErrDriverUnsupported,
72+
Hint: resolved.Driver + " cannot replay incremental change links",
73+
}
74+
}
75+
}
76+
6177
for i, m := range chain {
6278
emit(jobs.Event{Phase: jobs.PhaseProgress, Message: "applying " + m.ID})
6379
f, err := os.Open(d.Dumps.Path(m.ID))
@@ -120,28 +136,40 @@ func Restore(parent context.Context, d Deps, opt RestoreOpts) (<-chan jobs.Event
120136
})
121137
}
122138

139+
// chainHasIncremental reports whether any link in the resolved chain is an
140+
// incremental dump (one that carries a JSONL change body replayed via
141+
// ApplyChange). Incremental links record a non-empty ParentID in their metadata;
142+
// the chain root (a base or full dump) does not.
143+
func chainHasIncremental(chain []dumps.Meta) bool {
144+
for _, m := range chain {
145+
if m.ParentID != "" {
146+
return true
147+
}
148+
}
149+
return false
150+
}
151+
123152
// replayChanges decodes a JSONL stream of CanonicalChanges from r and applies
124-
// each to the target via ApplyChange, in order. A malformed line aborts the
153+
// each to the target via ApplyChange, in order. A malformed record aborts the
125154
// replay so a corrupt incremental never partially applies undetected.
155+
//
156+
// A json.Decoder streams successive JSON values directly off the reader, so —
157+
// unlike a bufio.Scanner — there is no per-record size ceiling that would reject
158+
// a valid change carrying a large row value as "corrupt".
126159
func replayChanges(ctx context.Context, applier driver.CanonicalTransfer, r io.Reader) error {
127-
sc := bufio.NewScanner(r)
128-
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
129-
for sc.Scan() {
130-
line := sc.Bytes()
131-
if len(line) == 0 {
132-
continue
133-
}
160+
dec := json.NewDecoder(r)
161+
for {
134162
var ch canonical.CanonicalChange
135-
if err := json.Unmarshal(line, &ch); err != nil {
163+
if err := dec.Decode(&ch); err != nil {
164+
if errors.Is(err, io.EOF) {
165+
break
166+
}
136167
return &errs.Error{Op: "restore.replay", Code: errs.CodeSystem, Cause: err, Hint: "incremental change body is corrupt"}
137168
}
138169
if err := applier.ApplyChange(ctx, ch); err != nil {
139170
return err
140171
}
141172
}
142-
if err := sc.Err(); err != nil {
143-
return &errs.Error{Op: "restore.replay", Code: errs.CodeSystem, Cause: err}
144-
}
145173
return nil
146174
}
147175

internal/app/sync.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package app
33
import (
44
"context"
55

6+
"github.com/nixrajput/siphon/internal/canonical"
67
"github.com/nixrajput/siphon/internal/driver"
78
"github.com/nixrajput/siphon/internal/errs"
89
"github.com/nixrajput/siphon/internal/jobs"
@@ -179,6 +180,10 @@ func runCrossEngineSync(parent context.Context, d Deps, opt SyncOpts) (<-chan jo
179180
if err != nil {
180181
return err
181182
}
183+
// Honor --table: native sync passes opt.Tables into Backup, so the
184+
// cross-engine path must filter the canonical schema the same way or it
185+
// would copy every table regardless of the requested subset.
186+
schema = filterSchemaTables(schema, opt.Tables)
182187

183188
stream := jobs.NewStream(64)
184189
errCh := make(chan error, 1)
@@ -200,3 +205,32 @@ func runCrossEngineSync(parent context.Context, d Deps, opt SyncOpts) (<-chan jo
200205
},
201206
})
202207
}
208+
209+
// tableAllowed reports whether table t passes a --table filter. An empty filter
210+
// (the default) allows every table.
211+
func tableAllowed(t string, filter []string) bool {
212+
if len(filter) == 0 {
213+
return true
214+
}
215+
for _, want := range filter {
216+
if want == t {
217+
return true
218+
}
219+
}
220+
return false
221+
}
222+
223+
// filterSchemaTables returns a schema containing only the tables that pass the
224+
// --table filter. An empty filter returns the schema unchanged.
225+
func filterSchemaTables(schema *canonical.CanonicalSchema, filter []string) *canonical.CanonicalSchema {
226+
if len(filter) == 0 {
227+
return schema
228+
}
229+
out := &canonical.CanonicalSchema{}
230+
for _, t := range schema.Tables {
231+
if tableAllowed(t.Name, filter) {
232+
out.Tables = append(out.Tables, t)
233+
}
234+
}
235+
return out
236+
}

internal/canonical/canonical.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,12 @@ func BuildCreateTableSQL(engine string, t CanonicalTable) (string, error) {
226226
// come first (1-based placeholders), then key columns. Identifiers are always
227227
// quoted via QuoteIdent; values are bound, not interpolated.
228228
func BuildUpdateSQL(engine, table string, setCols, keyCols []string) (string, error) {
229+
if len(setCols) == 0 {
230+
return "", fmt.Errorf("cross-engine: update %s: no columns to set", table)
231+
}
232+
if len(keyCols) == 0 {
233+
return "", fmt.Errorf("cross-engine: update %s: no key columns", table)
234+
}
229235
qt, err := QuoteIdent(engine, table)
230236
if err != nil {
231237
return "", err
@@ -256,6 +262,9 @@ func BuildUpdateSQL(engine, table string, setCols, keyCols []string) (string, er
256262
// key columns in the WHERE clause. Identifiers are always quoted; values are
257263
// bound (1-based for Postgres, ? for MySQL/MariaDB).
258264
func BuildDeleteSQL(engine, table string, keyCols []string) (string, error) {
265+
if len(keyCols) == 0 {
266+
return "", fmt.Errorf("cross-engine: delete %s: no key columns", table)
267+
}
259268
qt, err := QuoteIdent(engine, table)
260269
if err != nil {
261270
return "", err

internal/cli/cdc.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"github.com/spf13/cobra"
55

66
"github.com/nixrajput/siphon/internal/app"
7+
"github.com/nixrajput/siphon/internal/errs"
78
)
89

910
func newCdcCmd() *cobra.Command {
@@ -24,6 +25,13 @@ func newCdcCmd() *cobra.Command {
2425
if len(args) >= 2 {
2526
toName = args[1]
2627
}
28+
if fromName == "" || toName == "" {
29+
return &errs.Error{
30+
Op: "cdc",
31+
Code: errs.CodeUser,
32+
Hint: "cdc requires both source and target profiles (positional args or --from/--to)",
33+
}
34+
}
2735
deps, err := buildDeps()
2836
if err != nil {
2937
return err

0 commit comments

Comments
 (0)