Help us shape the future of Go! Take the 2026 Go Developer Survey

change

package
v0.19.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 20, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

README

Change Source

This package defines change.Source — the abstraction spirit uses to consume a stream of row changes from a source database — and the binlog-backed implementation behind NewBinlogClient. The implementation tracks changes by acting as a MySQL replica; the go-mysql library handles the connection and binary-log parsing, and spirit's role is to manage subscriptions for each table being migrated, deduplicate changes, and coordinate with the copier to avoid redundant work.

The interface is source-agnostic: resume positions are opaque strings, lifecycle is Start / StartFromPosition / Close, and additional implementations (e.g. Vitess VStream) can plug in without touching the applier, the bufferedMap, or the migration runner. See source.go for the full interface.

Each table tracked is represented by a subscription. There is a single subscription type — the buffered map — that stores the full row image from the binlog and applies it through the applier. For non-memory-comparable primary keys it falls back to a FIFO queue internally once the watermark optimization is disabled, but row images are still preserved and the applier path is still used.

Subscription Implementation

Background

Earlier versions of Spirit shipped two subscription types side-by-side: a deltaMap that stored only primary-key hashes (and re-read row state from the source via REPLACE INTO ... SELECT at flush time), and a deltaQueue that preserved binlog order for non-memory-comparable PKs. The split caused issue #746: MySQL's binlog-vs-visibility ordering meant that the deltaMap path could read a stale row image when its SELECT raced ahead of the row's commit visibility, applying the wrong final state.

The fix was to unify everything around a single subscription type — the buffered map — that captures the full row image from the binlog directly, so the applied state is the binlog state and the source-side SELECT race is gone. The deltaMap and deltaQueue types were removed entirely; the FIFO behaviour previously provided by deltaQueue now lives inside bufferedMap as an internal mode for non-memory-comparable PKs (see below).

Buffered Map

The buffered map stores the full row image directly from the binlog and applies it through the applier interface:

How it works:

  • Maintains a map of primaryKeyHash -> (isDelete, fullRowImage).
  • Multiple changes to the same row are automatically deduplicated (only the final state is stored).
  • Uses the applier's UpsertRows and DeleteKeys to write changes — there is no SELECT FROM original round-trip.
  • Flushes batches concurrently through the applier's synchronous UpsertRows and DeleteKeys methods. Flush concurrency is independent of the asynchronous copy-worker pool.

Advantages:

  • Excellent deduplication: if a row is modified 100 times, only one upsert is performed.
  • Parallel flushing: independent keys can be written concurrently via the applier.
  • No source-side reads at flush: the row image is already in memory, so no contention with OLTP traffic on the source.
  • Sidesteps the binlog/visibility race: because the row image is the applied state, there is no opportunity for MySQL's binlog-vs-visibility ordering to surface a stale row (see issue #746). This also makes spirit safe to run against sources configured with semi-synchronous replication, which can widen that window by tens or hundreds of milliseconds depending on replica ACK latency. The mysql-semisync-docker.yml CI lane exercises this configuration end-to-end.
  • Watermark optimization (when supported by the chunker): can skip ranges of keys using both KeyAboveHighWatermark and KeyBelowLowWatermark.
  • Cross-server compatibility: the applier can target a different MySQL server, which is what pkg/move relies on.

