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

checksum

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: 27 Imported by: 0

README

Checksum

Checksums validate data consistency between two tables. During schema changes, this means comparing the original table with its _new counterpart. For move operations, checksums verify consistency between source and destination tables.

Key Features

  • Column mapping: The checksum uses ColumnMapping to determine which columns to compare between source and target tables. This handles the intersection of columns (every non-generated target column with a source counterpart, including a source column that was generated and is now regular), column renames, and type casting automatically.
  • Type normalization: A CAST operation converts columns to a comparable type before comparison. This enables comparisons when data types have changed and their string representations differ (e.g., TIMESTAMP vs. TIMESTAMP(6)).
  • Repair during initial verification: Run repairs confirmed differences by recopying affected chunks. RunContinuous never repairs; a confirmed divergence aborts the run.
  • Parallel execution: Checksums process chunks concurrently across multiple threads for efficient handling of large tables. The worker count is resizable while a pass runs — see Pacing and scaling.
  • Consistent snapshot or optimistic reads: SingleChecker takes a brief table lock to establish a consistent snapshot segment, so reads within that segment are immune to concurrent modifications. LocklessChecker takes no lock and re-reads a chunk until it matches (see Lockless checksum).
  • Server-side execution: The checksum computation is pushed down to MySQL, with each chunk returning only a CRC32 value and row count to Spirit. This minimizes network overhead and is significantly more efficient than approaches that extract all data for client-side comparison.

Why Checksums Matter

Checksums are a defensive feature against bugs — and, for two specific copy-phase optimizations, a load-bearing part of the copy algorithm rather than a check on it (see Not only bugs below). While Spirit is designed to correctly copy and apply data changes, subtle data corruption can occur during online operations in many ways.

Naive implementations that only compare row counts fail to catch most of these problems—validating the actual data is essential. Common issues include:

  • Trailing space handling: Storage engines and column types may handle trailing spaces inconsistently
  • Special character mangling: Character encoding issues can corrupt special characters during copy operations
  • Character set mishandling: Converting between character sets (e.g., latin1 → utf8mb4) can introduce subtle corruption
  • Timezone conversions: Timestamp values may be incorrectly converted between timezones
  • Lost updates: Race conditions or replication lag can cause updates to be missed during the copy process
  • Type conversion edge cases: Implicit type conversions may produce unexpected results (e.g., floating point precision)
  • NULL mangling: NULLs can be incorrectly replaced by empty strings during data operations

While we do our best to prevent such bugs, we also want to be pedantic when it comes to data integrity. In most cases we have observed that the checksum process takes about 10% of the time as the copy-rows stage, which makes it an easy cost to justify.

There are also some known cases where a checksum failure is not a bug. The canonical one is adding a unique index to non-unique data: the duplicate rows are silently skipped during the copy, and the digest is what notices. Preventing the cut-over is the correct outcome, not a malfunction.

A lossy data type conversion (e.g., VARCHAR(100) → VARCHAR(10) when records exist requiring more than 10 characters) is also refused, but usually earlier than the checksum — MySQL warns on the truncating write and the copy aborts. See Type conversions for which gate catches what. The exception is reducing a DATETIME/TIMESTAMP precision, which MySQL rounds without a warning, so the checksum is what refuses it (see Fractional seconds).

Not only bugs: two copy-phase optimizations are unsafe by design

Everything above is about catching mistakes. There is a second reason the checksum exists, and it is a stronger one: two optimizations in the copy phase are known not to be correct on their own, and a repairing checksum is what makes them safe. They are not latent bugs awaiting a fix — they are positions taken deliberately, because the airtight alternative costs more than the repair does. Automatic repair is in the checksum's Run for this reason, and the initial checksum before cutover is therefore a component of the copy algorithm rather than an audit of it. Which of the two checkers runs makes no difference; both repair.

Two properties make that arrangement sound, and they are worth stating before the cases themselves:

  1. Every one of these optimizations is switched off before the checksum runs. Copy → SetWatermarkOptimization(ctx, false) → drain → checksum, in that order, and the same call also moves a non-memory-comparable key's subscription onto its safe (FIFO) path. So the checksum verifies a closed window rather than chasing a feed that is still allowed to drop events, and a chunk it repairs cannot be re-broken behind it.
  2. Repair is scoped to the initial checksum. It exists to absorb exactly this copy-phase exposure. The continuous checksum that runs during a deferred cutover is a different question — by then nothing should be diverging, so RunContinuous never repairs, for any caller: a divergence that survives a full feed drain returns ErrPermanentDivergence and aborts. Visibility is preferred over a silent recopy while cutover may be imminent, and the resumed run's initial checksum repairs it — see Who repairs, and when.

Three mechanisms are involved, all in pkg/change (see that package's README):

  • KeyAboveHighWatermark — at ingest, discard a change for a key the copier has not reached yet, on the grounds that the copier's own later SELECT will read the row in its current state anyway.
  • KeyBelowLowWatermark / KeyNotYetDispatched — at flush time, defer a change only while a chunk read covering its key is genuinely in flight.
  • The buffered map keys pending changes by utils.HashKey and keeps one row image per key, so a row updated ten times is applied once.
1. Keys that are not memory-comparable

All three compare or hash the key in Go, and for keys that are not memory comparable Go's answer is not MySQL's. Datum.compare falls through to lexicographic byte comparison for unknownType — which is every VARCHAR, CHAR, TEXT, JSON, temporal and FLOAT/DOUBLE/DECIMAL key — and HashKey is Go string equality:

  • 'aa' and 'AA' are the same row under utf8mb4_0900_ai_ci, and two different Go map keys.
  • "ch" sorts after "h" under utf8mb4_czech_ci, and before it in Go.

So a watermark decision can be wrong in either direction — a change discarded that should have been buffered, or buffered that could have been discarded — and two collation-equal keys occupy two map slots while resolving to one MySQL row, which lets the map's non-deterministic iteration order apply their events in the wrong order. Reimplementing MySQL's collation semantics in Go exactly is not practical, so Spirit does not try. Issue #479 records the position in as many words — "checksum will fix any discrepancies" — and TableInfo.PrimaryKeyIsMemoryComparable is the predicate that identifies these keys.

The unsafety is confined to the copy phase, which is what makes it repairable. The SetWatermarkOptimization(ctx, false) call that runs immediately after row copy does double duty for these keys: it stops the watermark filtering, and it drains the buffered map and switches the subscription into FIFO queue mode, which replays events in binlog order and lets the target's own collation-aware uniqueness collapse them onto the right row. Every change from that point on is applied safely, so the checksum is establishing that the rows copied up to that point are correct — and repairing the ones that are not.

2. The binlog visibility window

The second one applies to every key type, memory-comparable or not, and it is the reason the above-watermark discard cannot be made safe by fixing collations alone. MySQL delivers a transaction's row events to subscribers at the binlog sync stage — before the engine-commit stage makes its rows visible to readers. binlog_order_commits=ON (required by preflight) fixes the order of engine commits; it does not close that window. So:

                   binlog sync                engine commit
                   (feed sees T)              (rows readable)
 time  ─────────────────●───────────────────────────●──────────────►
                        │                       t_visible
 feed                   └─ key is above the high watermark → DISCARDED
 copier                         ├─ SELECT of the chunk covering that key
                                └─ its snapshot opens before t_visible, so the
                                   pre-T row is what gets copied
 position                       the next flush publishes a GTID/offset that
                                already contains T — no resume refetches it

End state: the change exists on the source, is absent from the target, is in no buffer, and no resume coordinate will bring it back. An INSERT leaves a missing row, an UPDATE a stale one, a discarded DELETE a phantom. The window is sub-millisecond on a healthy primary, but it widens to the semi-sync ACK round trip (the whole point of "lossless" semi-sync is that data reaches replicas before it is locally visible), to Aurora's commit latency under load, or to the full replication lag when the feed and the copier read from a replica.

migrate and move gate cutover on a mandatory repairing checksum, so this never reaches trusted data — the visible cost is a differencesFound > 0 and a chunk recopy. sync repairs during its initial checksum, so its target can serve a missing/stale/phantom row until that initial verification covers the chunk. After the first clean pass, a confirmed divergence stops the sync. A consumer of pkg/copier + pkg/change that runs no checksum at all has no backstop.

The field signature of a run that hit this is keys_dropped_above_high > 0 in the watermark-toggle log line together with non-zero checksum differences. The full analysis, the four candidate fixes, and a deterministic repro (TestKeyAboveWatermarkVisibilityWindow) are in pkg/change/README.md.

What this means when you read a result

A checksum that reports differences is not automatically a bug report. On a table with a collated string key, or on a source with a wide commit-visibility window, some rate of repaired chunks is the design working as intended. What is a signal is differences that do not resolve: repeated passes re-finding a divergence in the same range is the case both checkers escalate and ultimately refuse to pass (see Chunk repair).

It also means the checksum is not a 10% tax that a sufficiently confident operator could skip. For these two paths it is the only thing standing between an accepted optimization and silent data loss.

Implementations

The checksum package contains two implementations:

  1. SingleChecker - Compares two tables on the same MySQL server. Used by schema changes only with --legacy-checksum
  2. LocklessChecker - An optimistic verifier using ordinary reads and retries. It compares one or more sources against one or more targets, aggregating each chunk across all of them, so it also serves moves (N sources routed onto M targets) and spirit sync (a target on another server). One checker serves both halves of the contract over the same pass loop: Run returns once a pass has verified the whole table, and RunContinuous keeps passing in the background. Used by spirit move, spirit sync, and (by default) spirit migrate.

SingleChecker takes a brief table lock to establish a consistent REPEATABLE READ snapshot; LocklessChecker deliberately does not (see Lockless checksum below).

Direction: lockless replaces single

We intend to replace SingleChecker with LocklessChecker, making lockless the only checksum. Move and sync already use only lockless. Migration defaults to lockless too, and keeps SingleChecker behind --legacy-checksum as a fallback until it is removed.

The code is arranged so that removing SingleChecker is mostly deletion:

  • Everything only SingleChecker uses lives in single*.go: the checker, its topology check and constructor, the snapshot-resume guard, the chunk-size observer, the CRC/count comparison, the continuous-snapshot loop, and YieldTimeout handling.
  • NewChecker has one CheckerConfig.Lockless branch. Removing single means deleting those files, that branch and the Lockless field, plus the "Single-only" section of CheckerConfig.
  • Everything else (Checker, CheckerConfig, the recopiers, autoscaling, row-difference logging) is shared, and is written against the Checker contract rather than a concrete type.

Both use CRC32 with XOR aggregation for chunk comparison. The lockless checker can additionally drain a bounded per-row PK/CRC32 snapshot for unresolved hot ranges.

Comparing the two algorithms

The two checkers are not a fast one and a careful one. They prove different statements, and neither statement implies the other. This section is the argument for why, what each one costs, and where each one is blind.

The premise: Spirit owns the target

Everything below rests on one fact about the topology, and it is easy to read past: Spirit is the only writer to the target table. The _new table, or the destination of a move or a sync, is created by Spirit and written only by the copier, the change feed(s), and the checksum's own chunk repairs. No application touches it.

That asymmetry is why "lockless" is possible at all. The source cannot be held still without a lock, but the target can: it only moves when Spirit writes to it. Three consequences run through the whole algorithm:

  • A target read is a read of something Spirit put there. No write to the target arrives from outside, which is why a retry waits for a feed flush (RetryFlushWait) instead of just a delay — re-reading before one lands could only return the image it already saw.

  • An old source image stays a valid comparison point. The retry rule "target now equals a source CRC we witnessed earlier" is sound only because nothing else writes the target: the target's state is a function of the source's history as delivered by the feed, so reaching a witnessed image means the pipeline carried that image faithfully. If a third party could write the target, matching a stale source image would prove nothing.

  • Parking a feed's reader freezes the rows that feed owns. This is what settling exploits. It cannot stop writes to the source, so it stops them to the target instead, and compares against the after-image the change stream carries — which, with binlog_row_image=FULL, is the source's value at that position.

    The freeze is per row, not table-wide, and that is all the algorithm needs. A park stops one feed's reader; on a multi-source move the other feeds keep applying, and a chunkRepairer repair for some other chunk can be writing the target at the same time. What makes the comparison sound is that nothing else writes this key: a row is settled on the feed of the source that owns it, a key seen on two sources never becomes a snapshot at all, and a row no source holds has no owner and defers (readHotSnapshotRowsAcross, and the note at the top of lockless_settle.go).

The third point is the one worth holding onto: for the rows where it matters, lockless does not weaken the consistency claim, it recovers a genuine point-in-time comparison — by freezing that row on the side it is allowed to freeze rather than the side it is not. What it gives up is not the guarantee; it is the guarantee holding at one instant table-wide.

The algorithms, side by side
 SingleChecker (snapshot)             LocklessChecker (optimistic)
 ───────────────────────────────      ───────────────────────────────────────
 1. flush the change feed             1. read the chunk from source and
 2. LOCK TABLES                          target, no lock, no transaction
 3. flush again, under the lock       2. CRCs equal?      -> chunk verified
 4. assert the feed is empty          3. not equal? re-read after RetryDelay
 5. open N REPEATABLE READ trx           (5s) and one feed flush
    (all see the same instant)        4. target caught up to a source image
 6. UNLOCK TABLES                        we have witnessed? -> verified
 7. read every chunk inside           5. source changed again? -> "hot":
    those transactions                   split the range and recurse, down
 8. release the transactions              to ~128 rows
    at the end of the pass           6. source stable, target still wrong?
                                         -> drain the feed and re-read;
                                            repair the chunk, or fail
                                     7. source never holds still, 10 times
                                         over? -> settle each row against
                                            the change stream itself

Step 7 of the lockless column is the interesting one and has its own section (Continuously updated hot rows). Everything above it is retry and subdivision; it is what makes the algorithm terminate at all on a row that is written continuously.

What each one proves

Snapshot. Every chunk is read inside a transaction whose read view was taken at one instant, T0, behind the table lock. The reads are spread over the wall clock — hours, on a large table — but they all observe T0:

                 T0 = the lock instant
                  │
  app writes ─────┼─────────────────────────────────────────────────►
                  │
                  │   all reads below see the table as it was at T0
                  ▼
   chunk A        ├─read─┤
   chunk B        │      ├─read─┤
   chunk C        │             ├─read─┤
   chunk …        │                    ├──  …  ──┤
                  │                              │           │
                  └─────── one pass (hours) ─────┘           │
                                                 └───────────┘
                                                  not covered by
                                                  the claim

Claim: at instant T0, source and target were equal — everywhere, at once. (Per yield segment — see below.)

The weakness is which instant. T0 is where the pass begins, so by the time the pass ends the claim can be many hours old, and the gap between T0 and cut-over is entirely uncovered.

One snapshot per segment, not per pass. Holding a REPEATABLE READ view open pins undo, so YieldTimeout (24h by default, and --legacy-checksum-yield-timeout lets an operator set it much lower) caps how long one snapshot may live. When it fires, runChecksumWithYield takes the chunker's low watermark, reopens there, and loops back into runChecksum — which takes a new table lock and a new transaction pool. So a pass that yields is not one instant; it is one instant per segment:

   ├──── snapshot 1 ─────┤──── snapshot 2 ─────┤──── snapshot 3 ─────┤──►
   T0                    T1                    T2
     chunks A–F            chunks G–M            chunks N–Z
                         │                     │
                         └─ yield: release the transactions, reopen the chunker at
                            the low watermark, take a new lock, snapshot again

A yield is not the only thing that starts a new segment. Run retries a failed attempt up to MaxRetries times, and an attempt that errored without finding a difference — killed pool connections, for instance — resumes at the low watermark rather than discarding the chunks already verified. That path also re-enters runChecksum, so it also takes a new lock and a new pool. The segment boundary is the same shape as a yield's; it just has nothing to do with YieldTimeout.

Every claim about T0 below should be read as a claim about one segment. With the 24h default and no errors, most passes are a single segment, so the distinction is usually theoretical — but the hours-long pass on a large table is exactly the one that yields, so it is not a corner case.

Lockless. Each chunk is read whenever a worker gets to it, and re-read until the target agrees with a source image the checker has witnessed. That is a weaker statement than "they were equal at some instant", and the difference matters — see below:

  app writes ─────────────────────────────────────────────────────────►
  source        v1 ──────────► v2 ──────────► v3 ──────────────────────►

   chunk A      ├r┤✗ ··wait·· ├r┤✓
                                 └─ target reached v1; source is on v2 now
   chunk B           ├r┤✓
   chunk C              ├r┤✗ ··· ├r┤✗ ··· ├r┤✗ ··· ├─settle─┤✓
                                                             └─ frozen target
                                                                vs the event's
                                                                own after-image
   chunk D                   ├r┤✓

   ✓ = "the target reached a source state we witnessed", not
       "both held it at the same moment". After the last ✓ and
       before cut-over: not covered.

Claim: for every chunk, the target reached a state the source is known to have held. Not that source and target held it simultaneously.

The retry rule is what makes this precise: a chunk passes when the target's CRC equals either the source CRC read a moment ago or the one read at the start of the retry window (lockless.go, the newTgt == item.originalSrc || newTgt == newSrc test). So the source may already have moved from v1 to v2 by the time the target reaches v1, and there was never an instant at which both held v1. What is established is lineage, not simultaneity: the target's state is a function of the source's history as delivered by the feed, so reaching a witnessed image is evidence the pipeline carried that image faithfully. That is exactly the property the premise buys, and it is the right thing to check for a bug in the pipeline.

The weakness, then, is not just that the claim is per-chunk rather than table-wide — it is that it is a claim about delivery, not about a common point in time. The strength is that the evidence is far fresher: the last chunk is verified minutes before cut-over rather than hours after the snapshot that vouched for it.

One exception runs the other way. A row resolved by settling gets a stronger guarantee than a retried chunk, not a weaker one: the owning feed's reader is parked, so nothing can write that row, and the comparison is against the after-image of a specific event at a specific position. That is a genuine point-in-time equality — the only place in the lockless algorithm where simultaneity is actually established. It holds for that row, not for the table: see the premise on what a park does and does not stop.

Neither claim reaches cut-over. Both diagrams end with an uncovered window, and both windows are the same kind of gap: a divergence introduced after a chunk was verified is caught by neither. This is the load-bearing point about what a checksum is for — see Why checksums matter. It is a bug detector, not a serialization point. Cut-over is safe because the change feed is correct; the checksum exists to catch the case where it is not.

Cross-chunk sampling: a different shape of coverage

Lockless reads different ranges at different times, so a row whose primary key changes can migrate out of a range that has not been read yet and into one that already has — and be absent from both readings:

  the row:   id=900  ─────────────►  id=7
             (UPDATE t SET id=7 WHERE id=900)
  ranges:    900 ∈ chunk C           7 ∈ chunk A

  ──────────────────────┬───────────────────┬────────────────────────►
                        │                   │
   chunk A read ────────┘                   │
     source: no id=7 yet                    │
     target: no id=7 yet     ✓ equal        │
                                            │
                 the move happens ──────────┤
                 feed applies delete(900), loses insert(7)
                                            │
   chunk C read ────────────────────────────┘
     source: id=900 gone (moved out)
     target: id=900 gone (deleted)  ✓ equal

           → every chunk reported equal, and id=7 appeared in
             neither side of any reading

Chunks are walked in key order, so "low range first, high range later" is the normal order, not a contrived one.

This does not, however, make a lockless pass blinder than a snapshot pass over the same window. A snapshot's T0 is established at the start of the pass, before any chunk is read, so the move above is after T0 too: the snapshot reads both ranges in their pre-move state, finds them equal, and reports clean as well. The loss falls in the post-T0 window it already does not cover.

That argument generalises, and it is worth saying what would falsify it. For this hole to hide a divergence, the divergence has to be created by the move — the feed dropping one half of a delete/insert pair — which puts it mid-pass, hence post-T0. A divergence that existed before the pass is caught by both: the snapshot mismatches at T0, and lockless mismatches whichever range holds the row when it reads it. On a moved row it is often repaired without either checker's help, because the binlog carries the insert half as a full after-image and applying it overwrites whatever the target held.

So: cross-chunk sampling changes the shape of the coverage — a lockless pass can report every chunk equal without having examined a given row at all — without widening the set of divergences that survive a single pass. What bounds it:

  • The exposure per row is the interval between the two chunk reads, not the length of the pass. More workers narrows it.
  • RunContinuous re-walks from the start, and the next pass reads both ranges after the move.

And a snapshot pass is not categorically free of this either. A pass that re-snapshots at the watermark — on a yield, or on a retry after an attempt errored clean — misses a row that moves from a not-yet-read range into an already-read range across that boundary, for exactly the same reason. The difference between the two checkers is therefore quantitative, not categorical: both re-establish their reference point periodically and acquire this exposure at every boundary. Lockless does it per chunk read; a snapshot pass does it per segment — rarely, rather than never.

Cost and operational profile
SingleChecker (snapshot) LocklessChecker (optimistic)
Proves equality at one instant, across every chunk in a segment per chunk, that the target reached a witnessed source state — lineage, not simultaneity (settled rows excepted: those are point-in-time)
Reference point re-established once per segment — a YieldTimeout (24h default) or a retry that resumes at the watermark on every chunk read
Evidence dates from the start of the current segment each chunk's own last read, so as late as that chunk got to
Read isolation REPEATABLE READ, pinned pool READ COMMITTED, ordinary reads
Locks brief LOCK TABLES on every table none
InnoDB history list grows for the whole pass (read views pin undo); YieldTimeout (24h default) exists only to bound this no growth
Query stalls every query queues behind the metadata lock, and connection pools fill head-of-line while it is held none
Concurrency ceiling fixed at construction — the pool cannot grow once the lock is released, and the ceiling lengthens the lock window in proportion resizable mid-pass
Busy table one pass, regardless of write rate extra reads: retries, subdivision, settling
Temporally blind to anything after the segment's T0, and — across a segment boundary — a row that migrates into an already-read range anything after each chunk's last read, and a row that migrates between two chunk reads (see above)
Cross-server / N sources not supported by SingleChecker; achievable with locks, at a cost that scales with the topology (see below) native — each chunk is read from every source and target and aggregated
Implementation simple substantially more complex
Used by spirit migrate --legacy-checksum spirit move, spirit sync, spirit migrate (default)

None of those rows is a claim about the digest. Both checkers compare a 32-bit CRC32 aggregated with BIT_XOR, plus a row count, so "equal" means equal digest and equal count — two different chunk contents can in principle collide. That caveat is identical for both, and identical to pt-table-checksum's, so it is not part of what distinguishes them; "temporally blind to" above is scoped to time on purpose.

Two rows are worth dwelling on, because both are about cost growing where lockless's does not:

  • A cross-server serialization point must be built, not borrowed — and building it is what costs. One MySQL REPEATABLE READ view is per-server, so there is no single snapshot spanning servers to take. But the equivalent can be manufactured, and Spirit used to: lock the tables on every source and every target, drain every change feed to empty under those locks, open a REPEATABLE READ transaction on each server inside that quiesced window, then release. The resulting snapshots are independent but mutually consistent, because nothing could write between them. This is what the DistributedChecker did, and it worked.

    What removed it (block/spirit#1281) is that the price scales with the topology while the guarantee does not improve. Every server is frozen simultaneously rather than one at a time; the lock window grows with the number of servers, since locks and then transaction pools are established serially across all of them; any single server failing to lock fails the whole pass; and all of it sits on the critical path of a move. Lockless covers the same topologies by reading each chunk from every source and every target and aggregating the CRCs and counts — no window, nothing frozen. A source fronted by a Vitess vtgate is the case where the locking route is genuinely unavailable rather than merely expensive.

  • The lock is brief; the stall it causes is not. LOCK TABLES waits for in-flight statements and then blocks new ones behind a metadata lock. Every blocked query holds its connection, so an application's pool drains head-of-line, and recovery outlasts the lock itself. This is the cost that the bullet above multiplies by the number of servers.

Hot rows, and the lag question

The honest concern about lockless on a continuously written table is a feedback loop: settling a hot row parks the change-feed reader, parking raises apply lag, more apply lag means more chunks read stale and mismatching, and more mismatches mean more hot ranges to settle.

The shape is real, and the cost is real: settling deliberately stalls apply in order to freeze the target (see the premise). What keeps it from running away is that the settling work per pass is bounded, from two different directions depending on the table.

On a large table the bound is throughput. A row is hot because it is rewritten inside the retry window faster than the checksum can read it; for a range to stay hot it has to change every window, and subdivision has already cut it toward ~128 rows. So N simultaneously hot ranges demand write throughput proportional to N, against N distinct key ranges, on one server:

  write throughput on the source
        │
        ├──► bounds how many distinct ranges can change every ~5s
        │          = bounds the number of hot ranges at once
        │
        └──► is also what the change feed has to apply
                   = a rate high enough to make a large table's ranges
                     mostly hot never lets the feed drain, so the
                     migration cannot reach cut-over — settling was
                     never the binding constraint

Note the failure mode there is a stall, not an error. Flush loops until the buffered change count falls below a trivial threshold, treating a BlockWait timeout as a warning and retrying; it returns early only on an apply error from the inner flush, or on context cancellation. Neither is a backlog signal, so a write rate the feed cannot absorb holds the migration short of cut-over indefinitely rather than failing it — something to watch for in the feed's own progress metrics, not an error an operator should expect to see surfaced.

On a small table that argument does not apply — a single continuously updated row can make a one-chunk table entirely hot while the feed keeps up without effort. The bound there is simply absolute size: "entirely hot" is a handful of ranges, so the settling work is a handful of settleBudget windows, not a growing tax.

Note what is not claimed: settling is not one-off work per row. Each continuous pass calls chunker.Reset() and re-walks from the start, so a row that stays hot can be settled again on every pass. What stops that from being continuous is pacing, not convergence — LocklessMinPassInterval (1 hour) puts an hour between passes, so a permanently hot row costs one bounded escalation per hour. The finite pre-cut-over gate is the case with no such gap, and it is bounded by MaxPasses instead.

What bounds it within a pass:

  • Settling is the last resort, not the first. A range reaches it only after MaxHotAttempts (10) observations of a moving source, and only after subdivision has already cut it toward ~128 rows.
  • The whole escalation for a range is bounded at settleBudget (5 seconds), rows are settled one at a time, and a row that produces no event inside its budget is deferred rather than waited on.
  • Parking terminates faster the hotter the row is — it ends at that row's next change. The rows that defeat read-and-compare are the ones this resolves quickest.
  • Within a pass, a row that has passed is not re-parked, so the apply lag a settle incurs is repaid before the pass ends rather than compounding through it.

What to watch, in that order: HotChunksSettledThisPass rising while HotChunksDeferredThisPass falls is the mechanism working. Both rising, pass over pass, is the loop above, and the lever is fewer checksum workers — which the backlog signal already pulls by itself when autoscaling is enabled (see Pacing and scaling).

And a non-converging table ends as an error rather than an unbounded wait: the finite gate stops after MaxPasses (10) with ErrVerificationUnresolved. When more than one pass ran and every pass repaired at least one range, the error also wraps ErrDifferencesExhausted: the repairs cannot close the divergence, so a retry would fail the same way. IsReproducible reports that case, and ErrPermanentDivergence, as verdicts about the data; a bare ErrVerificationUnresolved is not one, because it proves no divergence.

Prior art, and what is new here

The lockless checksum is a hybrid of two established ideas plus one that does not appear to have published prior art.

Borrowed: the chunk digest. CRC32 with BIT_XOR aggregation, pushed down to the server so a chunk costs one row on the wire, is pt-table-checksum's. Both checkers use it unchanged.

Borrowed: the stream is the arbiter, and you pause the consumer, not the producer. This is the DBLog family — Netflix's watermark-based CDC framework, and Debezium's incremental snapshots built on it. Spirit's copier is already modelled on DBLog's buffered producer/consumer pipeline (see pkg/copier). DBLog's chunk-selection trick is the relevant part here: rather than locking the table, it writes a low watermark row, runs the chunk SELECT, writes a high watermark row, then watches its own change log for those two markers and reconciles the chunk against whatever events landed between them. Its brief pause of log processing — not of the application's writes — is the same shape as parking the feed's reader.

What differs, and why. The resolution rule is the fork in the road:

  DBLog / Debezium incremental snapshot      Spirit lockless checksum
  ────────────────────────────────────       ─────────────────────────────
  goal: PRODUCE one correct stream           goal: VERIFY two copies that
        from a snapshot + a log                    another pipeline maintains

  row changed inside the window?             chunk disagrees?
    → drop it from the chunk;                  → re-read after a delay and a
      the log event wins                         feed flush; accept any source
                                                 image we have witnessed
                                             → still hot? subdivide toward
                                               ~128 rows
                                             → still hot? settle it against
                                               the stream's own after-image

  needs a writable marker table on           writes NOTHING to the source;
  the source, so markers land in-band        ordering evidence is out-of-band
                                             (flush completion, park position)

  no target — it emits                       owns the target, so it can
                                             freeze that side instead

Three consequences worth stating plainly:

  • DBLog's rule cannot produce a verdict. "The row changed inside the window, so drop it from the chunk and let the log carry it" is exactly right when you are emitting a stream, and useless when you are checking one: it would systematically decline to verify the hot rows — the only rows where a lost update is hard to catch. A verifier has to do the opposite of dropping them.
  • No markers means no write access to the source. Spirit's verification path writes nothing at all (only a repair writes, and only to the target). So there is no in-band low/high watermark to reconcile against, and the ordering facts come from elsewhere: RetryFlushWait (the target cannot have moved until a feed flush landed) and, for settling, the reader's park position.
  • Spirit has a target to freeze; DBLog does not. DBLog reconciles a snapshot against a log because it has nothing it owns. Spirit owns the target (the premise), which is what makes stopping the consumer a real serialization point rather than just a pause.

New: settling a row against its own next change. VerifyRowAtNextChange has no analogue in either. The watermark bracket bounds a read; it offers nothing for a row being rewritten continuously, which is precisely the case that defeats read-and-compare. Waiting for that row's next event, parking the reader on it, draining, and comparing the target against the event's after-image inverts the difficulty: the hotter the row, the sooner its verdict arrives.

On lineage: this section compares algorithms, which is checkable from both. It deliberately does not claim the design was derived from DBLog or Debezium — the repo records DBLog as the copier's inspiration and pt-table-checksum as the digest's, and nothing more than that.

Which to use

Today: the defaults. spirit migrate, spirit move and spirit sync all use LocklessChecker. Move and sync have no alternative, because no snapshot checker spans servers today — not because a cross-server snapshot is impossible, but because its cost scales with the topology (see What each one proves). --legacy-checksum selects SingleChecker for a migration, as a fallback.

The intended end state is lockless everywhere and SingleChecker deleted (see Direction: lockless replaces single). The snapshot checker has years of production migrations behind it, which is why it is kept as a fallback for migrate until lockless has the same.

Compared with other consistency checks

A chunked digest is not the only way to answer "do these two tables agree", and it is worth being explicit about where it sits, because the alternatives are not worse designs — they are different points on a cost-versus-confidence curve, picked for different jobs. The distinguishing question is how much data has to leave the server:

  mechanism                     what it computes          on the wire
  ───────────────────────────   ──────────────────────    ──────────────────
  row counts over a time window COUNT(*) on each side     two integers
  chunked digest                CRC32 + BIT_XOR per       one row per chunk
    (Spirit, pt-table-checksum)   chunk, server-side
  row-by-row comparison         every column of every     the whole table
    (e.g. Vitess VDiff)           row, in a client
Row counts over a timestamp window

The cheapest possible check: count rows on both sides within a bounded updated_at range and compare. Two integers cross the wire, so it can run continuously and near-free, which is a genuine and useful property — it is a good smoke signal.

What it cannot be is a cut-over gate, for two reasons:

  • It only sees cardinality. Any modification that preserves the row count is invisible: an in-place UPDATE, a charset mangling, a timezone shift, a NULL that became an empty string, a truncated VARCHAR. Those are most of the bug classes a checksum exists to catch. A delete and an insert inside the same window cancel exactly.
  • It has a schema dependency that is also a correctness dependency. It needs an indexed timestamp column that every write path maintains. Any code path that modifies a row without advancing updated_at is invisible to it, and that is a property of the application, not of the checker — so the check cannot establish its own soundness.
Row-by-row comparison in a client

Stream both sides in key order and compare column values in application code. Vitess's VDiff is the well-known implementation of this shape.

It has two real advantages, and the second is the more interesting one:

  • Discrepancies are already localized. The comparison knows which row differed and how, with no second step.
  • It does not depend on both servers rendering a row the same way. A digest is computed by the server over a text rendering of the row, so the two sides must be made to render comparably — which is why Spirit carries a ColumnMapping and a pile of CAST machinery, and why a mis-specified cast shows up as a false mismatch. A comparator in Go applies its own type-aware rules and sidesteps that class of problem entirely. Spirit has been bitten here: text-mediated comparison inherits the server's rendering semantics, including cases where MySQL's own JSON parser does not round-trip a document bit-for-bit (see the castExpr notes in pkg/table and Chunk repair on why JSON is read bare).

The cost is the wire and the deserialization. Every column of every row has to be transferred and materialized to be compared, so the work scales with the size of the table rather than with the number of chunks. For the tables Spirit targets — the design goal is a 10 TiB table inside five days, and checksumming has been observed to take roughly 10% of copy time (it is not a budget anything enforces) — that is not a reasonable shape. A server-side digest returns one row per chunk and is the only reason the verification cost stays a fraction of the copy.

Two things often cited as VDiff drawbacks are worth separating out honestly:

  • Single-threaded comparison is an implementation choice in Vitess today, not something the approach requires. It is a fair thing to note about the tool as it exists, and not an argument against row-by-row comparison as such.
  • "A digest cannot tell you which row is wrong" is true of a single chunk read and false of the algorithm. Spirit narrows a failing range by subdivision — splitHotChunk cuts a mismatching range into up to eleven children and keeps going down toward ~128 rows — and on a confirmed divergence it logs a line per differing row (mismatched, missing on the target, missing on the source). It also drops to genuine per-row comparison when it needs to: captureHotSnapshot reads a bounded per-row PK/CRC32 image of at most 128 rows per side. So the two approaches are the same spectrum, and the difference is that Spirit pays for row-level detail only on the residue rather than for the whole table.
Where that leaves the tradeoff

Digest-first with row-level escalation wins when divergence is rare, which is the case a migration or a move is built around: the copy plus the change feed are expected to be correct, and the checksum is a bug detector whose usual answer is "no differences". Under that assumption, paying per chunk and escalating on the exceptions is strictly cheaper than paying per row everywhere.

The ordering reverses if divergence is common. If a large fraction of rows is expected to differ, the digest's subdivision degenerates — nearly every range splits and then needs per-row reads anyway — and comparing row by row from the start is both simpler and faster. That is a reconciliation workload rather than a verification one, and it is the case where a VDiff-shaped tool is the right instrument.

Spirit is built for the first case, and the honest caveat is that this is an assumption about the workload rather than a property of the algorithm.

Checker contract

NewChecker returns a Checker: its finite Run succeeds only after verification completes. CheckerConfig.Lockless picks which one, and nothing else does:

Lockless checker compares
false (zero value) SingleChecker two tables on one server, under a REPEATABLE READ snapshot taken behind a brief table lock
true LocklessChecker N sources against M targets with optimistic READ COMMITTED reads and a delayed-retry queue, taking no locks; each chunk's CRCs are XORed and its counts summed across every server

Selecting the checker explicitly is what lets there be one Applier: the write path a repair goes through is not also the checker switch.

SingleChecker rejects more than one source or feed rather than using the first and ignoring the rest; it would otherwise verify one source and report the whole topology clean. A checksum that passes by not looking is the one failure mode worth refusing to construct. LocklessChecker requires one feed per source and takes its targets from, in order: TargetDB (one source only), the applier's GetTargets, or the lone source itself. Several sources with no target named is an error for the same reason. Applier targets that share a handle are read once: a chunk read carries no key range, so each would otherwise return the whole table's rows and double the count. Sources must be distinct handles, because each is paired with its own feed.

Both checkers are configured from the one CheckerConfig. The fields common to both (concurrency, autoscaling, throttler, metrics sink, logger, MaxRetries, Watermark) apply whichever is selected; the rest are documented in the section for the checker they belong to, and are ignored by the other. YieldTimeout is snapshot-only — lockless reads are short by construction and hold no snapshot to yield — and the retry, splitting and pacing fields are lockless-only.

Repair policy is not configured: it follows the method called, for both checkers. Run repairs — a confirmed divergence is rewritten from the source through the Recopier the factory builds over the required Applier, and verification continues. RunContinuous never repairs — a confirmed divergence returns ErrPermanentDivergence. MaxRetries bounds whole-run attempts for both. Every runner reuses the one factory result for RunContinuous, which owns pacing, chunker resets, feed flushing, and safe cancellation. ContinuousActive reports whether a pass is running rather than waiting for the next interval, so callers can report throttling accurately. Lockless passes retain their optimistic retry/defer behavior in both modes.

Once RunContinuous starts, ResumeWatermark stays empty, including after a clean background pass. Copy progress is retained, but a restarted migration must repeat initial verification. This avoids interpreting a reset background walker or a cleared per-pass mismatch counter as resume evidence.

Cross-server callers such as datasync go through the same factory. Naming a TargetDB says the copy being verified is on another server, which is what makes the factory build a repair path that reads one server and writes the other; it is lockless-only, because SingleChecker locks and snapshots exactly one server (a cross-server serialization point can be built, but the checker that did it was removed — see Cost and operational profile). Such a caller typically runs the feed's periodic flush itself for the whole process rather than per run, and says so with ExternalFlushLoop — otherwise every run starts and stops it, which is what a migration and a move want.

Callers open the chunker before construction unless supplying a nonempty CheckerConfig.Watermark. In that case the factory opens it at that watermark, for both checkers: a watermark means the prefix below it was read on both sides and observed equal, which is the same claim whichever checker observed it.

Persist Checker.ResumeWatermark(), never the chunker's traversal watermark. Snapshot checkers suppress evidence after differences. Lockless verification has no equivalent gate and does not need one — optimistic reads mismatch routinely on a table taking writes and almost all of those resolve on retry, so gating on the mismatch counter would discard the watermark on essentially every real migration. What makes the prefix trustworthy instead is that a chunk is reported to the chunker only once it has resolved clean, so a chunk that was repaired, deferred as hot, or split parks the watermark below itself and a resumed run re-verifies from there. The published answer never moves backwards: a retried attempt, and the second pass within an attempt, both re-walk from the start of the table, and the further-along prefix stays valid because the change feed has been keeping it equal.

Checker.SetThrottler is required for every finite implementation. Tests can use the shared checksum.MockChecker, whose throttler setter is a no-op and whose mismatch count can be changed safely while a runner is active.

The optional StatusReporter capability exposes a structured ChecksumStatus. StatusSummary and StatusRow format it, falling back to basic progress and pacing for checkers without that capability. Runners need no concrete checker assertions. Optimistic status distinguishes scan completion from verification completion.

Checksum Algorithm

The checksum is computed using (simplified version):

SELECT BIT_XOR(CRC32(CONCAT(...))) as checksum, COUNT(*) as c 
FROM table 
WHERE <chunk_range>

This approach:

  • Computes a CRC32 hash for each row (using concatenated column values)
  • Aggregates the row checksums using XOR (BIT_XOR)
  • Provides both a checksum value and row count for verification

The actual implementation includes additional handling:

  • NULL normalization: Uses IFNULL() and ISNULL() to ensure NULLs are consistently represented
  • Type casting: Applies CAST operations to convert columns to the target table's type for comparable string representations (see Type conversions)

The CRC32 + XOR aggregate technique for table checksumming was pioneered by pt-table-checksum from Percona Toolkit, which established this as a reliable method for verifying data consistency in MySQL. This same approach has since been adopted by other database tools, including TiDB's data migration and verification utilities, demonstrating its effectiveness for distributed database scenarios.

Type conversions

A schema change usually changes how a value is stored, not what the value is. Some of those changes also change the text MySQL renders for the value: TIMESTAMP → TIMESTAMP(6) adds .000000, DECIMAL(10,2) → DECIMAL(12,4) adds trailing zeros, dropping ZEROFILL drops the leading ones, widening a BINARY(N) changes how far the value is zero-padded. The digest is built out of CONCAT(), which is a string operation, so for those a raw comparison would report a difference on every row while the copy is perfect.

Plenty of conversions render identically and need no help — INT → BIGINT still prints 42 on both sides. The cast is applied uniformly anyway, so that the comparison never depends on which of the two a given ALTER turns out to be.

The rule is one line: both sides are CAST to the same type, derived from the target column's type, and only then hashed. ColumnMapping.ChecksumExprs builds the two expression lists, and the cast type comes from the target table — including for the source-side query — with one exception: DATETIME/TIMESTAMP take the wider of the two columns' fractional-second precisions (see Fractional seconds), which is the only place the source column's type is consulted. Where the ALTER renamed a column, the source SQL references the old name, and each side's type is looked up under that side's column name. Each column contributes two things to each side:

IFNULL(CAST(`col` AS <cast type>),'') , '#' , ISNULL(`col`)

— the cast value, and a separate NULL flag so that NULL and '' cannot hash alike. The '#' separators keep content from shifting across a column boundary undetected.

Why the cast is load-bearing

The fractional second makes it concrete. Widening TIMESTAMP → TIMESTAMP(6) leaves the instant untouched but makes the target render .000000, and that is enough to change the hash (real CRC32 values, MySQL 8.0.43 — the same instant stored on both sides):

 source column: ts TIMESTAMP        target column: ts TIMESTAMP(6)
 source value:  2026-01-01 10:00:00 target value:  2026-01-01 10:00:00.000000

 raw:   CRC32(CONCAT(ts))                        3432137608  vs  788709475   MISMATCH
 cast:  CRC32(CONCAT(CAST(ts AS datetime(6))))   788709475  vs  788709475   equal

Nothing is wrong with the copy in that example — the source cannot hold a fraction, the target renders one, and only the cast makes the two comparable. The same shape appears for scale and padding:

 DECIMAL(10,2) -> DECIMAL(12,4)    "169.09"  vs  "169.0900"
   raw                             1865833143  vs  2558327555   MISMATCH
   cast to decimal(12,4)           2558327555  vs  2558327555   equal

 BIT(8) -> BIT(16)                 0x01      vs  0x0001
   CAST(v AS char)                 2768625435  vs   920527465   MISMATCH
   cast to unsigned                2212294583  vs  2212294583   equal

 INT(5) ZEROFILL -> INT            "00042"   vs  "42"
   raw                             3233738973  vs   841265288   MISMATCH
   cast to signed                   841265288  vs   841265288   equal

 BINARY(2) -> BINARY(8)            0x6162    vs  0x6162000000000000
   cast to the target's binary(8) pads both sides to the same length
What each type casts to

castableTp (pkg/table/utils.go) maps a column type to the SQL-standard type CAST accepts, and checksumCastTp applies it to a source/target pair. The width is stripped for most types and deliberately kept for three:

Column type Cast to What that normalizes away
TINYINT … BIGINT signed display width, ZEROFILL padding
the UNSIGNED forms unsigned as above
TIMESTAMP(N), DATETIME(N) datetime(N), at the wider of the source's and target's N fractional-second padding on a widening, without hiding a lost fraction — see Fractional seconds
DECIMAL(M,D), including UNSIGNED/ZEROFILL the target's decimal(M,D), without UNSIGNED/ZEROFILL trailing-zero scale. CAST rejects an UNSIGNED modifier (1064), and an unsigned value always fits the signed decimal of the same M,D
BIT(N) unsigned, or binary when the source is a string, binary or JSON column the width. CAST(bit AS char) returns the raw stored bytes at the column's own width (0x01 vs 0x0001 above); unsigned compares the value, and BIT(64) fits. The exception exists because MySQL stores a string written into BIT as its bytes, not as a parsed number (VARCHAR '5' becomes 0x35, i.e. 53), while every other source type is stored as its numeric value. binary rather than char, because a char cast re-encodes the source into utf8mb4 and a latin1 'é' stored as 0xE9 would no longer match
FLOAT, DOUBLE char
VARCHAR, CHAR, TEXT, ENUM, SET char CHARACTER SET utf8mb4 charset and collation changes; utf8mb4 is the superset every other charset can be compared in
BINARY(N) the target's full binary(N) zero padding on a widening. A plain CAST(… AS binary) does not pad, and binary(0) would truncate every value to nothing
VARBINARY, the BLOBs binary
VECTOR (MySQL 9.7+) binary CAST(… AS char) is rejected outright by the server (ER_WRONG_ARGUMENTS)
JSON asymmetric, see below
everything else — DATE, TIME(N), YEAR, … char CHARACTER SET utf8mb4 the default branch, so an unlisted type is compared as the bytes CAST(… AS char) returns for it — its rendered text

The fallback matters for one case in particular: TIME(N) is not in the datetime row, so it takes the default branch and renders its fraction in full (CAST(TIME'10:00:00.100000' AS char) → 10:00:00.100000, CRC32 4947716). A sub-second divergence in a TIME column is therefore visible without the precision handling DATETIME/TIMESTAMP need.

A type that falls to the default branch when it should not is fail-closed for a value-preserving widening — a perfect copy fails the digest on every row, the repair cannot converge, and the cut-over is refused — so a missing case shows up as an unusable migration rather than as data loss. DECIMAL(M,D) UNSIGNED and BIT(N) were both such cases until block/spirit#1291.

ENUM and SET are compared as their string value, not their stored ordinal, so appending values to the end of an ENUM list is invisible to the digest — correctly, since no row's value changed. (Reordering and middle-insertion are refused in preflight for an unrelated reason: the binlog replay path receives ordinals and decodes them against the source's element list.)

JSON is the one type cast differently on the two sides: the source renders to text and re-parses, the target renders what is stored. That asymmetry asserts the text-image contract every JSON write path in Spirit actually delivers, and is not a normalization — the reasoning, and the MySQL parser bug behind it, are in the castExpr comment in pkg/table.

Lossy conversions, and which gate catches them

Spirit only supports conversions that preserve the data. A few conversions are refused in preflight by their shape — enumSetRemoval (pkg/check/) rejects ENUM/SET → numeric and SET → ENUM outright — but no preflight check measures your data against the narrower type, so for the general case nothing has looked at a single row by the time the copy starts. Two different mechanisms catch it afterwards, and it is worth knowing which, because they fail at different times and look nothing alike.

Most lossy conversions abort during the copy. Spirit connects with a non-strict sql_mode (NO_AUTO_VALUE_ON_ZERO and nothing else, pkg/dbconn/conn.go) and the copy writes with INSERT IGNORE, so MySQL downgrades what would otherwise be an error into a warning and stores a coerced value. Spirit does not let that pass: the row-copy and change-feed writes go through dbconn.RetryableTransaction, which runs SHOW WARNINGS after each statement and promotes any warning it finds to a fatal UnsafeWarningError. The one exemption is the duplicate-key warning, deliberately ignored under IgnoreDupKeyWarnings; everything else is fatal, including the range-optimizer capacity warning (3170), which gets its own message but is equally terminal. A VARCHAR(100) → VARCHAR(10) migration therefore fails on the first chunk containing a too-long value:

 INSERT IGNORE INTO _new (…) VALUES ('a-very-long-value-indeed')
 SHOW WARNINGS  ->  Warning  1265  Data truncated for column 'v' at row 1
                    => UnsafeWarningError, copy aborts

Reducing a DECIMAL's scale behaves the same way, via Note 1265 — the warning level is not consulted, only its code.

The final change-feed flush under the cutover table lock checks the same warnings through dbconn.TableLock.ExecUnderLockWithWarnings. It reads SHOW WARNINGS on the locking connection immediately after each DML statement, so a truncating value that arrives only in that final batch also stops cutover. These locked writes are autocommitted: detecting a warning does not roll back the write, but the shadow table is not promoted. DDL uses ExecUnderLock separately, so warnings do not hide a successful rename's ownership transfer.

What reaches the checksum is what MySQL does not warn about. The canonical case is adding a UNIQUE index to non-unique data: the duplicate row is skipped by INSERT IGNORE with a 1062 that Spirit is deliberately ignoring, so the copy completes with rows missing and the digest is what notices. The checksum then repairs the chunk, re-copies, skips the same row again, finds the same difference, and exhausts its retries into a hard error — cut-over refused. That is the designed outcome: the failure is the safety mechanism working, which is why it is listed under cases where a checksum failure is not a bug. Reducing a DATETIME/TIMESTAMP precision is the other one, described next.

Fractional seconds

CAST(… AS datetime) rounds to the second rather than truncating (10:00:00.999999 → 10:00:01), so the precision a temporal column is cast to decides what the digest can see. A single precision cannot serve every case:

  • Widening, TIMESTAMP → TIMESTAMP(6): the target renders .000000 and the source renders nothing, so the precision must be high enough that both render the same instant the same way.
  • Both sides fractional (a move or sync, where the types match, or any ALTER on a table that already has a fractional temporal column): a divergence below a second must be visible, so the fraction must not be rounded away.
  • Narrowing, TIMESTAMP(6) → TIMESTAMP: MySQL rounds a fractional value into a second-resolution column without any warning at all, so there is nothing for SHOW WARNINGS to promote and the copy gate cannot catch it. The digest is the only gate left, and it must compare the source's fraction against the target's rounded value.

checksumCastTp therefore casts both sides to datetime(max(Ns, Nt)) — the wider of the two columns' precisions. The narrower precision looks equally plausible and does not work: for TIMESTAMP(6) → TIMESTAMP it is datetime(0), both sides round to 10:00:01, and the lost fraction is invisible. Measured on MySQL 8.0.43:

case cast to the wider cast to the narrower
widening, TIMESTAMP → TIMESTAMP(6)
nothing below a second to lose, so this must not fire
788709475 vs 788709475
equal ✓
3432137608 vs 3432137608
equal ✓
narrowing, TIMESTAMP(6) → TIMESTAMP
source .999999, target rounded to 10:00:01 — the fraction really is gone
3755639858 vs 3819487485
MISMATCH ✓
3147133726 vs 3147133726
equal ✗ — the loss is hidden
both sides fractional, values diverge
.200000 vs .100000
1657430376 vs 3831370694
MISMATCH ✓
same as wider — both columns are (6), so the two agree

The widening stays clean because a second-resolution source cast up to datetime(6) renders the same .000000 the target stores.

The consequence for a precision-reducing ALTER is that it is judged by the data, not by its shape: if no row holds a fraction the narrowing loses, both sides render identically and the migration succeeds; if any row does, that chunk mismatches, the repair re-copies it into the same rounded value, and the checksum exhausts its retries — cut-over refused, after the copy. The widening precision is only taken from the source when the source is itself a DATETIME/TIMESTAMP; for any other source type (e.g. VARCHAR → DATETIME) the target's own precision is used.

Chunk repair

When a chunk mismatches during Run, the chunk is repaired rather than the run failing immediately. Every implementation repairs the same way:

  1. DELETE the chunk's key range on the target — this is what removes rows the source no longer has, which a pure upsert could never do.
  2. SELECT the chunk's rows from the source into Spirit.
  3. Write them back through the applier, the same buffered write path the copier and the binlog apply use.

Repairs are serialized (one chunk at a time) and run under a cancellation-detached, time-bounded (10 minute) context, so a chunk is never left deleted-but-not-rewritten. Rows are read and submitted in batches, so what Spirit holds is one batch plus the applier's queue — bounded by the write pipeline, not by the size of the chunk.

Going through the applier — rather than the REPLACE INTO _new (...) SELECT ... FROM original that SingleChecker used historically — matters for lock footprint, and it is what makes large checksum chunks safe to repair without splitting them first:

  • INSERT ... SELECT is a locking read under REPEATABLE READ: it takes shared next-key locks on every source row it reads, so application UPDATEs to the original table blocked behind a repair for as long as the statement ran. Reading into Spirit is a plain consistent read and locks nothing.
  • The write side is split into bounded statements (applier chunklets) instead of one statement whose row locks are held for its whole duration.

Two consequences of the applier being the write path:

  • It writes with INSERT IGNORE, not REPLACE. Rows inside the key range were just deleted, so nothing there conflicts; a row that collides on a UNIQUE secondary key with a row outside the range is skipped instead of clobbering that row. The chunk then stays diverged, the next attempt re-flags it, and retries exhaust into a hard error — the correct outcome for a lossy ALTER such as adding a unique index to non-unique data. The count of skipped rows is logged.
  • JSON columns are read bare, with no round-trip cast. The read/write pair is already text-mediated (the SELECT renders each document to text; the applier writes it back as a literal the target re-parses), so a repaired row lands as exactly the one-text-round-trip image the checksum's source side predicts. Casting on top would apply parse∘render twice, which does not converge for the doubles MySQL's JSON text parser misrounds — see castExpr in pkg/table.

The read is not synchronized with the change feed: a row deleted on the source after the repair reads it is written back if the feed has already applied that DELETE to the target. The chunk stays diverged and the next attempt repairs it again, converging once the churn on that key range stops. Cut-over requires a pass that finds no differences at all, so sustained delete churn on one chunk costs attempts, never a bad cut-over.

Who repairs, and when

Repair follows the method called, not a configuration field, and it is the same for every runner and both checkers:

Method Used for A divergence means
Run Initial checksum — migrate, move, sync Repair the chunk, re-verify it on a later pass, fail only if it keeps coming back
RunContinuous Sentinel wait — migrate, move; after the initial checksum — sync ErrPermanentDivergence; the run aborts

The reason repair exists at all is the copy-phase exposure the initial checksum stands behind: the row copy runs with optimizations that are only correct given a repairing check afterwards. A continuous pass is in a different position. It runs after that check has already passed and after the optimizations were disabled, so nothing should be diverging any more — and while a cut-over may be moments away, a loud failure is worth more than a quiet recopy. That is why RunContinuous never repairs. Migration and move clear the persisted checksum watermark before continuous verification starts, so the resumed run's initial checksum is a full pass that repairs the chunk. Sync has no cut-over, but the same reasoning holds: after its first clean pass nothing should diverge, so a divergence stops the sync rather than being rewritten silently.

Pacing and scaling

SingleChecker paces itself against the same throttler the copier uses. Two things are separate here:

  • The hard stop is not opt-in, but it reacts only to load. Before dispatching each chunk the checker calls Throttler.BlockWait, so a checksum pauses when server load says to. Chunks already in flight are never interrupted: the checksum stops dispatching rather than abandoning work, because an aborted chunk is wasted I/O that must be redone from the same watermark. Wire the throttler with Checker.SetThrottler — runners build the checker before their throttlers are open.

    Whatever throttler a checker is given is narrowed by loadOnlyThrottler to the children implementing throttler.GradualThrottler — in practice the Aurora signals. Binary signals, meaning replica lag, are dropped, and a checker given only those runs unpaced. This is not a shortcut but a correctness point: a checksum reads inside a REPEATABLE READ snapshot and writes nothing to the binlog, so it cannot be the cause of replica lag and pausing it cannot reduce that lag — while the pause extends the pass, holding the snapshot open and pinning undo the purge thread cannot advance past. The lag throttler also fails closed on stale polling, so an unreachable replica would stall dispatch until the yield timeout with the snapshot still held. Load is different in kind: a checksum does add read load to the primary, so backing off on load both works and is warranted.

    The one part of a checksum that replicates is a chunk repair, and it is deliberately left unpaced — repairs are rare and small, and blocking one incurs exactly the snapshot-hold cost the narrowing exists to avoid.

  • Scaling is opt-in via AutoscaleConfig, and adjusts the live worker count during a pass. Two signals drive it:

    • The throttler's continuous utilization signal, applying the same zone law as the copier (see pkg/autoscale). Only the Aurora throttlers provide this signal, so this is where growth comes from and it is Aurora-only.

    • The change-feed backlog, whose signal is available everywhere — unlike utilization, it needs nothing from the throttler. The feed flushes concurrently with the checksum, and its backlog gates cut-over — if it grows unboundedly the binlogs may be purged before a resume can replay them. If the feed is losing ground, the checksum's reads are winning a race against writes that have to finish, so a worker is shed. It is the one lever that needs nothing from the utilization signal, but it lives in the scaler, which exists only when autoscaling engages (an Aurora target), so it always acts alongside utilization.

      Available everywhere does not mean active everywhere: shedding lives in the scaler, and the scaler is only constructed when scaling is enabled. Without the opt-in a checksum has the hard stop and nothing else — it never moves its own worker count in either direction.

      What counts as "losing ground" is specifically a rising post-flush residual, not a rising backlog — and the residual is read from change.Source.FlushResidual, which the feed records at flush completion, rather than polled.

      Polling cannot recover this quantity. The pending count is a sawtooth: it climbs on every sample between flushes and drops when one lands, so its slope says nothing about whether the feed is coping (at 5s control tick and 30s flush interval, the rising edge alone is six samples long). Nor do window minima work, which is the subtler trap: a poll lands some offset φ after the flush and therefore reads residual + writeRate·φ. Because the flush interval is an exact multiple of the tick, φ is fixed for the whole pass by the arbitrary phase between two independent tickers — so on a busy table the sampling term can exceed the threshold on its own, and a rising write rate on a fully-draining feed produces rising apparent residuals indistinguishable from a feed falling behind.

      Because the signal is keyed on the feed's flush counter, silence has to be handled explicitly rather than latched: a flush that keeps erroring returns before recording anything and the periodic flusher logs the error and carries on, so the counter can freeze while the backlog grows without bound (a flush that merely takes minutes freezes it too). After csStaleFlushTicks ticks with no new flush the scaler stops trusting the standing verdict and freezes increases — growth and recovery alike — logging once per episode. It deliberately does not shed on it: a frozen counter says the signal stopped, not which way it was heading. This also covers a lockless checker with several feeds (a move), whose aggregate counter is the minimum across feeds, so one stuck feed freezes the signal for all of them.

      Reading the residual where the feed defines it removes the write rate from the signal entirely. Successive residuals are then compared across distinct flushes, with hysteresis in both directions: csBacklogHysteresisFlushes consecutive flushes must agree before the verdict changes. The exit condition matters as much as the entry one, because shedding is one step per flush while growth is one step per two ticks — a single favourable flush clearing the verdict would let the grows outpace the sheds and the controller would drift up while the feed fell further behind. While a verdict holds it suppresses growth as well as driving shedding.

The opt-in is the axis that matters most, so the capability table is keyed on it rather than on the server:

hard stop shed on backlog grow
scaling disabled (--skip-autoscaling, or a server autoscaling cannot engage on) on load only no no
scaling engaged (Aurora, at least autoscale.MinVCPUs) on load only yes yes (utilization law)

The hard stop is the one behavior that needs no opt-in — but "on load only" carries weight in every row: the load signal comes from the Aurora throttlers, so on stock MySQL there is nothing for the hard stop to react to and a checksum there is unpaced. AutoscaleConfig.Enabled is what builds the scaler, and the scaler is where both shedding and growth live. It is set only when autoscaling engages (concurrency.Engage): --skip-autoscaling is not set, and every target must provide an Aurora load signal and have at least autoscale.MinVCPUs (a smaller Aurora target gets small-instance mode: one fixed worker, no scaler). On stock MySQL autoscaling does not engage, so there is no scaler and no shedding.

Concurrency is gated by a resizable autoscale.Limiter rather than errgroup.SetLimit, which may not be resized while goroutines are active.

One constraint shapes all of this: the REPEATABLE READ transaction pool cannot grow once the table lock is released. Every transaction takes its snapshot under that lock, so they all see one point in time; a transaction started later would read a newer snapshot and could compare a chunk against changes its siblings cannot see. The pool is therefore provisioned at the autoscale ceiling up front, whether or not scaling is enabled. Over-provisioning costs one connection per idle transaction and no extra history retention, since every read view pins from the same instant. What it does cost is lock-window time: each transaction is started serially under the lock, so the ceiling lengthens that window in direct proportion. That cost is why autoscale.ReadBounds caps the read side at half the instance rather than all of it — for this pool a ceiling is not a hypothesis, it is spent whether or not scaling reaches it.

LocklessChecker uses ordinary reads rather than a pinned snapshot pool. It supports load throttling and autoscaling through its worker limiter; LocklessMinPassInterval and its retry queue govern pass/retry pacing.

Each pass logs a checksum chunk size distribution line (chunk count, duration p50/p90/max, row p50/max, and how many chunks hit table.MaxDynamicRowSize). The row-capped count is the useful one: the checksum aggregates server-side and returns one row per chunk, so its chunks are far cheaper than the copier's, and if most are pinned at the row ceiling then that — not the table.ChunkerDefaultTarget time budget — is what bounds them.

Lockless checksum

Run returns only after a complete pass with no repairs and no deferred ranges (retrying the whole run on transient failure; RunUntilClean is one such attempt). RunContinuous keeps checking until cancelled. All of them drive the same pass loop; how long the caller runs it does not change its correctness criteria.

LocklessChecker verifies a target that is still converging toward the source over a live replication feed, so a first-attempt mismatch is expected (the target simply hasn't caught up yet) rather than alarming. It runs in passes: each pass walks every chunk once and then drains a delayed-retry queue until empty. A mismatched chunk is re-read after a short delay (RetryDelay, 5s) and passes once the target's CRC matches a source CRC the checker has witnessed. With a change feed, the retry also waits until every feed has completed a flush since the retry was queued (bounded by RetryFlushWait: two default flush intervals, or twice --flush-interval for spirit sync): the target only moves when a feed flushes, every 30s by default, so an earlier re-read could only see the image it already saw and would spend a hot chunk's attempt budget on a target that had no chance to catch up. A chunk whose source keeps changing (a "hot chunk") cycles to the back of the queue without blocking the pass.

Continuously updated hot rows

A row that is written continuously defeats every read-and-compare strategy the checker has: the frozen source image is stale before the first target read, so every attempt observes a different source and the range is deferred with no verdict — pass after pass. A genuinely diverged hot row and a merely busy one stay indistinguishable for as long as the writes continue.

The way out is to stop reading the source. Any comparison between a SELECT of the source and a read of the target 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 the change stream already carries the answer: with binlog_row_image=FULL, an event's after-image is the source's value for that row at that position. MySQL guarantees it; no read is needed, and nothing has to hold still.

Settling is the terminal step built on that. Once a range has failed MaxHotAttempts observations, each outstanding row is verified against the stream's own image of it, one row at a time:

  1. Ask the feed to wait for the next change to that row and park its reader there (change.Source.VerifyRowAtNextChange). The change is buffered first and the reader parks immediately after, so nothing past that event is admitted.
  2. Drain the feed, so the target holds exactly that image. This is not the exported Flush, which ends in a BlockWait for the reader to reach the source's current position — the reader is parked, so that wait could never succeed. The parked drain applies what is buffered and stops, and reports whether the buffer emptied.
  3. Compare the target row to the event's image, evaluating the same column-mapping checksum expressions used everywhere else against the image itself. (The image is rendered as a one-row derived table whose column types come from the real table, so the CASTs land on a column of the right type.)

A mismatch at step 3 is a real inconsistency: the feed delivered that image and the drain applied it, so apply lag cannot explain a difference. It goes to the ordinary repair path (or is fatal, per the policy below) instead of being deferred again.

This terminates in the opposite direction from a lock: the more often the row is written, the sooner its next event arrives. The rows that defeat every read-and-compare strategy are exactly the ones this settles fastest, and a row quiet enough that no event arrives inside its one-second budget is one the ordinary poll was already converging on. A delete event is a verdict too, which is what lets an obligation that a row be absent be settled — no SELECT can prove a row will stay absent, because there is nothing to hold.

Nothing here takes a lock. The cost is that the stream is held for the duration of one row's verification, which is why rows are settled one at a time, the whole escalation is bounded at five seconds, and it is reached only after a range has already failed MaxHotAttempts observations. HotChunksSettledThisPass counts it; a rising value alongside a falling HotChunksDeferredThisPass is it working.

Three cases still defer rather than settle, and all three are honesty constraints:

  • No change arrives inside the row's budget. Not the case this exists for.
  • The row was written again before the target could be read. The image no longer matches what the drain left behind, so the comparison would be against a value the target was never meant to hold.
  • The parked drain could not empty the buffer — a batch that lost to lock contention, a key held behind the copier's watermark. The watched change may be among what is left, and reporting that as a divergence would be reporting apply lag, the one mistake this whole path exists to avoid.

A checker with no feed at all (library callers may have none) simply leaves the range where it was before settling existed.

Several sources (a move with N sources) settle each row on the feed of the source it was read from. A key found on two sources is refused as a snapshot (it is either a disjointness violation or a row mid-move, and a per-key image cannot say which copy is the row), so every source row has exactly one owner, and only that owner's stream carries its next change; only that feed is parked. A row that only the target holds has no source, and so no owning feed, when there are several: its obligation is to be absent, and only a delete event from the source it would have come from could settle that, which cannot be identified. It defers, and the ordinary retries carry it.

When a chunk's source CRC is stable across the retry window but the target still disagrees, that is a stable divergence. How the checker reacts is governed by whether it has a Recopier:

  • With one, a stable divergence is repaired by recopying that chunk from the source: DELETE the key range on the target, re-SELECT from the source, and re-apply through the same write path the change feed uses. Run always has one — the factory requires an Applier to build it from — so the initial checksum of migration, move and sync each self-heals a divergence and gives up only when repeated passes keep re-finding one. chunkRepairer repairs through the applier, deleting the range on every target and reading it from every source (spirit migrate, spirit move); mysqlRecopier is the cross-server one (spirit sync). Recopies are serialized and run under a cancellation-detached, time-bounded (10 minute) context, so a chunk is never left deleted-but-not-rewritten.
  • Without one, a stable divergence is fatal: the checker returns ErrPermanentDivergence and the caller aborts. RunContinuous withholds the recopier for this: the initial checksum already passed, so a divergence found while waiting on the sentinel is surfaced rather than repaired near cutover. A resumed run's initial checksum repairs it.

Before either policy acts, the change feed is drained and the chunk re-read, so a target that was merely behind on applying buffered changes is not mistaken for a diverged one. On a confirmed divergence the checker logs a line per differing row (mismatched, missing on the target, missing on the source), the same diagnostic the snapshot checker emits.

Continuous passes are paced by LocklessMinPassInterval so a small table is not re-checksummed back-to-back; the finite gate paces by RetryDelay instead, because a cut-over is waiting on the answer. MaxPasses bounds the finite gate: a range that never converges returns ErrVerificationUnresolved (wrapping ErrDifferencesExhausted too when every pass repaired) instead of keeping the caller in an endless re-walk with no error and no end. FirstCleanPass exposes a channel that closes the first time a pass completes with every chunk read-verified equal and zero recopies — the signal that the target is known consistent.

Documentation

Overview

Package checksum verifies that a copy of a table matches its source while the source keeps taking writes. It is not in the row/ package because it requires a change feed to be passed in, which would cause a circular dependency.

There are two checkers. LocklessChecker (CheckerConfig.Lockless) takes no locks, spans servers, and is what move and sync use. SingleChecker, the default, compares two tables on one server under a snapshot taken behind a brief table lock. The intent is for lockless to replace it, so Single-only code is kept in the single*.go files, which can then be deleted whole.

SingleChecker compares two tables on the same MySQL server under a REPEATABLE READ snapshot taken behind a brief table lock. It is the default checker (CheckerConfig.Lockless unset), and is expected to be replaced by LocklessChecker. Everything only it uses lives in the single*.go files so that removing it is a matter of deleting them and the branch in NewChecker.

Index

Constants

View Source
const (
	DefaultLocklessMaxQueueSize = 1024
	// DefaultLocklessMaxHotAttempts bounds how long one continuously
	// changing chunk can hold a pass open. The initial read counts as attempt
	// one. A deferred hot chunk makes the pass ineligible to be clean and is
	// visited again from a fresh chunk walk on the next pass.
	DefaultLocklessMaxHotAttempts = 10
	// DefaultLocklessMaxPasses bounds RunUntilClean. Ten full walks is well
	// past the point where a table that is going to converge has converged —
	// the common case is one pass, and a repair costs one more — so reaching
	// the bound means the table is not converging rather than that it needs
	// longer. Only the finite gate is bounded; continuous Run passes forever
	// by design.
	DefaultLocklessMaxPasses = 10
)

Default values applied for zero-valued config fields. Exported so callers can reference them when tuning.

View Source
const (

	// DefaultConcurrency is the worker count every checker starts at when the
	// caller does not choose one. Four readers is enough to keep a chunk in
	// flight per available connection on a small instance without being a
	// meaningful share of a large one's capacity; the autoscaler moves it from
	// there when it is enabled.
	DefaultConcurrency = 4
)

Variables

View Source
var (
	// ErrDifferencesExhausted is returned by Run when the attempts kept finding
	// row differences: every attempt completed and found them, or, after an
	// attempt that errored, at least two completed attempts in a row did, so a
	// completed attempt re-read a repair and found the difference again. The
	// table is diverging in a way the repairs cannot close, so a further
	// attempt reproduces it: a lossy ALTER (adding a UNIQUE index to
	// non-unique data being the common one), or a bug. Callers that decide
	// whether to retry should not.
	ErrDifferencesExhausted = errors.New("checksum kept finding differences")

	// ErrAttemptsExhausted is returned by Run when the attempts ran out and at
	// least one of them errored before it could compare the whole table, and
	// fewer than two completed attempts followed the last such error —
	// killed connections, a cancelled context, a failure inside a pass. Any
	// other attempt found and repaired differences, but an errored attempt
	// says nothing about whether a repair held, so nothing has been proven
	// about the data, and the condition may well be gone by the next attempt.
	// It wraps the most recent attempt error, which is the one worth triaging.
	ErrAttemptsExhausted = errors.New("checksum attempts errored before the table was verified")

	// ErrPermanentDivergence is returned by RunContinuous when it confirms a
	// difference: the source is not racing, and (for LocklessChecker) the
	// mismatch survived a full drain of every feed, so it is not apply lag.
	// RunContinuous never repairs, because it runs while a cutover may be
	// imminent; the caller aborts, and the resumed run's initial Run repairs
	// the range. Run never returns it, since Run repairs what it finds.
	ErrPermanentDivergence = errors.New("checksum: permanent divergence detected")
)
View Source
var (
	// LocklessMinPassInterval is RunContinuous's pacing for every checker:
	// the minimum time between passes, so a small table whose
	// pass finishes in seconds doesn't re-scan back-to-back during a possibly
	// days-long sentinel wait. It is not configurable: every caller runs
	// continuous verification at the same pace.
	LocklessMinPassInterval = 1 * time.Hour
	// DefaultLocklessRetryDelay is the constructor default for RetryDelay: the
	// wait before re-reading a mismatched chunk. It is short because it is not
	// what keeps apply lag from being mistaken for divergence: a retry also
	// waits for the change feed to flush (see gateOnFlush), and a stable
	// mismatch drains the feed and re-reads before any verdict (see
	// executeWork). With a feed it is only a floor; without one it is the
	// whole wait.
	DefaultLocklessRetryDelay = 5 * time.Second
	// DefaultLocklessRetryFlushWait is the constructor default for
	// RetryFlushWait: two *default* flush intervals, so a periodic flush lands
	// inside it even when the flush before it ran long. A caller whose feeds
	// flush on another interval sets RetryFlushWait from it (spirit sync does,
	// from --flush-interval); otherwise the cap expires first and retries fall
	// back to RetryDelay alone.
	DefaultLocklessRetryFlushWait = 2 * change.DefaultFlushInterval
)

Shared lockless-checksum pacing. Vars (not consts) so tests can shorten them; production never overrides them. Keeping them here makes the pacing identical across every caller (migrate, sync).

View Source
var (
	// ErrYieldTimeout is returned by runChecksum when the yield timeout expires.
	// This is distinct from the parent context being canceled, and signals that
	// the checksum should resume from the current watermark after releasing
	// long-running transactions to reduce HLL (history list length) growth.
	ErrYieldTimeout = errors.New("checksum yield timeout")

	// DefaultYieldTimeout is the default maximum duration for a single checksum
	// pass before yielding to release long-running REPEATABLE READ transactions.
	DefaultYieldTimeout = 24 * time.Hour
)
View Source
var ErrVerificationUnresolved = errors.New("checksum: verification did not converge within the pass budget")

ErrVerificationUnresolved is returned by RunUntilClean when MaxPasses passes have completed and none of them was clean — every pass still ended with ranges that were repaired, or that were changing too fast to verify. Nothing is proven about those ranges, so the caller must not cut over; but neither is a divergence proven, so this is deliberately distinct from ErrPermanentDivergence. It is the optimistic counterpart of ErrDifferencesExhausted: a bound that makes the run terminate instead of re-walking the table forever. When the budget spans more than one pass and every pass repaired at least one range, the divergence reproduced after each repair, and the error wraps ErrDifferencesExhausted as well. Settling (lockless_settle.go) resolves most hot rows before they get that far; what reaches this bound is a range that could not even be settled — see "Continuously updated hot rows" in the package README for the cases where that happens.

Functions

func IsReproducible added in v0.19.0

func IsReproducible(err error) bool

IsReproducible reports whether err is a checksum verdict about the data: one that checking the same data again would reach again. It covers ErrDifferencesExhausted (a completed attempt found differences again after the one before it repaired them, which includes an ErrVerificationUnresolved whose every pass repaired) and ErrPermanentDivergence (a confirmed difference). The error may be wrapped.

One exception rests on a single observation: SingleChecker.Run with MaxRetries set to 1 reports ErrDifferencesExhausted after one attempt that found and repaired differences, which no later attempt re-read. A caller that relies on this verdict leaves MaxRetries at its default.

A bare ErrVerificationUnresolved is not reproducible. The pass budget ran out with ranges that were changing too fast to verify, or that a single repair did not settle, but no divergence is proven, and a later attempt against a quieter table can pass. ErrAttemptsExhausted is not reproducible either: an attempt that errored proved nothing about the data.

Reproducible is about re-reading the same data, not about what a caller's retry does to it: RunContinuous reports ErrPermanentDivergence without repairing, and the initial Run of a resumed migration repairs that range. Whether such a retry is worth making is the caller's decision.

func StatusRow added in v0.18.0

func StatusRow(c Checker) string

StatusRow formats the checksum's row in a runner status block.

func StatusSuffix added in v0.16.0

func StatusSuffix(c Checker) string

StatusSuffix renders the pacing fields for the checksum row of a runner status block, or "" if the checker does not report them. It keeps the leading two spaces used between fields within a row, so callers can append it unconditionally.

func StatusSummary added in v0.18.0

func StatusSummary(c Checker) string

StatusSummary formats rich status when supported, otherwise basic progress.

Types

type AutoscaleConfig added in v0.16.0

type AutoscaleConfig struct {
	Enabled bool
	// MaxThreads is the ceiling scaling may reach. The transaction pools are
	// provisioned at this size whether or not Enabled is set, so callers must
	// budget connections for it (see SingleChecker.initConnPool for why the
	// pools cannot grow on demand). Values below Concurrency are raised to it.
	MaxThreads int
}

AutoscaleConfig controls the checksum phase's worker-count control loop. It mirrors copier.AutoscaleConfig, minus a StartThreads field — the checksum starts at CheckerConfig.Concurrency.

Enabled only turns on *scaling*. The throttler hard-stop applies either way: a checksum with autoscaling disabled still pauses when the throttler says to, which before this existed it did not do at all.

type Checker

type Checker interface {
	// SetThrottler installs pacing before Run. Every finite checker supports it.
	SetThrottler(throttler.Throttler)
	// ResumeWatermark returns safe verification progress, or an empty string when
	// a resumed run must recheck everything. Read it instead of the walker watermark.
	ResumeWatermark() (string, error)
	// Run performs finite verification, and it repairs: a chunk that differs
	// is rewritten from the source through the configured Applier and
	// re-verified by a later pass. A nil result authorizes completion;
	// deferred ranges and repairs alone are not verification.
	Run(ctx context.Context) error
	// RunContinuous reuses a successfully completed checker for background
	// verification, and it never repairs: a confirmed difference returns
	// ErrPermanentDivergence. A resumed run's initial Run is what repairs it.
	// Calls to Run and RunContinuous must be sequential. A nil return means
	// cancellation was safe, not that the interrupted pass verified every row.
	// Other errors abort cutover. Once started, ResumeWatermark stays empty:
	// restarting requires full initial verification.
	RunContinuous(context.Context) error
	// ContinuousActive distinguishes a running pass from interval pacing.
	// It is safe to query concurrently with RunContinuous.
	ContinuousActive() bool
	// GetProgress returns the structured checksum progress — rows verified so far
	// and the total to verify. Call String() on the result for the display form.
	GetProgress() status.ChecksumProgress
	StartTime() time.Time
	ExecTime() time.Duration
	// DifferencesFound reports observed mismatches, including transient ones.
	// Snapshot checkers count the current pass; optimistic verification counts
	// across passes within Run. This is not resume evidence: use ResumeWatermark.
	DifferencesFound() uint64
}

func NewChecker

func NewChecker(sourceDBs []*sql.DB, chunker table.Chunker, feeds []change.Source, config *CheckerConfig) (Checker, error)

NewChecker creates a new checksum object. CheckerConfig.Lockless picks which one, and nothing else does; the zero value is SingleChecker.

sourceDBs contains the source database connections (one for single-source migrations, multiple for N:M moves), each paired with the feed at the same index. Lockless aggregates checksums across all of them; Single accepts exactly one. For Single the copy being verified lives on sourceDBs[0] too; for Lockless it is wherever resolveLocklessTargets says — TargetDB (how a sync verifies across two servers), else the applier's targets, else sourceDBs[0]. Open the chunker before construction unless supplying Watermark, in which case the factory opens it according to policy.

type CheckerConfig

type CheckerConfig struct {
	// Lockless selects LocklessChecker; false (the zero value) selects
	// SingleChecker. It is the only thing that selects a checker — the Applier
	// is a repair write path, not a switch. Lockless is the only one that
	// spans servers: a sync (TargetDB), and a move, which reads N sources
	// against the applier's M targets and aggregates each chunk across all of
	// them. SingleChecker is expected to be removed in its favour.
	Lockless    bool
	Concurrency int
	DBConfig    *dbconn.DBConfig
	Logger      *slog.Logger
	// Watermark is verification evidence from a previous run: every row below
	// it was read on both sides and observed equal. Supplying it makes the
	// factory open the chunker there, so verification resumes rather than
	// restarting; leave the chunker unopened when supplying it. Every checker
	// honours it — the claim it encodes does not depend on which checker
	// observed it. Take it from Checker.ResumeWatermark, never from the
	// chunker's traversal watermark.
	Watermark string
	// MaxRetries bounds whole-run attempts for every checker: a transient
	// infrastructure failure costs an attempt rather than the migration.
	MaxRetries int
	// Applier is the write path Run rewrites a mismatched chunk through. It is
	// the caller's to supply because every runner already has one, and building
	// a second here would hide which one a repair actually goes through.
	//
	// Required: Run always repairs, and a checker that cannot repair should
	// fail to build, not on the first mismatch hours in. A repair starts and
	// stops it around each rewrite, since repairs are rare and serialized.
	// Lockless also reads GetTargets from it to find the copy being verified
	// (see resolveLocklessTargets), which is how a move names its M targets.
	Applier applier.Applier
	// Throttler paces the checksum. Optional: nil installs a Noop, and callers
	// that build the checker before their throttlers are open should use
	// SetThrottler instead (the migration runner does).
	//
	// Whatever is passed is narrowed by loadOnlyThrottler — a checksum reacts to
	// load signals and ignores binary ones such as replica lag.
	Throttler throttler.Throttler
	// Autoscale configures the worker-count control loop.
	Autoscale AutoscaleConfig
	// MetricsSink is where the control loop reports its gauges. Optional.
	MetricsSink metrics.Sink

	// YieldTimeout is the maximum duration for a single checksum pass before
	// yielding to release long-running transactions. Lockless reads are short
	// by construction and hold no snapshot to yield.
	YieldTimeout time.Duration

	// RetryDelay is the minimum wait between attempts for any given chunk —
	// measured from the *last* attempt of that chunk, not from the original
	// failure. Default DefaultLocklessRetryDelay (5s). It is also what paces
	// the re-walk between finite passes.
	RetryDelay time.Duration

	// RetryFlushWait bounds how long a retry additionally waits for every
	// change feed to complete a flush since the attempt that queued it. The
	// target only moves when a feed flushes, so a retry before then re-reads
	// an image it has already seen. It is an upper bound, not a delay: the
	// retry runs as soon as every feed has flushed (and RetryDelay has
	// passed), and without feeds there is no wait at all. Default
	// DefaultLocklessRetryFlushWait (2 × change.DefaultFlushInterval), which
	// assumes the default flush interval: a caller whose feeds flush less
	// often should set it to twice their interval.
	RetryFlushWait time.Duration

	// MaxQueueSize is the cap on entries in the delayed-retry queue. Reaching
	// it stalls the walker until retries drain rather than failing the run.
	// Default 1024.
	MaxQueueSize int

	// MaxHotAttempts is the number of observations allowed for a chunk whose
	// source signature keeps changing. Once reached, the chunk is deferred to
	// the next pass rather than holding the current pass open forever. Default
	// 10; a positive value below 2 is clamped to 2 because detecting a source
	// change requires an initial read and a retry. Deferral never counts as
	// verification and makes the pass not clean.
	MaxHotAttempts int

	// MaxPasses bounds Run and RunUntilClean: once this many passes have
	// completed without one of them being clean, verification gives up with
	// ErrVerificationUnresolved rather than walking the table again. Zero means
	// unbounded, which is what RunContinuous always is; NewChecker defaults it
	// to DefaultLocklessMaxPasses so the finite gate terminates.
	MaxPasses int

	// TargetDB is the server holding the copy being verified, when that is not
	// the server being read from. Nil — the usual case — means the target lives
	// on sourceDBs[0], which is true of a migration (the shadow table sits
	// beside the original). A sync verifies two servers, so it supplies one.
	//
	// Setting it also changes the shape of a repair: a cross-server rewrite has
	// no ColumnMapping to apply, because both sides hold the same logical
	// table. See newRecopier.
	TargetDB *sql.DB

	// ExternalFlushLoop says the caller runs the feed's periodic flush itself,
	// for longer than any one run, so a run must not start and stop it. The
	// default (false) is that the checker owns flushing for the duration of a
	// run, which is what a migration and a move want: nothing else is driving
	// the feed while verification is the only thing happening.
	//
	// A sync is the opposite — its flush loop runs for the whole process at its
	// own configured interval, and a verification pass stopping it on the way
	// out would be stopping someone else's goroutine.
	ExternalFlushLoop bool
	// contains filtered or unexported fields
}

func NewCheckerDefaultConfig

func NewCheckerDefaultConfig() *CheckerConfig

type ChecksumStatus added in v0.18.0

type ChecksumStatus struct {
	Progress   status.ChecksumProgress
	Optimistic *LocklessCheckerStats
}

ChecksumStatus separates optimistic traversal from completed verification. Optimistic is a point-in-time copy, not mutable checker state.

func (ChecksumStatus) String added in v0.18.0

func (s ChecksumStatus) String() string

type LocklessChecker added in v0.18.0

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

LocklessChecker is the optimistic checker. It satisfies the whole Checker contract natively: Run verifies the table once and returns, RunContinuous verifies it forever in the background, and both drive the same pass loop. Construct it through NewChecker with CheckerConfig.Lockless set, naming a TargetDB when the copy being verified is on another server.

Run and RunContinuous must not overlap; everything else — Stats, FirstCleanPass, ResumeWatermark, the status accessors — is safe to call concurrently with either.

func (*LocklessChecker) ChecksumStatus added in v0.18.0

func (c *LocklessChecker) ChecksumStatus() ChecksumStatus

ChecksumStatus reports rows verified rather than rows walked. The chunker advances on feedback, and this checker gives feedback only for a chunk that resolved clean (see feedbackResolved), so its progress is verification progress. Before that was so, this reported 0 until the first clean pass and then jumped to the whole table, which read as a stalled checksum for the entire run.

func (*LocklessChecker) ContinuousActive added in v0.18.0

func (c *LocklessChecker) ContinuousActive() bool

ContinuousActive distinguishes a running pass from the interval pacing between passes.

func (*LocklessChecker) DifferencesFound added in v0.18.0

func (c *LocklessChecker) DifferencesFound() uint64

DifferencesFound returns the lifetime number of chunks that mismatched on their initial (fresh-walk) read — i.e. Stats().MismatchesDetected. It exists so a LocklessChecker can be consumed through the same minimal "has this checker observed any divergence?" view the migration runner uses to gate checkpoint-watermark persistence (DumpCheckpoint / invalidateChecksumWatermark), matching the Checker.DifferencesFound semantics of the SingleChecker. A transient mismatch that later reconciles on retry still counts here, so the gate stays conservative: any hint of divergence blanks the persisted watermark and forces re-verification on resume.

func (*LocklessChecker) ExecTime added in v0.18.0

func (c *LocklessChecker) ExecTime() time.Duration

ExecTime is how long the current run has been going, or how long the last one took.

func (*LocklessChecker) FirstCleanPass added in v0.18.0

func (c *LocklessChecker) FirstCleanPass() <-chan struct{}

FirstCleanPass returns a channel that is closed the first time a pass completes with every chunk READ-verified equal and zero recopies. A pass containing a recopy does not qualify: the repaired rows were never observed equal, so the signal waits for a follow-up pass that re-reads them (and everything else) with no repairs needed. The signal is monotonic: once closed it stays closed. Callers that need a "data is known consistent" gate should select on this channel. Safe to call concurrently with Run.

func (*LocklessChecker) GetProgress added in v0.18.0

func (c *LocklessChecker) GetProgress() status.ChecksumProgress

func (*LocklessChecker) ResumeWatermark added in v0.18.0

func (c *LocklessChecker) ResumeWatermark() (string, error)

ResumeWatermark returns the verified-clean prefix of the table for the walk now in progress: every row below it was read on both sides and observed equal, so a resumed run may start there instead of at the beginning. It is the direct consequence of feedbackResolved — see that method for why the chunker's low watermark carries this meaning at all.

A second pass resets the chunker and so resets this to nothing. The prefix the first pass verified is deliberately not carried over: the second pass exists because the first one repaired or deferred something, and if it now fails to re-verify a range the first pass verified, that range stopped being equal — exactly the case where reporting the older, further-along answer would let a resume skip the damage.

Continuous verification reports nothing: it never finishes, so there is no run to resume, and a restart requires full initial verification.

Unlike the snapshot checker there is no "any difference found ⇒ no evidence" gate here, and there does not need to be. Optimistic reads mismatch routinely on a table taking writes, and almost all of those resolve on retry; gating on the mismatch counter would discard the watermark on essentially every real migration. What makes the prefix trustworthy instead is that a chunk is reported to the chunker only once it has resolved clean.

A watermark that is not yet available (no chunk has resolved) is reported as an empty string rather than an error: nothing has resolved, so there is nothing to persist, and that must not stop the caller writing the rest of its checkpoint.

func (*LocklessChecker) Run added in v0.18.0

func (c *LocklessChecker) Run(ctx context.Context) error

Run verifies the whole table and returns, which is the finite half of the Checker contract. A nil result authorizes completion: it means a pass walked every chunk and needed no repairs and deferred nothing. Repairs and deferred ranges are not verification, so a pass containing either is followed by another one (bounded by MaxPasses, after which it gives up with ErrVerificationUnresolved).

A failed attempt is retried up to MaxRetries times when a fresh attempt could plausibly survive the failure. This mirrors SingleChecker.Run, and for the same reason: a checksum is the last thing standing between a migration and a cut-over, and a pool of connections killed mid-pass (or any other transient infrastructure failure) should not fail the migration outright. The verdicts that are *about the data* (IsReproducible) are not retried, because repeating the read would reach the same conclusion, and neither is ErrVerificationUnresolved, whose attempt already spent the pass budget (see locklessRetryable).

func (*LocklessChecker) RunContinuous added in v0.18.0

func (c *LocklessChecker) RunContinuous(ctx context.Context) error

RunContinuous verifies the table in the background for as long as ctx lives, which is the continuous half of the Checker contract. It is the same pass loop as Run with three differences: there is no pass budget, because the point is to keep verifying; nothing is repaired, so a confirmed divergence returns ErrPermanentDivergence; and a cancellation is reported as nil, because a background verifier being shut down is not a failure. Any error it returns should abort a cutover.

Following a finite run, the first continuous pass waits LocklessMinPassInterval rather than re-walking the table immediately behind the pass that just verified it.

func (*LocklessChecker) RunUntilClean added in v0.18.0

func (c *LocklessChecker) RunUntilClean(ctx context.Context) error

RunUntilClean is one attempt of Run: it returns as soon as a complete pass has no repairs and no deferred ranges, without the whole-run retry loop. It joins all workers before returning. Cancellation is an error, never evidence of verification.

func (*LocklessChecker) SetThrottler added in v0.18.0

func (c *LocklessChecker) SetThrottler(t throttler.Throttler)

SetThrottler installs pacing before a run. It is called during runner setup, because a runner usually opens its throttlers after it builds the checker.

func (*LocklessChecker) StartTime added in v0.18.0

func (c *LocklessChecker) StartTime() time.Time

StartTime is when the current (or last) run began.

func (*LocklessChecker) Stats added in v0.18.0

Stats returns a point-in-time snapshot of the checker's counters. Safe to call concurrently with Run.

type LocklessCheckerStats added in v0.18.0

type LocklessCheckerStats struct {
	// PassesCompleted is the number of passes finished so far. A pass
	// completes when every chunk has resolved (READ-verified, recopied, or
	// explicitly deferred as continuously hot);
	// only a pass with zero recopies counts as clean for the
	// FirstCleanPass signal.
	PassesCompleted uint64

	// CurrentPass is the 1-indexed active or most recently completed pass
	// (0 before the first pass starts).
	CurrentPass uint64

	// NextPassAt is the scheduled start while waiting between passes; zero otherwise.
	NextPassAt time.Time

	// ChunksThisPass is how many chunks the walker has emitted in the
	// current pass, including split parents and their subsequently emitted children.
	ChunksThisPass uint64

	// ChunksPassedThisPass is how many chunks have gone clean in the
	// current pass (either initially or via retry). Split parents are excluded,
	// so this is not a completion numerator over ChunksThisPass.
	ChunksPassedThisPass uint64

	// ProgressBasisPoints estimates how far the chunker has walked through the
	// current pass, from 0 to 10000. Chunk sizes adapt while the pass runs, so
	// ChunksThisPass is only the number emitted so far and can never be an
	// honest denominator. Chunker.Progress supplies a stable-enough fraction
	// over keyspace distance or estimated rows, depending on the chunker.
	ProgressBasisPoints uint64

	// ScanComplete means the walker exhausted the current pass successfully.
	// Retries, in-flight reads, repairs, or deferred ranges may still prevent
	// verification; an estimate of 100% does not imply ScanComplete.
	ScanComplete bool

	// MismatchesThisPass is how many chunks mismatched on their initial
	// (fresh-walk) read in the current pass and were enqueued for retry.
	// At the end of a completed pass this equals PassedSecondAttemptThisPass +
	// PassedUnder5AttemptsThisPass + PassedUnder10AttemptsThisPass +
	// RecopiesThisPass + HotChunksDeferredThisPass + HotChunksSplitThisPass.
	// A completed pass may contain repairs or deferrals and need not be clean. Resets
	// each pass.
	MismatchesThisPass uint64

	// Per-pass histogram of attempts-to-converge. "attempts" counts every
	// read of the chunk (initial fresh-walk + each retry). Buckets are
	// non-overlapping; their sum equals ChunksPassedThisPass on a clean
	// pass. All reset each pass.
	PassedFirstAttemptThisPass    uint64 // 1 attempt (no retry needed)
	PassedSecondAttemptThisPass   uint64 // 2 attempts (1 retry)
	PassedUnder5AttemptsThisPass  uint64 // 3-4 attempts
	PassedUnder10AttemptsThisPass uint64 // 5+ attempts; 10+ outliers fold in here
	// RecopiesThisPass is the count of chunks that were recopied this
	// pass — i.e. retry detected stable target divergence (source CRC
	// unchanged across the retry window, target still wrong) and the
	// Recopier rewrote the chunk from source. Always zero under
	// RunContinuous, which does not repair (those failures surface as
	// ErrPermanentDivergence and abort the run instead). A pass with
	// RecopiesThisPass > 0 cannot be the first clean pass — recopied
	// chunks are repaired, not verified, and are re-read on the next
	// pass before FirstCleanPass can fire.
	RecopiesThisPass uint64

	// HotChunksDeferredThisPass is the number of continuously changing chunks
	// deferred after MaxHotAttempts *and* not settled against the change stream
	// (see HotChunksSettledThisPass). They are not counted as passed; any value
	// greater than zero makes this pass ineligible for FirstCleanPass.
	HotChunksDeferredThisPass uint64
	// HotChunksSettledThisPass counts chunks that exhausted MaxHotAttempts and
	// then reached a real verdict by comparing the target against the change
	// stream's own image of each outstanding row — verified outright, or
	// diverged and handed to the repair path. It is the escalation that keeps a
	// continuously written row from deferring forever; a rising count alongside
	// a falling HotChunksDeferredThisPass is it working. It does not say which
	// verdict was reached: a settled divergence is also counted by
	// RecopiesThisPass (or ends the run), and a settled clean chunk by the
	// ordinary passed counters.
	HotChunksSettledThisPass uint64
	// HotChunksSplitThisPass counts parents replaced by child ranges. A split
	// is not a verification result; all children must resolve independently.
	HotChunksSplitThisPass uint64

	// RetryQueueDepth is the current size of the delayed-retry queue.
	RetryQueueDepth int

	// HotChunkCount is the number of entries currently in the retry queue
	// with consecutiveSrcChanged >= 2 — i.e. a chunk that has been observed
	// changing on the source across multiple retry windows.
	HotChunkCount int

	// InFlight is the number of checksum reads or recopies currently executing.
	// Together with RetryQueueDepth it distinguishes work waiting for another
	// observation from a slow query that has not returned yet.
	InFlight int

	// WalkerStalls is the lifetime count of times the dispatcher refused
	// to read a fresh chunk from the walker because the retry queue was
	// already at MaxQueueSize. Each stall represents the checker holding
	// back the walker until existing retries drain enough to make room —
	// it does not abort the run. A persistently rising value means source
	// churn is outpacing the verifier (consider tuning MaxQueueSize,
	// Concurrency, or RetryDelay).
	WalkerStalls uint64

	// MismatchesDetected is the lifetime count of initial-read mismatches
	// (does not include re-failures within a single retry sequence).
	MismatchesDetected uint64

	// PermanentFailures is the lifetime count of chunks that failed twice
	// in a row with the source CRC unchanged. Run returns on the first such
	// event; this counter is bumped immediately before the error returns.
	PermanentFailures uint64

	// ConfirmedDifferences is the count of divergences that survived a drain
	// of every feed (or were settled against the change stream), each of which
	// was repaired or reported. Unlike MismatchesDetected it excludes apply
	// lag that reconciled on retry, and it is never reset, so it covers every
	// run of this checker.
	ConfirmedDifferences uint64

	// FirstCleanPassAt is the wall-clock time at which the first clean
	// pass completed (zero before that).
	FirstCleanPassAt time.Time
}

LocklessCheckerStats is a snapshot of the checker's counters. All fields are point-in-time; for monotonic totals, sample successively.

type MockChecker added in v0.18.0

type MockChecker struct {
	Chunker  table.Chunker
	RunError error
	// contains filtered or unexported fields
}

MockChecker is a shared test double for runners. Configure Chunker and RunError before use; SetDifferencesFound may be called concurrently with runner work. It models snapshot resume eligibility without performing verification.

func (*MockChecker) ContinuousActive added in v0.18.0

func (*MockChecker) ContinuousActive() bool

func (*MockChecker) DifferencesFound added in v0.18.0

func (m *MockChecker) DifferencesFound() uint64

func (*MockChecker) ExecTime added in v0.18.0

func (*MockChecker) ExecTime() time.Duration

func (*MockChecker) GetProgress added in v0.18.0

func (*MockChecker) GetProgress() status.ChecksumProgress

func (*MockChecker) ResumeWatermark added in v0.18.0

func (m *MockChecker) ResumeWatermark() (string, error)

func (*MockChecker) Run added in v0.18.0

func (m *MockChecker) Run(context.Context) error

func (*MockChecker) RunContinuous added in v0.18.0

func (m *MockChecker) RunContinuous(ctx context.Context) error

func (*MockChecker) SetDifferencesFound added in v0.18.0

func (m *MockChecker) SetDifferencesFound(n uint64)

func (*MockChecker) SetThrottler added in v0.18.0

func (*MockChecker) SetThrottler(throttler.Throttler)

func (*MockChecker) StartTime added in v0.18.0

func (*MockChecker) StartTime() time.Time

type Paced added in v0.16.0

type Paced interface {
	// Threads is the live worker count, which the autoscaler may have moved
	// away from the configured concurrency.
	Threads() int
	// IsThrottled reports whether the throttler is currently telling the
	// checksum to pause.
	IsThrottled() bool
	// ChunkSize is the row count of the most recently checksummed chunk. The
	// checksum sizes its chunks dynamically just as the copy does, so the same
	// field is worth watching in both phases.
	ChunkSize() uint64
}

Paced is the optional capability a Checker exposes when it can report how it is currently being paced. The runner status block uses it so a slow checksum can be told apart from a throttled or scaled-down one — the same question the copier row's throttled= answers for the copy phase.

type Recopier added in v0.15.0

type Recopier interface {
	Recopy(ctx context.Context, chunk *table.Chunk) error
}

Recopier knows how to overwrite a single chunk's worth of data on the target from the source. It is invoked when the lockless checker's retry path detects stable target divergence — i.e. the source CRC is unchanged across a retry window but the target CRC is still wrong.

Recopy must be safe to call concurrently from multiple worker goroutines; implementations are expected to serialize internally where needed (see mysqlRecopier for the production implementation).

type SingleChecker added in v0.10.1

type SingleChecker struct {
	sync.Mutex
	// contains filtered or unexported fields
}

func (*SingleChecker) ChecksumChunk added in v0.10.1

func (c *SingleChecker) ChecksumChunk(ctx context.Context, trxPool *dbconn.TrxPool, chunk *table.Chunk) error

func (*SingleChecker) ChunkSize added in v0.17.0

func (c *SingleChecker) ChunkSize() uint64

ChunkSize reports the row count of the most recently checksummed chunk, or 0 before the first one.

func (*SingleChecker) ContinuousActive added in v0.18.0

func (c *SingleChecker) ContinuousActive() bool

func (*SingleChecker) DifferencesFound added in v0.14.0

func (c *SingleChecker) DifferencesFound() uint64

DifferencesFound returns the number of chunks where a source/target mismatch was detected in the most recent (or in-flight) pass. Used by the lockless-checksum loop to decide whether a cancellation swallow is safe.

func (*SingleChecker) ExecTime added in v0.10.1

func (c *SingleChecker) ExecTime() time.Duration

func (*SingleChecker) GetProgress added in v0.10.1

func (c *SingleChecker) GetProgress() status.ChecksumProgress

GetProgress returns rows verified so far and the total to verify, proxied from the chunker.

func (*SingleChecker) IsThrottled added in v0.16.0

func (c *SingleChecker) IsThrottled() bool

IsThrottled reports whether the throttler is currently pausing dispatch.

func (*SingleChecker) ResumeWatermark added in v0.18.0

func (c *SingleChecker) ResumeWatermark() (string, error)

ResumeWatermark returns evidence from one attempt, synchronized with retries.

func (*SingleChecker) Run added in v0.10.1

func (c *SingleChecker) Run(ctx context.Context) error

func (*SingleChecker) RunContinuous added in v0.18.0

func (c *SingleChecker) RunContinuous(ctx context.Context) error

RunContinuous re-verifies the table every LocklessMinPassInterval until ctx is cancelled. Each pass is a Run that does not repair: a mismatch returns ErrPermanentDivergence.

func (*SingleChecker) SetThrottler added in v0.16.0

func (c *SingleChecker) SetThrottler(t throttler.Throttler)

SetThrottler installs the throttler the checksum paces itself against. It mirrors Copier.SetThrottler and exists for the same reason: the runner builds the checker before it opens the throttlers (the throttler needs the monitoring connection, which is set up later), so the wiring cannot happen at construction. A nil throttler is ignored, leaving the Noop in place.

What is installed is the load-only narrowing of t, not t itself — see loadOnlyThrottler.

func (*SingleChecker) StartTime added in v0.10.1

func (c *SingleChecker) StartTime() time.Time

func (*SingleChecker) Threads added in v0.16.0

func (c *SingleChecker) Threads() int

Threads reports the live worker count: the limiter's current limit while a pass is running, falling back to the configured concurrency before the first pass starts.

type StatusReporter added in v0.18.0

type StatusReporter interface{ ChecksumStatus() ChecksumStatus }

StatusReporter optionally exposes algorithm-specific verification state. Callers depend on this capability rather than concrete checker types.

Jump to

Keyboard shortcuts

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