Limitations:

  • Requires binlog_row_image=FULL and an empty binlog_row_value_options (the applier needs the complete row image).
  • Higher memory usage than a key-only map: stores full row data for each changed key.
  • Watermark optimizations (KeyAboveHighWatermark and KeyBelowLowWatermark) are available on MappedChunker implementations (both optimistic and composite chunkers). They work correctly for numeric, binary, and temporal primary key types. For VARCHAR/TEXT columns with collations, Go's byte-order comparison may differ from MySQL's collation order; any discrepancies are caught by the checksum phase (see issue #479).

Map iteration order is irrelevant to correctness because the applier issues REPLACE INTO target VALUES (...), which deletes any row that conflicts on PRIMARY KEY or any UNIQUE index before each insert. That makes the multi-row VALUES list order-independent — see "Applier idempotence via REPLACE INTO" below.

It is not irrelevant to lock contention, which is a separate matter and the reason a drain no longer batches in iteration order — see Flush partitioning by unique secondary index.

Example scenario:

Binlog events:  INSERT(id=1, ...), UPDATE(id=1, ...), UPDATE(id=1, ...), DELETE(id=2)
Buffered map:   {1: {row: <latest image>}, 2: {isDelete}}
Applied:        UpsertRows({id=1, ...}); DeleteKeys({id=2});
FIFO fallback for non-memory-comparable primary keys

For tables with non-memory-comparable primary keys (e.g. VARCHAR with a case-insensitive collation), the subscription uses LWW buffered-map dedup during the copy phase and switches to an internal FIFO queue post-copy. The queue still stores row images inline and applies them via the applier — there is no REPLACE INTO ... SELECT, so the #746 fix and cross-server move support (issue #607) are preserved. The queue exists only to preserve binlog order: collation-equivalent keys like "A" and "a" hash to different map slots but resolve to the same MySQL row, so a map's non-deterministic iteration would apply events out of order. FIFO replay through the applier preserves binlog order; the target's own collation-aware uniqueness then collapses the events onto the right row.

During the copy phase LWW map dedup is faster but can misorder collation-equivalent keys. Its correctness depends on the repairing initial checksum after the FIFO switch, as with the watermark approximations below. When the watermark optimization is disabled at the end of the copy phase, SetWatermarkOptimization drains the map inline and the subscription switches into queue mode for the cutover/checksum window. The post-copy checksum repairs any residual divergence.

Memory-comparable PKs always use the buffered map, since map-key equality matches MySQL row identity.

Applier idempotence via REPLACE INTO (#847)

The applier writes a multi-row statement per batch. We use:

REPLACE INTO target (cols) VALUES (...), (...), ...;

rather than INSERT ... ON DUPLICATE KEY UPDATE. The choice matters whenever two rows in the same batch can collide on a unique key — typically because a source-side transaction legally moves a unique value between rows:

-- Legal in source: deactivate one row, then activate another,
-- inside a single transaction. UNIQUE(slot_id) allows NULLs to
-- duplicate, so the invariant holds.
START TRANSACTION;
UPDATE t SET slot_id = NULL  WHERE id = 1;  -- was 'S'
UPDATE t SET slot_id = 'S'   WHERE id = 2;  -- was NULL
COMMIT;

With INSERT ... ON DUPLICATE KEY UPDATE, MySQL processes the multi-row VALUES list in array order and resolves only the first conflict on each row (via the UPDATE clause). If the resulting update introduces a second unique-key collision the statement fails with Error 1062. The map's randomized iteration meant a swap pair could land "activate-first" in the batch, hitting that exact failure.

REPLACE INTO is order-independent for this case. Per the docs:

REPLACE works exactly like INSERT, except that if an old row in the table has the same value as a new row for a PRIMARY KEY or a UNIQUE index, the old row is deleted before the new row is inserted.

So each row's conflicts — on PK or any unique index — are deleted before that row's insert runs, irrespective of where the conflicting row sits in the batch. The swap pair collapses to "delete the previous holder, insert the new holder" and the order of the two events inside the batch doesn't matter.

This is the same robustness the pre-#821 deltaMap had with REPLACE INTO target SELECT FROM source, but without the read-after-commit race that motivated #746 — we supply the inline row image, not a SELECT against source.

Eventual consistency between batches

REPLACE's "delete any unique-key conflict before each insert" semantic means a single REPLACE statement can delete more rows than the ones in its VALUES list — specifically, any row currently in the destination that previously held a unique value the new row is now claiming. That row is briefly missing from the destination until its own event arrives in a later batch (or in the same batch but processed later) and re-inserts it.

Concretely, for the swap pair above with batches of size 1:

Step Batch Destination state
0 — id=1: 'S', id=2: NULL
1 REPLACE (id=1, slot=NULL) id=1: NULL, id=2: NULL
2 REPLACE (id=2, slot='S') id=1: NULL, id=2: 'S'

And for the same swap pair if the activate landed first across batches:

Step Batch Destination state
0 — id=1: 'S', id=2: NULL
1 REPLACE (id=2, slot='S') id=2: 'S' (id=1 deleted — unique-key conflict on 'S')
2 REPLACE (id=1, slot=NULL) id=1: NULL, id=2: 'S' (id=1 re-inserted)

Binlog ordering gives us the first table in practice — within a single source-side transaction, the deactivate event has a lower binlog position than the activate — but Spirit's correctness does not depend on which case occurs. The destination converges to source's current state once the last unflushed event for each affected PK has been applied.

This eventual consistency is safe because the bufferedMap is an up-to-date and disjoint representation of pending changes: every PK appears at most once at flush time, holding the latest row image MySQL emitted for it. Any row transiently deleted by REPLACE's conflict resolution is therefore guaranteed to have its own event in the buffer (or arriving shortly) — its row image isn't lost, just temporarily not yet applied. The post-copy checksum, which repairs, is the backstop for anything that slips through.

See TestBufferedMapSwapPairFlushesViaReplace (unit) and TestSwapPairEndToEndViaReplace (end-to-end) for the regression gates.

Features

Watermark Optimization

The watermark optimization is a critical performance feature that prevents the replication client from doing redundant work during the copy phase.

The Problem: During the initial copy phase, the copier is reading rows from the source table and writing them to the new table. Meanwhile, the replication client is also receiving binlog events for those same rows. Without optimization, we would:

  1. Copy row with id=1000 from source to target
  2. Receive a binlog event for id=1000 (from before the copy)
  3. Apply the binlog change, overwriting what we just copied
  4. Result: Wasted work and potential deadlocks

The Solution: The copier maintains a "watermark" representing its progress. The replication client uses this watermark to filter changes:

  • High watermark: Skip changes for rows that haven't been copied yet (they'll be picked up by the copier)
  • Low watermark: Defer buffered changes while a chunk covering their key is in flight; keep them for a later flush (avoid races with the copier, which may cause deadlocks/lock waits)
// Ingest time (HasChanged): discard above-high keys during the copy.
// The repairing initial checksum closes the visibility window discussed below.
// keyNoter is the chunker's optional table.BufferedKeyNoter, asserted once
// when the subscription is created; nil disables the discard (see below).
if keyNoter != nil && chunker.KeyAboveHighWatermark(key[0]) {
    return  // Skip, copier will handle this
}
if keyNoter != nil {
    keyNoter.NoteBufferedKey(key[0]) // admitted: never drop this key again
}

// Flush time (bufferedMap.mustDeferKey): defer only the in-flight band.
if !chunker.KeyBelowLowWatermark(key[0]) && !chunker.KeyNotYetDispatched(key[0]) {
    continue  // Defer; keep the entry buffered until its chunk finishes
}

Note the flush-time filter has two halves. A buffered change is safe to apply both when the copier has already committed its key (KeyBelowLowWatermark) and when the copier has not yet dispatched a chunk covering it (KeyNotYetDispatched). Only the band between them, where a chunk read is genuinely in flight, has to wait.

Applying a not-yet-dispatched key puts it on the target before the copier, and the copier writes with INSERT IGNORE, so its later copy of that row is skipped, not applied on top. The target is only correct if every later change for the key keeps reaching it. The ingest-time discard would break that: a second change to the same key after the copier's first dispatch is above the high watermark and was dropped, leaving the first change's image on the target (or, if the dropped change was a DELETE, a row the source no longer has). The same happens when a change is flushed before SetWatermarkOptimization(true), which is the order pkg/datasync uses. NoteBufferedKey closes this: every admitted change reports its key, and the chunker raises a per-run guard (bufferedHighPtr) to the highest key admitted while no dispatched chunk covered it. KeyAboveHighWatermark returns false at or below that guard, exactly as it does at or below checkpointHighPtr after a resume. The guard is a single value, so it costs no memory; the cost is that changes to keys between the dispatch pointer and the guard are applied instead of dropped. In practice that range is usually most of the table. On an actively written table, a single insert, or an update to a recent row, before the copier's first dispatch raises the guard to roughly the table's max key. From then on the discard mostly applies only to rows inserted after the copy started, and keys_dropped_above_high falls to match. The chunker logs once at Info, with the key and the dispatch pointer, the first time the guard rises, so a lower drop count on a hot table has a visible cause. NoteBufferedKey is on a separate optional interface, table.BufferedKeyNoter, so a MappedChunker written before it existed still compiles. The subscription type-asserts for it once, when it is created. If the chunker does not implement it, the subscription never applies the above-high-watermark discard: every change is buffered and applied, which is correct but gives up the optimization. The in-tree chunkers implement it. Regression tests: TestPreDispatchChangeThenAboveHighWatermark (predispatch_discard_test.go), and end to end TestE2EPreDispatchChangeThenAboveHighWatermark (pkg/migration) and TestSyncPreDispatchChangeThenAboveHighWatermark (pkg/datasync).

Deferring the not-yet-dispatched region too (the behaviour before #1167) pinned the checkpoint's binlog position for entire copies: KeyAboveHighWatermark returns false until the first chunk is dispatched, so every change in the window between SetWatermarkOptimization(true) and the first chunker.Next() — a window the throttler can stretch arbitrarily — was buffered, including changes to rows at the top of the key space. Those entries stay above the low watermark until the copier physically reaches their key, and a single one is enough to make every flush report allChangesFlushed=false.

Important: The watermark optimization is disabled before the final cutover to ensure all changes are applied regardless of the copier's position.

Above-watermark discard vs. binlog visibility

The high-watermark discard in HasChanged (subscription_buffered.go) is only safe if:

For every discarded event E (transaction T, key K above the high watermark at discard time), the copier's later read of the chunk covering K opens a snapshot that includes T.

The copier reads each chunk with a plain autocommit SELECT on a pooled connection, i.e. a fresh snapshot at read time, so the invariant reduces to read-after-delivery visibility: a snapshot opened after delivery of E must see T.

MySQL does not guarantee that. Group commit runs flush → sync (fsync; dump threads may send from here; semi-sync AFTER_SYNC waits for the replica ACK here) → engine commit (InnoDB makes rows visible). Binlog subscribers — spirit included — receive a transaction's events at the sync stage, before its rows are readable on the source. binlog_order_commits=ON (required by preflight since #818) only fixes the order of engine commits; it does not close that window. The gap is sub-millisecond on a healthy primary, but it widens to:

  • the semi-sync ACK round trip, or the full rpl_semi_sync_source_timeout with AFTER_SYNC — that ordering is the entire point of "lossless" semi-sync: data reaches replicas before it is visible locally;
  • elevated commit latency on Aurora under load;
  • the full replication lag when the change feed and the copier read from a replica (the spirit sync import case).

So the race is:

  1. T (INSERT of key K) reaches the sync stage; spirit receives its row events now. T's engine commit completes later, at t_visible.
  2. KeyAboveHighWatermark(K) is true → the event is discarded (keys_dropped_above_high).
  3. A copier read worker dispatches the chunk covering K and opens its snapshot before t_visible. The chunk is copied without T (missing row for an INSERT; stale image for an UPDATE; for a discarded DELETE the still-visible row is copied, leaving a phantom).
  4. T's GTID went into bufferedGTID at step 1, so the next flush publishes flushedGTID ⊇ T — the resume coordinate claims T is handled. The file/offset client advances flushedPos identically.

End state: the change exists on the source, is absent from the target, is in no buffer, and no resume re-fetches it. Steps 2→3 race at every chunk boundary — KeyAboveHighWatermark compares against the dispatch-time upper bound and read workers dispatch continuously — so "key just above the watermark, covering chunk dispatched milliseconds later" is ordinary, not pathological.

This is the same mechanism as issue #746, already fixed for the applier path (inline row images instead of REPLACE INTO … SELECT) and for the pre-first-chunk window (KeyAboveHighWatermark returns false until a chunk has been dispatched). The general above-watermark discard is the remaining path whose safety depends on read-after-delivery.

What is not a problem here:

  • Crash/resume does not add loss. Copy resumes from the checkpointed low watermark, which is ≤ the high watermark at any earlier discard, so discarded-key chunks are re-read long after t_visible (and the checkpointHighPtr guard suppresses the discard up to the new table's max key). Only the live interleaving in step 3 loses data.
  • Holding back the GTID/flushed position would not help. Deferring the resume coordinate past discarded events only changes the crash path, which re-copy already heals; in the no-crash path the live stream is past T and never redelivers it.
  • The checkpoint format is irrelevant. GTID and file/offset advance identically, so disabling the optimization only under GTID mode would be misdirected.

Why the shipped flows are safe today: a repairing checksum stands behind the copy. That backstop is load-bearing, not incidental:

Flow Backstop Net effect today
migrate, move Mandatory pre-cutover checksum, which repairs Repaired before cutover. Cost: differencesFound > 0, a chunk recopy, and a "checksum found differences" signal that looks alarming
sync (continuous) Initial checksum + mysqlRecopier, then a continuous checksum that does not repair Real exposure: the target can serve a missing/stale/phantom row from copy time until the initial checksum covers that chunk; a divergence found after the first clean pass stops the sync
Library consumers of pkg/copier + pkg/change with no checksum None Silent data loss

This is the same reliance already accepted knowingly for collation-imprecise key comparisons (issue #479, "checksum will fix any discrepancies") — except the visibility window affects every key type, not just collated strings.

Field signature: a run that hit the race shows keys_dropped_above_high > 0 in the watermark-toggle log line and non-zero checksum differences. Semi-sync sources, Aurora under heavy commit load, and replica-fed syncs should expect that correlation to be reproducible.

If we want to stop relying on the checksum, the options are:

  • Buffer instead of discard. Keep the low-watermark flush deferral, stop dropping above-high-watermark events. Airtight and simple, but the memory cost lands exactly on the workload the optimization exists for: on append-heavy tables every tail insert is buffered for the rest of the copy, and the soft limit then parks the binlog reader.
  • Visibility-proof deferred drop. Buffer above-watermark events and drop them at flush time once dropping is provably safe: still above the high watermark (covering chunk still undispatched) and the transaction is contained in gtid_executed (one SELECT @@gtid_executed per flush). Containment implies engine commit, so any later chunk read sees the row. Bounded residency (~one flush interval) preserves the memory profile, but it needs per-entry transaction identity plumbed into the subscription, and a time-dwell fallback on non-GTID sources.
  • Copier-side visibility barrier. Before each chunk read, WAIT_FOR_EXECUTED_GTID_SET on the change feed's delivered set — holds reads instead of events. Clean and usually free, but it couples the copier to the change source's position (deliberately decoupled today) and has no file/offset equivalent.
  • Disable the discard where no synchronous checksum gate exists (pkg/datasync fresh copies). One line, costs sync initial-copy throughput on hot tables, and swaps in a smaller DELETE-only hazard that sync's resume path already accepts.

Repro: TestKeyAboveWatermarkVisibilityWindow (gtid_visibility_race_test.go) demonstrates the whole chain deterministically, using the semi-sync source plugin with no replica so the first commit after arming stalls for the full timeout between binlog sync and engine commit:

# once, on a scratch server:
#   INSTALL PLUGIN rpl_semi_sync_source SONAME 'semisync_source.so';
MYSQL_DSN="root:...@tcp(127.0.0.1:3306)/test" \
  go test ./pkg/change/ -run TestKeyAboveWatermarkVisibilityWindow -v

Observed on MySQL 8.0.43: the row event is delivered and discarded ~15ms into a 3000ms commit stall, the covering chunk read (the copier's statement shape) does not contain the row, a flush during the window publishes a GTID position that already covers the transaction, and the target never receives it. The test self-skips without the plugin, without the privileges to arm the window, or when a semi-sync replica is attached — which means it skips in both CI lanes (the default lane has no plugin; the semi-sync lane has an ACKing replica) and is a scratch-server tool.

Skipping row decode for unsubscribed tables

While the copy runs, the binlog is dominated by spirit's own writes — the multi-row INSERTs into the _new table. The stream client has no subscription for _new, so those events are no-ops, but they still have to move through the parser, and decoding every column of every row image (including JSON rendering) just to discard the event by table name is the single largest cost in the stream path. On a fast copy the reader falls behind its own migration and repays the gap after the copy as a long catch-up phase that is almost entirely no-ops.

Both clients therefore install a RowsEventDecodeFunc on the syncer (see newRowsEventDecodeFunc): the event header is always decoded — that is where the table name and stream position come from — but the row images are decoded only when the table has a subscription. This is the same hook go-mysql's canal uses for its table filters. It is safe because the Source lifecycle requires all subscriptions to be added before Start; processRowsEvent enforces that with a hard error if a subscribed table's event ever arrives undecoded, rather than silently treating it as empty.

Checkpointing

The replication client tracks two positions:

  • Buffered position: All events have been read from the server and stored in memory
  • Flushed position: All events have been successfully applied to the target table
// Get the safe checkpoint position (opaque string owned by the source).
pos := client.Position()

// Resume from a checkpoint — primes the position and starts streaming.
err := client.StartFromPosition(ctx, savedPosition)

Periodically, changes are flushed to advance the flushed position, which is then used as part of checkpoints. Because all replication changes are idempotent, it is understood that on recovery some changes will effectively be re-flushed, and the last ~1 minute of progress may have been lost.

Final Cutover coordination

Before a cutover operation can run, it's important to ensure that there are no unapplied replication changes. The best practice way to do this is to first Flush(ctx) without a lock, and then repeat the flush with the lock held. i.e.

// Ensure most changes are up to date before we need to do this again
// with a lock held (ensures lock duration is as short as possible)
err = client.Flush(ctx)

if err != nil {
    return err
}

// Acquire both table locks on the same server for a schema change.
lock, err := dbconn.NewTableLock(ctx, db, []*table.TableInfo{sourceTable, targetTable}, dbConfig, logger)
if err != nil {
    return err
}
defer lock.Close(ctx)

// Supply exactly one lock per distinct target *sql.DB.
// Targets sharing a handle share one lock covering all their tables.
if err := client.FlushUnderTableLock(ctx, []*dbconn.TableLock{lock}); err != nil {
    return err
}

// This check should be redundant, but we verify everything is applied
if !client.AllChangesFlushed() {
    return errors.New("changes still pending")
}

// Safe to cutover now

The client.Flush() will retry in a loop until the number of pending changes is considered trivial (currently <10K). It is important to handle errors correctly here, because FlushUnderTableLock may fail if it can't flush the pending changes fast enough. This is your cue to abandon the cutover operation for now, and try again when the server is under less load.

Flush partitioning by unique secondary index

A map-mode drain splits its rows into batches and runs several through the applier at once. Those batches are disjoint by primary key — a map holds one image per key — and for a long time that was assumed to be enough. It is not.

Two concurrent REPLACE statements on PK-disjoint rows can still deadlock, and in issue #1168 they did: the InnoDB cycle inverted between the clustered index and a UNIQUE secondary index. REPLACE's duplicate detection takes a next-key lock on each unique secondary index — the gap below the record included — so two batches collide whenever any of their rows land in adjacent slots of any such index. Secondary key order is unrelated to primary key order, so PK disjointness says nothing about it.

The conflict surface is therefore exactly the set of UNIQUE secondary indexes, and that is a precise claim rather than a cautious one. It is established against a real server by TestReplaceContendsOnlyOnUniqueIndexes in pkg/applier:

Two rows are… Contend? Why
adjacent in the PRIMARY KEY no a REPLACE's clustered-index conflict is with the row bearing that exact PK, so under READ COMMITTED it takes a record lock and no gap
equal in a non-unique secondary index no those records are keyed (indexed columns, PK), so PK-disjoint rows always occupy distinct records
adjacent in a UNIQUE secondary index yes duplicate detection takes a next-key lock, gap included

So primary-key separation buys nothing, and an earlier attempt at PK-sorting the drain was aimed at the wrong index. The drain instead:

  1. Chooses the unique secondary index whose values are most clustered across this drain's own rows — measured, not configured, because whether a key correlates with anything is a property of the workload rather than of the schema. A repeating leading column means physically adjacent sibling records and a near-certain collision; a uniformly distributed key has an adjacency probability of roughly n²/N per drain (about 0.3 for 50,000 rows in 8.5 billion) and needs no help.
  2. Sorts by that index and cuts contiguous batches, nudging cut points to fall where the leading key value changes so a run of siblings is not split across two batches. Range partitioning, not hashing: hashing spreads equal-ish values across buckets, which is the arrangement that collides.
  3. Stripes the batches into evens and odds, running one group at a time, so no two batches in flight together are neighbours in the sort order. Handing the sorted list straight to the limiter would undo most of the benefit — the in-flight window is roughly contiguous, so neighbours would run together and every batch boundary would become a candidate collision.

Rows that are close together in the chosen index end up in the same statement, where they cannot conflict, and statements that do run together are separated by at least one whole batch of intervening rows. Note that the separation is measured in rows, not in value distance: whether two values are adjacent in the B-tree depends on the whole table, not on the drain, so no value-space margin would mean anything.

Getting it wrong costs throughput, never correctness. Batches remain disjoint by key and map mode makes no cross-key ordering promises, so a misordered sort or a poorly chosen index simply reproduces the old collision behaviour, which the AIMD contention controller still catches. That is what makes it acceptable to sort row images with a best-effort comparator.

The controller therefore stays, and covers what partitioning cannot:

  • Deletes. A buffered delete keeps only its primary key (the before image is discarded at buffer time), so there is no way to know where it sits in a unique secondary index. Deleted rows are grouped at the tail.
  • Second and subsequent unique indexes. Sorting by one says nothing about the others.
  • REPLACE's out-of-partition deletion cascade. A REPLACE deletes any row conflicting on any unique index, including primary keys not in the batch, whose other unique values are unknowable from here.

Partitioning is automatic, has no flag, and turns itself off when there is nothing to do: a table with no usable unique secondary index has no conflict surface between PK-disjoint batches at all, so the sort would be pure cost. A flush partitioning enabled line at Info reports the candidates once per subscription.

Memory backpressure

Each subscription approximates the bytes it is holding in memory (row image + key bytes per buffered change) and parks HasChanged on a per-subscription condition variable when the total reaches DefaultSubscriptionSoftLimitBytes (256 MiB). This keeps wide rows — LONGTEXT, BLOB, large JSON — from OOMing the migrator when the source's write rate outpaces the applier.

The cap is soft: the wait is checked before a change is added, against the buffer's current pre-add size. A row is therefore always admitted whenever sizeBytes < softLimitBytes, even if its own size pushes the total well past the limit; the cap only blocks new arrivals once the buffer is already at or over it. This is intentional — it preserves forward progress regardless of row width — but it does mean peak memory can exceed DefaultSubscriptionSoftLimitBytes by up to one oversized row's worth before the next caller parks.

Override via ClientConfig.SubscriptionSoftLimitBytes; pass a negative value to disable the cap entirely. The times_parked_on_soft_limit and size_bytes fields appear in the watermark-toggled log line, and keys_added / keys_dropped_above_high / keys_skipped_not_below_low provide the surrounding context.

Limitation — binlog retention: while parked, the binlog reader makes no progress. If the source rotates past the reader's current position (binlog_expire_logs_seconds) before the buffer drains, the reader will fail to resume and the migration will abort. Tune the soft limit and source retention together for sustained high-write workloads.

Parking at a row change (VerifyRowAtNextChange)

VerifyRowAtNextChange is part of the Source contract: every source must be able to hold its reader at a chosen event. It exists for one caller — the lockless checksum, verifying a row that is written continuously.

Such a row cannot be verified by reading both sides. Any SQL comparison is between a source image at one position and a target state at a later one, and closing that window means stopping the writes. But with binlog_row_image=FULL, an event's after-image is the source's value for that row at that position, so the verification becomes:

RowParker.Verify(ctx, watch, verify, drain, allFlushed):
  arm the watch, then release the reader
  on a change matching watch, dispatchRow does, in this order:
      RowParker.Watch records it against the watch
      buffer it into the subscription
      ParkedRow.Release parks the reader and wakes the verification
                        (unless the verification has given up)
  drain the buffer (not Flush — see below), so the target holds exactly that image
  call verify(key, image, deleted) with the reader still parked
  re-check for a rewrite, then disarm and unpark, whatever happened

All of this is RowParker, which any Source — in-tree or out — embeds by value and wires into three places: Wait(ctx) in the read loop (after the event is read, before it is acted on), Watch / Release around the buffering step in the dispatch, and Verify from the interface method. A Source supplies only its own inner drain and its AllChangesFlushed. It is shared rather than reimplemented because every step below is a silent correctness bug when it is out of order, and none of them fails a test that uses a fake feed.

Five details carry the correctness:

  • The gate is checked after the event is read from the stream but before it is acted on, so parking never consumes and discards an event. Dispatch resumes with it.
  • The drain is not Flush. Flush ends in BlockWait, which waits for the reader to reach the source's current position — and the reader is parked, by us, precisely so nothing past the watched event is admitted. That wait could never succeed. flushParked applies what is buffered, stops, and returns ErrFlushIncomplete if the buffer will not empty; the caller must then retry rather than treat a target difference as a divergence.
  • A multi-row event keeps dispatching after the gate arms, so a second change to the same key can still land. The parked row counts those, and the verification checks the count twice — once after the drain (which would otherwise carry the newer image) and once after verify returns, because the verifier reads a target that a periodic flush is still free to write to. Either check returns ErrRowRewritten rather than a verdict reached against a target that moved.
  • Each of dispatchRow's three steps is wrong anywhere else. The verification reads the rewrite count after its flush, so a rewrite must be recorded before it can be buffered — otherwise a flush could carry the newer image to the target while the count still read zero, and the comparison would report a divergence against an image the target had already moved past. Equally, the verification must not be released until after the change is buffered, or the flush would run without it. TestDispatchRowOrdering pins both.
  • Only a live verification's row may park the reader. A dispatch and the verification that armed it are on different goroutines, and the verification can be gone by the time the dispatch reaches the park. Parking then stops the feed for the rest of the run, because that verification was the only thing that would have unparked it. ParkedRow.Release and ParkedRow.abandon serialize on the row's mutex, so either the park is skipped or abandon undoes it. Disarming the watch first is not enough on its own: a dispatch already holds the pointer it loaded from the slot.

One verification runs at a time (RowParker.verifyMu), which is what makes a single watch slot enough.

This park is unrelated to the memory backpressure park, which blocks inside HasChanged. The two interact in exactly one place: a watch is offered the change before HasChanged, so it does fire while a subscription is over its soft limit — but the verification is not woken until the change is buffered, so it times out and defers, and the dispatch arrives at the park long after the verification is gone. That is the case abandon exists for.

Other Minor Features
  • Automatic recovery: Handles transient errors and reconnects to the binlog stream without data loss
  • DDL detection: Monitors for schema changes and notifies the migration coordinator. This is used to abandon any schema changes if the table was externally modified. An ALTER TABLE that only adds or drops foreign keys on a subscription's new table is not treated as a change: it changes no column and no row, and the migration's experimental foreign key support writes such ALTERs to the binary log on every cutover attempt, so a run resumed after a failed attempt reads them after its checkpoint.

Implementing a Source

Source is a wide interface, but two of its methods are shared machinery rather than per-backend work, and an implementation that rolls its own gets them subtly wrong in ways no test catches:

  • VerifyRowAtNextChange — embed a RowParker by value and wire it into the read loop, the dispatch and the method. Supply only the backend's inner drain and its AllChangesFlushed.
  • FeedStats — fill the fields the backend knows (last flush, buffered position and event time, and whatever its own reader counts), then call stats.MergeSubscriptions(subs) for the rest. The rules for combining the per-subscription figures are not guessable: parks sum while IsParked ORs, and the flush shape is the narrowest effective one paired with the configured width it is narrow relative to. A field a backend genuinely has no analogue for — binlog rotations, on a source with no binlog files — stays zero, and the status row prints it as zero rather than hiding the feed.

Testing against a Source

change.MockSource (mock.go) is the shared test double, for this package and for every package that is handed a feed. Source is a wide interface, so a hand-rolled double is ~20 lines of no-op methods around the one or two that carry behaviour, and adding a method to Source means writing it once per copy — which is how the checksum, move and sync packages each ended up with their own.

With Inner set it delegates to a real source and records the calls; with Inner nil every method is a success-shaped no-op reading from its configuration fields. A test that needs a scripted change delivered embeds it and overrides VerifyRowAtNextChange.

Prefer it over embedding a nil change.Source, even for a feed a test believes is never touched. Promotion through an embedded interface satisfies Source with methods that panic, so growing the interface turns a passing test into a nil dereference at a call site nobody was thinking about — which is what moving FeedStats onto Source did to pkg/migration's status stub. Embed a nil Source only where an unintended call should be a panic that names itself: a parameter the code under test must not reach at all.

See Also

Documentation

Overview

Package change contains binary log subscription functionality.

Index

Constants

View Source
const (

	// DefaultBatchSize is the maximum number of rows in each batched
	// REPLACE/DELETE statement that the binlog applier emits against the
	// _new table. Larger is better, but we need to keep the run-time of
	// the statement well below dbconn.maximumLockTime so that it doesn't
	// prevent copy-row tasks from failing. On Aurora tables with
	// out-of-cache workloads that copy ~300 rows per second this is close
	// to the safe ceiling.
	//
	// Batches are additionally capped by their estimated rendered byte
	// size (applier.MaxStatementSizeBytes, shared with the copy path's
	// chunklet splitting) so that wide rows can't accumulate into a
	// statement larger than max_allowed_packet. Whichever cap is reached
	// first cuts the batch; see flushMapLocked / flushQueueLocked in
	// subscription_buffered.go.
	//
	// Was previously an initial value for an adaptive sizer (feedback()
	// driven by p90 apply time). That mechanism was meaningful when the
	// applier issued `REPLACE INTO _new ... SELECT FROM source` and S-locked
	// rows on the live table, but after #853 the applier emits inline
	// VALUES against _new only — no source-side locks — and the batches
	// are strictly serial inside flushBatch. There's nothing left to
	// throttle, so the batch size is just a constant. See issue #869.
	DefaultBatchSize = 1000

	// DefaultFlushInterval is the time that the client will flush all binlog changes to disk.
	// Longer values require more memory, but permit more merging.
	// I expect we will change this to 1hr-24hr in the future.
	DefaultFlushInterval = 30 * time.Second
	// DefaultFlushConcurrency is the number of applier batches a
	// map-mode flush keeps in flight concurrently. The binlog apply
	// path is synchronous REPLACE/DELETE statements — it does not use
	// the copy path's write worker pool — so each stream tops out at
	// DefaultBatchSize rows per statement round trip. On large tables
	// where secondary index maintenance dominates, that is only a few
	// hundred rows/s, which a busy source's distinct-key write rate can
	// permanently outrun: the buffer pins at the soft limit and the
	// migration never converges, however long it runs. Map-mode flush
	// batches are disjoint by key and order-free (REPLACE/DELETE on
	// distinct keys commute), so applying them concurrently is safe and
	// multiplies the ceiling. Queue-mode drains (non-memory-comparable
	// PK, post-copy) and under-lock (cutover) flushes remain serial.
	DefaultFlushConcurrency = 8
	// DefaultSubscriptionSoftLimitBytes caps the approximate memory held
	// per subscription before HasChanged starts blocking on the buffered
	// map's condition variable. The cap is "soft": a single oversized
	// row admitted when the buffer is empty will exceed the limit, and
	// the next caller will park until that row drains. This keeps wide
	// rows (LONGTEXT / BLOB / large JSON) from OOMing the migrator
	// while still guaranteeing forward progress regardless of row width.
	// See pkg/change/subscription_buffered.go for the accounting model.
	//
	// Three behaviours keep the cap from starving the change reader:
	// map-mode overwrites of already-buffered keys bypass it (dedup
	// stays live under backpressure), parking requests an immediate
	// flush rather than waiting for the periodic interval, and flushes
	// release capacity per applied batch — the reader resumes as soon
	// as the first batch lands, not when the whole buffer has drained.
	//
	// Operators should be aware that pausing the binlog reader for an
	// extended period risks falling past the source's binlog retention
	// (binlog_expire_logs_seconds). Tune this value, or the source's
	// retention, accordingly.
	DefaultSubscriptionSoftLimitBytes = 256 << 20
	// DefaultSubscriptionSoftLimitChanges caps the number of pending
	// changes per subscription before HasChanged parks, alongside
	// DefaultSubscriptionSoftLimitBytes. Whichever binds first parks the
	// reader.
	//
	// The byte cap alone is not enough because bytes and count measure
	// different costs. Bytes bound memory, which is what a handful of wide
	// LONGTEXT rows threatens. Count bounds how long the drain that empties
	// the buffer takes: the flush applies rows in batches of at most
	// DefaultBatchSize per round trip, so drain time scales with count and
	// is indifferent to row width. A narrow-row table therefore reaches an
	// unworkable drain long before it reaches 256MiB — in production, a
	// table averaging ~600 bytes per change filled to over 450k pending
	// changes while still well inside the byte cap, and the drain that
	// followed ran for 21m37s holding flushMu for its full duration.
	//
	// 50k targets a drain of roughly two minutes rather than twenty. Scaling
	// that production drain — at most 452,571 rows in 21m37s — down to 50k
	// gives about 2m23s, and that is a floor rather than an estimate: the
	// 452,571 figure is the backlog at flush *start*, so if fewer rows actually
	// landed the per-row cost is higher and the scaled time is longer.
	//
	// Two minutes is not "one flush interval", and it is not meant to be. What
	// matters is that the drain *completes*, because only a complete drain
	// reports allChangesFlushed=true and only that advances the flushed
	// position; a cap tight enough to fit one DefaultFlushInterval would
	// truncate every drain and freeze the position just as before. Overlapping
	// flushes are not a concern either — flushMu serializes them, so a tick
	// arriving mid-drain waits rather than piling on.
	//
	// It is well above binlogTrivialThreshold, so it does not interfere with
	// the "flush until trivial" loops, and it still lets dedup absorb hot-row
	// workloads — map-mode overwrites of already-buffered keys bypass the cap
	// entirely.
	DefaultSubscriptionSoftLimitChanges = 50000
	// DefaultTimeout is how long BlockWait is supposed to wait before returning errors.
	DefaultTimeout = 30 * time.Second
)

Variables

View Source
var (

	// ErrChangesNotFlushed indicates that not all changes have been flushed from the replication feed.
	ErrChangesNotFlushed = errors.New("not all changes flushed")
)
View Source
var ErrFlushIncomplete = errors.New("change: parked flush did not drain the buffer")

ErrFlushIncomplete means the parked flush could not empty the buffer, so the watched row's change may not have reached the target. The caller should retry; it must not read the target and call the difference a divergence.

View Source
var ErrPositionNotFound = errors.New("change.Source: cannot resume from position; it is no longer available on the source")

ErrPositionNotFound is returned by StartFromPosition when the underlying source can no longer resume from the requested opaque position — most commonly because the binlog file has been purged on a MySQL source. Wrapped with %w so callers can errors.Is against it.

View Source
var ErrRowRewritten = errors.New("change: watched row was rewritten during verification")

ErrRowRewritten means the watched row was written again before the verification could read the target, so the image the caller was handed is no longer what the target holds. The caller should retry.

Functions

func GTIDEnabled added in v0.17.0

func GTIDEnabled(ctx context.Context, db *sql.DB) (bool, error)

GTIDEnabled reports whether the server has GTIDs enabled — gtid_mode=ON and enforce_gtid_consistency=ON — i.e. whether it can serve the GTID-based change source (COM_BINLOG_DUMP_GTID, and @@GLOBAL.gtid_executed / gtid_purged for resume validation). The permissive modes (ON_PERMISSIVE / OFF_PERMISSIVE) report false: in those modes the server may still write anonymous transactions, which have no GTID to resume from.

func IsGTIDPosition added in v0.17.0

func IsGTIDPosition(pos string) bool

IsGTIDPosition reports whether pos — an opaque position previously returned by Source.Position() — is a GTID-set coordinate rather than a binlog file:offset coordinate. The two encodings cannot collide: a GTID set is "uuid:interval[,uuid:interval]..." and a binlog coordinate is "<file>:<offset>" where the file ("binlog.000001") never parses as a UUID. The empty string (no position observed yet) is not a GTID position.

func NewServerID

func NewServerID() uint32

NewServerID allocates IDs in [1001, 4294967295], avoiding typical MySQL server IDs. IDs do not repeat within a process until the range is exhausted. The random starting point reduces, but cannot eliminate, cross-process collisions.

func StatusRow added in v0.17.0

func StatusRow(srcs ...Source) string

StatusRow renders the feed stats of srcs as the binlog row of a runner status block, or "" when there is no source to report on. Runner Status() can be called before the feed is constructed, so nil sources are skipped — and a call with nothing left after that produces no row rather than a row of zeros, which would read as a feed that exists and has never flushed.

Multiple sources (a sharded move reads one feed per source) are merged into one set of fields: counters are summed, and the flush figures are taken from the feed that flushed least recently, since that is the one holding the position back.

Types

type BufferedSubscriptionConfig

type BufferedSubscriptionConfig struct {
	// CurrentTable is the source-side TableInfo. Required.
	CurrentTable *table.TableInfo

	// NewTable is the destination-side TableInfo. May be nil for
	// MoveTables/import flows where source and destination share the
	// same schema; in that case Subscription.Tables() returns just
	// [CurrentTable].
	NewTable *table.TableInfo

	// Applier writes batched changes to the target. Required.
	Applier applier.Applier

	// Chunker provides the watermark filter + column mapping. Required.
	Chunker table.MappedChunker

	// Logger receives diagnostic events. Defaults to slog.Default()
	// when nil.
	Logger *slog.Logger

	// SoftLimitBytes is the per-subscription byte cap before
	// HasChanged blocks waiting on the flush path. Zero disables the
	// cap. See bufferedMap.softLimitBytes for the semantics.
	SoftLimitBytes int64

	// SoftLimitChanges is the per-subscription cap on pending change
	// *count* before HasChanged parks, applied alongside SoftLimitBytes;
	// whichever binds first parks the reader. Zero disables it. See
	// bufferedMap.overSoftLimitLocked for why both exist.
	SoftLimitChanges int

	// FlushRequest, when non-nil, receives the parked subscription (a
	// non-blocking send) each time HasChanged parks on the soft limit.
	// Owners that flush on a periodic ticker should select on it and
	// flush the received subscription first, then run their normal
	// all-subscription pass — flushing others first would leave the
	// change reader parked for those entire drains. Optional; nil
	// disables the signal.
	FlushRequest chan<- Subscription

	// FlushConcurrency is the maximum number of applier batches a
	// map-mode flush keeps in flight concurrently. Zero or negative
	// means serial, preserving prior behaviour for callers that do not
	// set it; the in-tree clients pass DefaultFlushConcurrency. Queue-
	// mode and under-lock flushes are always serial regardless.
	FlushConcurrency int

	// BatchSize is the maximum number of rows one flush batch renders
	// into a single statement. Zero means DefaultBatchSize, preserving
	// prior behaviour for callers that do not set it.
	//
	// It is not independent of FlushConcurrency: their product is the
	// rows a drain has in flight, so a caller raising one should lower
	// the other. autoscale.FlushBounds returns the pair.
	BatchSize int

	// UnderLoad is ClientConfig.UnderLoad: the server-load signal the drain
	// narrows itself on. Optional; nil disables load shedding entirely.
	UnderLoad func() bool
}

BufferedSubscriptionConfig configures NewBufferedSubscription.

type ClientConfig

type ClientConfig struct {
	Logger   *slog.Logger
	ServerID uint32
	DBConfig *dbconn.DBConfig // Database configuration including TLS settings

	// CancelFunc is an optional callback from the caller (e.g. migration or move runner).
	// It is called when a DDL change is detected on a subscribed table
	// (FatalReasonSchemaChange), or when a fatal stream error occurs, such as
	// minimal RBR detection or exhausted streamer recreation attempts
	// (FatalReasonStreamError), when XA is detected
	// (FatalReasonUnsupportedXA), or when a binlog file's 4-byte LogPos wraps
	// past 4GiB (FatalReasonLogPosWrapped), or when the periodic flush fails
	// to apply changes (FatalReasonFlushError).
	// The caller is expected to handle cancellation
	// and cleanup, using reason to decide whether persisted resume state
	// (e.g. a checkpoint) must be invalidated (schema change) or is still
	// safe to resume from (stream error).
	// It returns true if the error was acted upon (caller actually cancelled),
	// or false if it was ignored (e.g. because the caller is already past cutover).
	CancelFunc func(reason FatalReason) bool

	// DDLFilterSchema, when set, broadens DDL detection to cancel on any DDL change
	// in the specified schema, rather than only on exact table matches against subscriptions.
	// This is used by the move runner to detect DDL on any table in the source database.
	DDLFilterSchema string

	// DDLFilterTables, when set alongside DDLFilterSchema, narrows the schema-level
	// DDL detection to only the specified table names. This is used for partial moves
	// where only specific tables from a schema are being moved — DDL on unrelated
	// tables in the same schema should not trigger cancellation.
	// If empty (and DDLFilterSchema is set), all tables in the schema trigger cancellation.
	DDLFilterTables []string

	// SubscriptionSoftLimitBytes overrides DefaultSubscriptionSoftLimitBytes
	// for new subscriptions. Set to a negative value to disable the cap
	// entirely (HasChanged will never block on memory). Zero (the
	// zero-value default) means use DefaultSubscriptionSoftLimitBytes.
	SubscriptionSoftLimitBytes int64

	// SubscriptionSoftLimitChanges overrides
	// DefaultSubscriptionSoftLimitChanges for new subscriptions: the cap on
	// pending change *count* before HasChanged parks, applied alongside
	// SubscriptionSoftLimitBytes. Set to a negative value to disable the cap
	// entirely. Zero (the zero-value default) means use
	// DefaultSubscriptionSoftLimitChanges.
	SubscriptionSoftLimitChanges int

	// FlushConcurrency overrides DefaultFlushConcurrency for new
	// subscriptions: the maximum number of applier batches a map-mode
	// flush keeps in flight concurrently. Set to a negative value to
	// force serial flushing. Zero (the zero-value default) means use
	// DefaultFlushConcurrency.
	FlushConcurrency int

	// BatchSize overrides DefaultBatchSize for new subscriptions: the
	// maximum number of rows one map-mode flush batch renders into a
	// single statement. Zero (the zero-value default) means use
	// DefaultBatchSize; a negative value is clamped to one row per
	// statement.
	//
	// This travels with FlushConcurrency rather than being set on its
	// own, because the two together decide how many rows a drain has in
	// flight. See autoscale.FlushBounds, which is what sets both when
	// the migration runner sizes them from the instance.
	BatchSize int

	// UnderLoad reports whether the target is currently loaded enough that the
	// flush should narrow. Nil (the zero value) means no signal, and the drain
	// runs at its configured width exactly as it did before this existed.
	//
	// This is the change feed's only view of server load, and it exists because
	// the feed was previously the one write path with no such view at all. The
	// flush is deliberately not throttled — the binlog position has to keep
	// advancing or the migration loses its retention window — and the original
	// reasoning was that the copier would absorb the load on its behalf. That
	// held while the flush was a fixed 8 batches wide. Once the width became
	// instance-derived (up to 32) the absorbing side kept shedding while the
	// widened side never did, so under sustained load the copier would shed to
	// almost nothing while the flush stayed at full width and the total barely
	// moved. See bufferedMap.adaptFlushLoad.
	//
	// It is a func rather than a throttler because the change feed has no
	// business importing one, and because the migration runner swaps its
	// throttler during setup — a value captured at construction would be the
	// wrong one.
	UnderLoad func() bool
}

func NewClientDefaultConfig

func NewClientDefaultConfig() *ClientConfig

NewClientDefaultConfig returns a default config for the copier.

type DrainBudgetReporter added in v0.17.0

type DrainBudgetReporter interface {
	LastDrainHitBudget() bool
}

DrainBudgetReporter is implemented by Subscription implementations that bound how long one flush spends dispatching work and can report whether the last one hit that bound. Optional, for the same reason ParkReporter is: a subscription that always drains what it holds has nothing to report.

type FatalReason added in v0.16.0

type FatalReason int

FatalReason tells the caller's CancelFunc why the change client hit a fatal condition, so the caller can decide which of its state (if any) must be invalidated before cancelling.

const (
	// FatalReasonSchemaChange means DDL was detected on a watched table.
	// Persisted resume state (checkpoints) describes the table's old
	// definition, so a caller that keeps such state must invalidate it:
	// resuming against the changed table could corrupt data.
	FatalReasonSchemaChange FatalReason = iota
	// FatalReasonStreamError means the change stream itself failed fatally
	// (streamer recreation attempts exhausted, or a row event could not be
	// processed). The watched tables are not known to have changed, so
	// persisted resume state remains valid and a retry can resume from it.
	FatalReasonStreamError
	// FatalReasonUnsupportedXA means an XA group was found in the stream.
	// Replaying the checkpoint would hit the same group, so finite runs
	// must discard their checkpoint and start fresh after XA activity stops.
	FatalReasonUnsupportedXA
	// FatalReasonLogPosWrapped means a binlog file grew past 4GiB and the
	// 4-byte LogPos wrapped, so file+offset coordinates no longer identify
	// a unique point in the stream. Replaying the checkpoint would stream
	// forward into the same oversized transaction and wrap again, so finite
	// runs must discard their checkpoint; a fresh run started after the
	// file rotates gets usable coordinates again.
	FatalReasonLogPosWrapped
	// FatalReasonFlushError means the periodic flush failed to apply buffered
	// changes to the target, after the applier's own retries. The failed
	// changes stay buffered and the resume coordinate stops advancing, so
	// carrying on only spends binlog retention until the checkpoint can no
	// longer be resumed. The watched tables are not known to have changed,
	// so persisted resume state remains valid.
	FatalReasonFlushError
)

func (FatalReason) Advice added in v0.18.0

func (f FatalReason) Advice(noun string) string

Advice returns what a finite run logs when it aborts for this reason, naming the run as noun ("migration", "move"): whether the checkpoint survives and what the operator must do before re-running. It is empty for a schema change, which the change client has already logged.

func (FatalReason) PreservesCheckpoint added in v0.18.0

func (f FatalReason) PreservesCheckpoint() bool

PreservesCheckpoint reports whether a finite run (a migration or a move) can still resume from its checkpoint after aborting for this reason. Only a failed stream or a failed flush leaves the watched tables unchanged and the checkpoint usable. Every other reason — including any future one — reports false: invalidating costs a restart, while wrongly resuming could corrupt data.

func (FatalReason) String added in v0.16.0

func (f FatalReason) String() string

String implements fmt.Stringer for logging.

type FeedStats added in v0.17.0

type FeedStats struct {
	// LastFlushAt is when the most recently completed flush finished, or the
	// zero time before the first flush completes.
	LastFlushAt time.Time
	// LastFlushDuration is how long that flush took.
	LastFlushDuration time.Duration
	// LastFlushRows is how many buffered changes were pending when that flush
	// started — the "batch size" of the flush. Zero is normal and meaningful:
	// it is what a feed that is keeping up looks like, and it is what
	// Source.Flush always ends on (it loops until the backlog is trivial and
	// then flushes once more).
	LastFlushRows int
	// BufferedPosition is how far the feed has *read*, in the same opaque
	// encoding Source.Position uses. Empty before the feed has read anything.
	//
	// This is deliberately not the resume coordinate. Source.Position and the
	// ckpt row both report the *flushed* position, which only advances when a
	// flush lands every buffered change — so while any change is held back the
	// checkpoint is frozen by design, and the status block goes silent about
	// the reader even though it is working normally. That is indistinguishable
	// from a genuinely stalled feed, which is the case an operator most needs
	// to tell apart. Reporting the buffered position alongside restores the
	// distinction: if it advances between status blocks the reader is fine and
	// only publication is blocked, and the gap to the ckpt row is how much
	// re-reading a restart would cost.
	BufferedPosition string
	// BufferedEventAt is the source's own wall-clock timestamp on the newest
	// event the reader has read — i.e. when the source committed the
	// transaction that BufferedPosition names. Zero before the feed has read
	// an event carrying a timestamp.
	//
	// Rendered as an age next to BufferedPosition, which is the only form in
	// which the position is legible as *progress*. A GTID coordinate says
	// nothing about how far behind the feed is: on a resumed run the number
	// looks the same whether it is seconds or a week stale, and the count of
	// GTIDs to go cannot be turned into a time without knowing the source's
	// commit rate, which nothing in the status block reports. The age answers
	// it directly — and it answers it from data the reader already has, with no
	// extra query against the source.
	//
	// This is the field to read when deciding whether a resumed migration can
	// converge. A migration that resumes from a week-old checkpoint has to
	// replay a week of binlog before it can cut over, and until now the only
	// tell was the copier starting at 99.x%. It is also the honest measure of
	// checkpoint staleness that Record.Age() is not: that measures when the
	// checkpoint row was last written, which on a progressing run is always
	// seconds ago no matter how stale the position inside it is.
	//
	// Measured against this host's clock, so clock skew against the source
	// shifts it. At the multi-hour lags it exists to expose that is noise; at
	// "caught up" it is why the rendering floors at zero rather than showing a
	// negative age.
	BufferedEventAt time.Time
	// Rotations counts binlog rotations the feed has followed. Duplicate
	// rotate events (the server sends a real one and an artificial one
	// carrying the same position) are counted once.
	Rotations int64
	// ForcedRotations counts the `FLUSH BINARY LOGS` statements the feed
	// issued itself, which only happens when BlockWait sees the buffered
	// position stall. This is the number to watch when the question is
	// whether cutover-time waiting is churning through binlogs; a rising
	// count with a flat Rotations count means we are the one doing it.
	ForcedRotations int64
	// Parks counts, cumulatively, how many times a subscription has parked
	// the binlog reader on one of its soft limits. Summed across the feed's
	// subscriptions.
	//
	// Parking is normal under a write rate the applier cannot match, and a
	// single sustained episode of backpressure produces many parks — flushes
	// release capacity per applied batch, so the reader is woken and re-parks
	// repeatedly while one drain runs. The number to read is therefore the
	// *rate* between status blocks, not the absolute value.
	Parks int64
	// IsParked is true when at least one of the feed's subscriptions is
	// parked at the instant the status block was rendered. Together with
	// Parks this separates the two cases an operator cares about: a rising
	// Parks with is-parked=false is a reader being briefly throttled and
	// recovering, while is-parked=true across consecutive status blocks is a
	// reader being held off for minutes at a time, which is what puts the
	// source's binlog retention at risk.
	IsParked bool
	// FlushShape is how wide a map-mode drain is running right now, and
	// ConfiguredFlushShape is how wide it would run with no AIMD penalty
	// outstanding. Both are taken from the same subscription, so they are
	// always comparable; see mergeFlushShapes for which subscription that is.
	//
	// These are reported for the same reason ActiveWorkers is on the applier
	// row: the number is no longer a constant anyone can assume. Since #1173
	// the width is derived from the instance rather than fixed, so an operator
	// reading a status block has no other way to learn what it is — it is not a
	// flag they set and not a default they can look up.
	//
	// The pair, rather than the effective figure alone, is what makes the AIMD
	// controller legible. A bare `flush=2x250` is ambiguous between a small
	// instance running at its derived width and a large one that contention has
	// halved twice, which are opposite situations. The controller does log each
	// step it takes, but those are events in a log that may be hours deep on a
	// migration measured in days, whereas this is state, re-rendered every
	// status block — so a width that is stuck down is visible without going
	// looking for it, and so is its recovery.
	FlushShape           FlushShape
	ConfiguredFlushShape FlushShape
}

FeedStats is a point-in-time summary of what the change feed has been doing. It exists so the runners can fold the feed's activity into the binlog row of their single periodic status block, instead of the feed logging about itself on its own schedule (see github.com/block/spirit/issues/329).

The zero value means "nothing to report yet" and renders as a feed that has not flushed.

func (*FeedStats) MergeSubscriptions added in v0.18.0

func (s *FeedStats) MergeSubscriptions(subs []Subscription)

MergeSubscriptions folds the per-subscription figures — the park counters and the flush shapes — into stats. Every Source calls it from FeedStats with its own subscriptions.

It is one call rather than each Source walking its subscriptions itself because the rules for combining them are not guessable: parks sum while IsParked ORs, and the flush shape is the *narrowest* effective one paired with the configured width it is narrow relative to. Getting either wrong renders a status block that misreports which feed is struggling, and nothing fails.

Callers must not hold their own mutex: this reaches into each subscription's lock, and subscriptions take the Source's lock on their flush paths.

func (FeedStats) String added in v0.17.0

func (s FeedStats) String() string

String renders the stats as the binlog row of a runner's status block.

The flush figures read as a phrase — "flushed 30s ago (took 9µs, 0 rows)" — rather than as three separate duration fields, because two of them are durations of different kinds: how long ago the flush was, and how long it took. Side by side as bare `key=0s` pairs those are genuinely ambiguous; as a phrase the reading is forced.

type FlushShape added in v0.17.0

type FlushShape struct {
	Concurrency int
	BatchSize   int
}

FlushShape is the width of a map-mode drain: how many applier batches run concurrently, and how many rows each of them renders into one statement.

The two travel together because the AIMD controller moves them together — one contention step halves both, so it costs 4x, and reporting either alone would understate what a backed-off feed has given up. They are also the two terms of the lock footprint that produced the back-off in the first place: batch size sets how many records one statement locks, concurrency sets how many such statements are in flight to collide.

func (FlushShape) String added in v0.17.0

func (f FlushShape) String() string

String renders the shape as it appears in the binlog row, e.g. "8x1000".

type FlushShapeReporter added in v0.17.0

type FlushShapeReporter interface {
	FlushShapes() (effective, configured FlushShape)
}

FlushShapeReporter is implemented by Subscription implementations whose drains have an adjustable width and can report it. Optional, for the same reason ParkReporter is: a queue-mode-only or out-of-tree subscription that drains serially has no shape to report and contributes nothing rather than having to grow a method.

type MockSource added in v0.18.0

type MockSource struct {
	// Inner is the source to delegate to. Nil means this mock stands alone and
	// every method succeeds without doing anything.
	Inner Source

	// Pos is what Position and CurrentPosition report when Inner is nil.
	Pos string

	// FixedStats is what FeedStats reports when Inner is nil.
	FixedStats FeedStats

	// DeltaLen is what GetDeltaLen reports, and Residual the first return of
	// FlushResidual, when Inner is nil. Both default to zero — a feed with
	// nothing outstanding.
	DeltaLen int
	Residual int

	// Pending makes AllChangesFlushed report a feed still holding buffered
	// changes. The zero value is a healthy feed.
	Pending bool

	// Watermark is what WatermarkEnabled reports; SetWatermarkOptimization
	// overwrites it. Preset it when the test cares what state the optimization
	// started in.
	Watermark bool

	// FlushFn, if set, runs in place of the no-op Flush. It is the hook for a
	// test that simulates the target catching up as buffered changes are
	// applied — the side effect, not just the error, is usually the point.
	FlushFn func(ctx context.Context) error

	// Error injection. Each is returned by its method instead of delegating, so
	// a nil Inner is not required to make one fire.
	StartErr     error
	BlockWaitErr error
	// contains filtered or unexported fields
}

MockSource is the shared Source test double. It lives here rather than in each consumer's test files because every package that is handed a change feed — the checksum, the move runner's reverse feed, a sync runner, this package's own status reporting — wants the same thing from a double, and had grown its own near-identical version of it under a different name (fakeFeed, noopChangeSource, fakeChangeSource, statsFeed, resumeSource). Source is a wide interface, so each copy was ~20 lines of no-op methods around the one or two that carried behaviour, and a method added to Source had to be added five times.

It is a decorator, not a stub. With Inner set, every method delegates, so a test can drive a real feed and still record the calls or inject a failure into one of them. With Inner nil, every method is a success-shaped no-op reading from the fields below, which is what a test that only needs the interface satisfied wants.

Recording is under a mutex, so the counters are safe to read while the code under test runs. The configuration fields are not: set them before the source is handed over.

Prefer it over embedding a nil change.Source, even for a feed a test believes is never touched. Two such stubs existed and both were wrong or one method away from it: promotion through an embedded interface satisfies Source with methods that panic, so growing the interface turns a passing test into a nil dereference at a call site nobody was thinking about. That is exactly what moving FeedStats onto Source did to pkg/migration's status stub. Embed a nil Source only where an unintended call *should* be a panic that names itself — a parameter the code under test must not reach at all.

func (*MockSource) AddSubscription added in v0.18.0

func (m *MockSource) AddSubscription(currentTable, newTable *table.TableInfo, chunker table.MappedChunker) error

func (*MockSource) AllChangesFlushed added in v0.18.0

func (m *MockSource) AllChangesFlushed() bool

func (*MockSource) BlockWait added in v0.18.0

func (m *MockSource) BlockWait(ctx context.Context) error

func (*MockSource) BlockWaits added in v0.18.0

func (m *MockSource) BlockWaits() int

func (*MockSource) Close added in v0.18.0

func (m *MockSource) Close()

func (*MockSource) Closes added in v0.18.0

func (m *MockSource) Closes() int

func (*MockSource) CurrentPosition added in v0.18.0

func (m *MockSource) CurrentPosition(ctx context.Context) (string, error)

func (*MockSource) FeedStats added in v0.18.0

func (m *MockSource) FeedStats() FeedStats

func (*MockSource) Flush added in v0.18.0

func (m *MockSource) Flush(ctx context.Context) error

func (*MockSource) FlushResidual added in v0.18.0

func (m *MockSource) FlushResidual() (int, int)

FlushResidual reports Residual against the mock's own flush count, so a test that drives several flushes sees them as distinct the way a real feed's caller does. Residual stays at whatever the test set it to: the mock does not model a backlog draining.

func (*MockSource) FlushUnderTableLock added in v0.18.0

func (m *MockSource) FlushUnderTableLock(ctx context.Context, locks []*dbconn.TableLock) error

func (*MockSource) Flushes added in v0.18.0

func (m *MockSource) Flushes() int

func (*MockSource) GetDeltaLen added in v0.18.0

func (m *MockSource) GetDeltaLen() int

func (*MockSource) PeriodicFlushStarts added in v0.18.0

func (m *MockSource) PeriodicFlushStarts() int

PeriodicFlushStarts and PeriodicFlushStops report the periodic-flush lifecycle calls. They are counted separately from Starts/Stops because what tests assert on them is a balance — a checker that stops flushing for a snapshot has to start it again — and folding them in would hide that.

func (*MockSource) PeriodicFlushStops added in v0.18.0

func (m *MockSource) PeriodicFlushStops() int

func (*MockSource) Position added in v0.18.0

func (m *MockSource) Position() string

func (*MockSource) SetWatermarkOptimization added in v0.18.0

func (m *MockSource) SetWatermarkOptimization(ctx context.Context, enabled bool) error

func (*MockSource) Start added in v0.18.0

func (m *MockSource) Start(ctx context.Context) error

func (*MockSource) StartFromPosition added in v0.18.0

func (m *MockSource) StartFromPosition(ctx context.Context, pos string) error

func (*MockSource) StartPeriodicFlush added in v0.18.0

func (m *MockSource) StartPeriodicFlush(ctx context.Context, interval time.Duration)

func (*MockSource) Starts added in v0.18.0

func (m *MockSource) Starts() int

Starts, Flushes, BlockWaits, Stops and Closes report the calls made so far. Starts counts Start and StartFromPosition together, and Flushes counts Flush and FlushUnderTableLock together: a test asserting on either usually cares that the feed was driven, not which entry point did it.

func (*MockSource) Stop added in v0.18.0

func (m *MockSource) Stop()

func (*MockSource) StopPeriodicFlush added in v0.18.0

func (m *MockSource) StopPeriodicFlush()

func (*MockSource) Stops added in v0.18.0

func (m *MockSource) Stops() int

func (*MockSource) VerifyRowAtNextChange added in v0.18.0

func (m *MockSource) VerifyRowAtNextChange(ctx context.Context, watch RowWatch, verify RowVerifier) error

VerifyRowAtNextChange stands in for a stream on which no matching change ever arrives: the caller's budget ends the wait, which is what a row that went quiet looks like. A test that needs a change delivered embeds MockSource and overrides this — scripting an event is specific enough to the test that a hook field here would not save it anything.

func (*MockSource) WatermarkEnabled added in v0.18.0

func (m *MockSource) WatermarkEnabled() bool

WatermarkEnabled reports the last value passed to SetWatermarkOptimization, or the preset Watermark if it was never called.

type ParkReporter added in v0.17.0

type ParkReporter interface {
	ParkStats() (parks int64, parked bool)
}

ParkReporter is implemented by Subscription implementations that apply backpressure to the change reader and can report on it. Optional — unlike Source.FeedStats, which is required — because a subscription that never parks has nothing to report rather than something it declines to say, so an absent implementation is not a gap in the status block.

type ParkedRow added in v0.18.0

type ParkedRow struct {
	// contains filtered or unexported fields
}

ParkedRow is a row change an armed verification is waiting for: what RowParker.Watch hands back when a change matches, and what the dispatch calls Release on once it has buffered it.

At most one is armed at a time, which RowParker enforces.

func (*ParkedRow) Release added in v0.18.0

func (w *ParkedRow) Release()

Release holds the reader at this change and wakes the verification. The dispatch calls it once the change has been buffered. Waking from observe instead would let the verification flush before the watched change was in the buffer, so the target would not hold the image it was about to be compared against.

It is a no-op on a nil receiver, which is the overwhelmingly common case: Watch returns nil for every change no verification is waiting for, and the dispatch calls Release unconditionally rather than branching on it.

The abandoned check is what makes it safe to park the reader from inside a dispatch. The dispatch and the verification that armed the watch run on different goroutines, and the verification can be gone by the time the dispatch gets here: HasChanged blocks on the subscription's soft limit, for as long as the backpressure lasts, which is easily longer than the caller's budget. Parking for a verification that has returned stops the feed for the rest of the run, because that verification was the only thing that would have unparked it. Serializing against abandon on w.mu means one of the two always happens: either the park is skipped, or abandon undoes it.

type RowParker added in v0.18.0

type RowParker struct {
	// contains filtered or unexported fields
}

RowParker is everything a Source needs to implement VerifyRowAtNextChange: the gate that holds the reader between events and the single armed watch. Embed one by value, zero value ready, and wire it into three places:

reader loop:  if err := p.Wait(ctx); err != nil { return }   // after the
                                                             // event is read,
                                                             // before it acts
dispatch:     row := p.Watch(tbl, key, image, deleted)
              sub.HasChanged(key, image, deleted)
              row.Release()
the method:   return p.Verify(ctx, watch, verify, s.drain, s.AllChangesFlushed)

The last two arguments are the Source's own, not the parker's: the drain is its inner flush (not its exported Flush — see Verify), and allFlushed is its report of whether that drain landed everything.

That ordering is the whole contract, which is why it lives here and not in each Source: the watch must see the change before it is buffered (so the rewrite count cannot miss one a flush could carry), the reader must park before the verification is woken (so the flush cannot run without the change in the buffer), and nothing past the watched event may be admitted while the target is read.

The two built-in Sources and the out-of-tree ones share this rather than each reimplementing it, because every one of those steps is a silent correctness bug when it is out of order and none of them fails a test that uses a fake feed. See the comment at the top of this file for what the mechanism is for.

func (*RowParker) Verify added in v0.18.0

func (p *RowParker) Verify(
	ctx context.Context,
	watch RowWatch,
	verify RowVerifier,
	drain func(context.Context) error,
	allFlushed func() bool,
) error

Verify is the body of Source.VerifyRowAtNextChange. A Source supplies only the two parts that are its own: drain, which applies what is already buffered, and allFlushed, which reports whether the buffer emptied.

drain must be the Source's *inner* flush, not its exported Flush. Flush ends in BlockWait, which waits for the reader to reach the source's current position — and the reader is parked, by us, precisely so that nothing past the watched event is admitted. That wait can never succeed, so every verification would spend its whole budget in there and time out instead of reaching a verdict.

func (*RowParker) Wait added in v0.18.0

func (p *RowParker) Wait(ctx context.Context) error

Wait blocks the reader while a verification holds the feed, and returns ctx.Err() if the context ends first — which the reader should treat as a shutdown.

Call it once per event, after the event has been read from the source but before it is acted on. Checking it earlier would consume an event and discard it; checking it later would admit one past the watched change.

func (*RowParker) Watch added in v0.18.0

func (p *RowParker) Watch(tbl *table.TableInfo, key, image []any, deleted bool) *ParkedRow

Watch offers a row change to an armed verification and returns the parked row when this is the one being watched. The caller must then buffer the change and call Release on the result — in that order.

Returning nil (no verification armed, or not this row) is the overwhelmingly common case and costs one atomic load. Release is nil-safe, so the dispatch calls it unconditionally rather than branching.

type RowVerifier added in v0.18.0

type RowVerifier func(ctx context.Context, key []any, image []any, deleted bool) error

RowVerifier is handed the event the feed parked at: the row's key, its after-image (nil for a delete), and whether it was a delete. It runs with the reader parked and the change flushed, so the target holds that image and nothing beyond it. Returning an error fails the verification; the reader is unparked either way.

type RowWatch added in v0.18.0

type RowWatch struct {
	Schema string
	Table  string
	Match  func(key []any) bool
}

RowWatch names the rows a verification is waiting for. Match is called with the row's primary key values as the binlog decoded them, in the source table's key order; returning true fires the watch.

type Source

type Source interface {
	// AddSubscription constructs a bufferedMap from (currentTable,
	// newTable, chunker) and registers it. ROW events matching the
	// registered (schema, table) pair are pushed to the subscription's
	// HasChanged. Must be called before Start / StartFromPosition.
	AddSubscription(currentTable, newTable *table.TableInfo, chunker table.MappedChunker) error

	// Start begins streaming from the current source head and spawns the
	// reader goroutine. Returns once the reader is running; the stream
	// itself continues until Close is called or ctx is cancelled.
	// Implementations perform any required validation (privileges,
	// connectivity, server settings) before returning.
	Start(ctx context.Context) error

	// StartFromPosition is the resume-time entry point. It primes the
	// source's internal position to the opaque string previously
	// returned by Position(), then begins streaming as if Start had
	// been called. Implementations validate the position is still
	// resumable (e.g. MySQL: the binlog file has not been purged); an
	// unresumable position is returned wrapped with ErrPositionNotFound.
	StartFromPosition(ctx context.Context, pos string) error

	// Position returns the latest safe-to-resume position as an opaque
	// string. The implementation owns the encoding; spirit never parses
	// it. Advances only at transaction commit boundaries. Returns "" if
	// no position has been observed yet, signaling that a fresh Start is
	// required.
	//
	// Position reports this *running* feed's in-memory progress and does no
	// server I/O — contrast CurrentPosition, which reads the live server head.
	Position() string

	// CurrentPosition queries the source server for its current head position
	// and returns it in the same opaque encoding as Position (so the result is
	// a valid StartFromPosition input for this implementation).
	//
	// It is mechanically different from Position:
	//   - Position returns in-memory state: the safe-to-resume point a *running*
	//     feed has flushed, advancing only at commit boundaries and "" before
	//     the feed has observed anything. No server round-trip.
	//   - CurrentPosition issues a live query and needs no running feed. In
	//     binlog mode it FLUSHes and reads SHOW [BINARY LOG|MASTER] STATUS
	//     (file:offset); in GTID mode it reads @@GLOBAL.gtid_executed (a GTID
	//     set). Because the encoding is per-implementation, this is why the
	//     capture belongs on Source rather than a binlog-only helper.
	//
	// Its purpose is to capture a "start here, as of now" point to hand to a
	// later StartFromPosition — e.g. seeding a reverse feed at cutover, before
	// that feed has been started.
	CurrentPosition(ctx context.Context) (string, error)

	// Flush requests that all registered subscriptions flush their
	// buffered changes to their targets. Blocks until the flush
	// completes or ctx cancels.
	Flush(ctx context.Context) error

	// FlushUnderTableLock is the cutover-time variant of Flush: the
	// caller holds table locks and we drain the in-flight backlog
	// against that quiescent state. locks carries one lock per target
	// server being written to (a single lock for single-target
	// migrations; one per shard for sharded moves) — the applier
	// executes each target's statements under that target's own lock,
	// since LOCK TABLES blocks writes from every other connection.
	FlushUnderTableLock(ctx context.Context, locks []*dbconn.TableLock) error

	// BlockWait blocks until all events received from the underlying
	// stream up to call-time have been delivered to their subscriptions.
	// Used by the runner around cutover to drain the in-flight backlog.
	// Returns when drained or ctx cancels.
	BlockWait(ctx context.Context) error

	// GetDeltaLen returns the total number of pending changes across
	// all registered subscriptions. Used by callers to decide whether
	// the backlog is small enough to consider cutover.
	GetDeltaLen() int

	// FlushResidual reports what the most recently completed flush left
	// behind: residual is the pending-change count observed immediately
	// after that flush, and flushes is a monotonic count of completed
	// flushes. Both are 0 before the first flush completes.
	//
	// This is the quantity that says whether the feed is keeping up, and it
	// has to be sampled here rather than polled by the caller. GetDeltaLen
	// is a sawtooth: it climbs on every sample between flushes and drops
	// when one lands, so a poller observes the residual plus however many
	// writes arrived since the flush. That second term is large enough on a
	// busy table to swamp the residual itself, and it does not average out
	// — a polling ticker and the flush ticker hold a fixed phase
	// relationship whenever their intervals are commensurate, which at the
	// defaults (30s flush) they are for any poll interval that divides it.
	//
	// A caller watching for a feed that is losing ground should compare
	// residuals only across distinct flushes, which is what flushes is for.
	// A residual that stays near zero means the feed is keeping up however
	// heavy the write load; one that climbs flush over flush means work is
	// surviving flushes and accumulating.
	FlushResidual() (residual, flushes int)

	// SetWatermarkOptimization toggles the high/low watermark
	// optimization across all subscriptions. Disabled before
	// checksum/cutover to ensure all changes are flushed regardless of
	// watermark position.
	SetWatermarkOptimization(ctx context.Context, enabled bool) error

	// StartPeriodicFlush spawns a background goroutine that flushes the
	// changeset at the given interval. Used by the migrator to advance
	// the safe-flushed position. Calling Start while a periodic flush
	// is already running or after Close has been called is a no-op.
	StartPeriodicFlush(ctx context.Context, interval time.Duration)

	// StopPeriodicFlush stops the goroutine started by
	// StartPeriodicFlush. Safe to call when no periodic flush is
	// running (no-op).
	StopPeriodicFlush()

	// AllChangesFlushed reports whether the buffered position has been
	// caught up to the flushed position (i.e. no in-flight changes
	// remain). For non-binlog implementations, this is equivalent to
	// "have all received events been applied?".
	AllChangesFlushed() bool

	// VerifyRowAtNextChange waits for the next change to a row matching watch,
	// parks the reader at that event, flushes so the target holds its image,
	// and calls verify with it. The reader is unparked before returning,
	// whatever the outcome. See park.go for what this is for and why the image
	// a change carries is a sound basis for comparison.
	//
	// It returns ErrRowRewritten if a further change to the same row was
	// admitted before verify could run — the image handed over would no longer
	// be what the target holds — ErrFlushIncomplete if the flush could not
	// land it, and ctx.Err() if no change arrived in time. A row that is
	// genuinely hot produces one almost immediately; a row that does not is
	// not the case this exists for.
	VerifyRowAtNextChange(ctx context.Context, watch RowWatch, verify RowVerifier) error

	// FeedStats reports what the runner's status block prints for this feed:
	// rotation and park counters, the shape and timing of the last flush, and
	// how far the reader has buffered. StatusRow merges it across feeds.
	//
	// An implementation with nothing to say returns the zero value, which
	// renders as a feed that has never flushed. This was an optional interface
	// (StatsReporter) so an out-of-tree source would not have to grow a method,
	// but that traded a compile error for a status block that silently goes
	// blank — and a source that cannot say when it last flushed is one an
	// operator cannot tell apart from a stalled one.
	FeedStats() FeedStats

	// Stop ends delivery of events to subscriptions. Everything else stays
	// live: the source keeps reading and tracking its position, and Flush /
	// BlockWait / AllChangesFlushed / Position keep working. Close, not Stop,
	// releases resources. One-way and idempotent.
	//
	// Cutover calls it once the tables are renamed and while it still holds
	// the exclusive lock, so no write can be in flight — after UNLOCK TABLES
	// the first post-cutover write is a race, and those events no longer
	// decode against the subscriptions' TableInfo (see cutover.go). Hence two
	// requirements: Stop must not block, because every write to the table is
	// stalled behind it, and it must leave the source flushable, because a
	// rename that fails ambiguously is retried via Flush and BlockWait.
	Stop()

	// Close releases all resources, cancelling and joining the reader and any
	// periodic flush loop. Subsequent StartPeriodicFlush calls are no-ops.
	// Safe to call more than once.
	Close()
}

Source is the abstraction spirit uses to consume a stream of row changes from a source database. It exists so spirit's replication pipeline is not pinned to the MySQL binlog protocol — alternative implementations (e.g. Vitess VStream) can plug in without touching the applier, the bufferedMap, or any other spirit-side machinery.

The built-in implementation that uses go-mysql's BinlogSyncer lives in this package and backs the existing Client. Out-of-tree implementations construct their own Source value and pass it to spirit via the Move/Migration config.

Lifecycle: construct → AddSubscription(...)* → Start(ctx) OR StartFromPosition(ctx, pos) → Flush / BlockWait / FlushUnderTableLock as needed → Stop() → Close().

Events flow PUSH-style: when a row event matching one of the subscribed tables arrives, the source implementation looks up the Subscription whose Tables() includes that (schema, table) and calls sub.HasChanged(key, row, deleted) directly. There is no Next() / Recv() loop on this interface — the caller registers subscriptions and lets the source drive them.

The surface area is intentionally broad to match the existing binlog-backed implementation so all spirit consumers (pkg/migration, pkg/move, pkg/checksum) program against the interface. Resume-time positions are opaque strings (Position / StartFromPosition) so that alternative implementations can encode whatever they need (file+offset, GTID, VStream position, etc.) without leaking to callers.

func NewAutoClient added in v0.17.0

func NewAutoClient(ctx context.Context, db *sql.DB, host string, username, password string, appl applier.Applier, config *ClientConfig, resumePosition string) (Source, error)

NewAutoClient constructs the built-in change.Source for a server, selecting between the GTID and binlog file:offset implementations:

  • Fresh runs (resumePosition == ""): the server is probed and the GTID client is used when GTIDs are enabled (GTIDEnabled), the binlog client otherwise.
  • Resumed runs (resumePosition != ""): the position's own encoding decides, so the run stays in the coordinate scheme it started with. A GTID-set position requires the GTID client (and errors if the server no longer has GTIDs enabled); a file:offset position uses the binlog client even when the server could serve GTIDs — e.g. a checkpoint written by an older spirit.

The remaining arguments mirror NewBinlogClient / NewGTIDClient, which this delegates to. The chosen implementation is logged on config.Logger.

func NewBinlogClient

func NewBinlogClient(db *sql.DB, host string, username, password string, appl applier.Applier, config *ClientConfig) Source

NewBinlogClient constructs the binlog-backed change.Source. The returned Source talks to MySQL via go-mysql's BinlogSyncer; future alternative sources (e.g. VStream) will live behind their own constructors. config.Applier is required.

func NewGTIDClient

func NewGTIDClient(db *sql.DB, host string, username, password string, appl applier.Applier, config *ClientConfig) Source

NewGTIDClient constructs the GTID-backed change.Source. It mirrors NewBinlogClient: config.Applier (passed via appl) is required.

Most callers should use NewAutoClient instead, which selects between this and the binlog client based on the server's GTID support (fresh runs) or the checkpointed position's encoding (resumes).

type Subscription

type Subscription interface {
	HasChanged(key, row []any, deleted bool)
	Length() int
	// Flush writes the pending changes to the target(s) via the applier.
	// When underLock is true, locks carries the table locks the caller is
	// holding — one per target server — and the applier executes each
	// target's statements under that target's own lock.
	Flush(ctx context.Context, underLock bool, locks []*dbconn.TableLock) (allChangesFlushed bool, err error)
	// Tables returns the tables related to the subscription in
	// currentTable, newTable order. Move-flow subscriptions have no
	// destination-side TableInfo, in which case only [currentTable] is
	// returned. Entries are never nil: consumers (the clients' DDL
	// subscription-match loops, out-of-tree change.Source event routing)
	// iterate and dereference them.
	Tables() []*table.TableInfo

	// ImmutableColumnOrdinal returns the position (an index into
	// Tables()[0].Columns, and thus into each full binlog row image) of a
	// column whose value must never change between the before and after
	// image of an UPDATE, or -1 when no such column is configured.
	//
	// This backs the sharded applier's vindex contract (see
	// applier.MySQLApplier.UpsertRows): modifications are tracked by
	// PRIMARY KEY only, so an UPDATE that changed the sharding column
	// would flush the new row image to its new shard while the old shard
	// silently kept a stale copy. The change source is expected to treat
	// such an UPDATE as a fatal error and cancel the operation — see
	// checkImmutableColumn.
	ImmutableColumnOrdinal() int

	// SetWatermarkOptimization toggles both high and low watermark
	// optimizations. For non-memory-comparable PKs toggling switches the
	// subscription between map mode and queue mode; on such a transition
	// it drains the outgoing store via the applier so only one store has
	// pending entries at a time. Returns the drain error if any.
	SetWatermarkOptimization(ctx context.Context, enabled bool) error

	// Close signals that no further events will be delivered. Any HasChanged
	// caller currently parked on backpressure (e.g. the bufferedMap soft
	// memory limit) is unblocked so the binlog reader goroutine can exit.
	// Close does NOT flush; pending changes are discarded along with the
	// subscription. It is safe to call more than once.
	Close()
}

func NewBufferedSubscription

func NewBufferedSubscription(cfg BufferedSubscriptionConfig) (Subscription, error)

NewBufferedSubscription constructs the default bufferedMap-backed Subscription. It is the public counterpart to feedCore's AddSubscription, which both binlog-backed clients share: out-of-tree change.Source implementations (e.g. strata's pkg/vstream) call this from their own AddSubscription to build a Subscription the runner / copier can drive.

The returned Subscription is not yet wired into a registry — the caller is responsible for storing it and routing row events to its HasChanged method. The internal sync.Cond is initialised before return (matching subscriptionRegistry.AddBuffered) so HasChanged / Flush / SetWatermarkOptimization are safe to call immediately.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL