checksum

package
v0.17.0 Latest Latest
Warning

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

Go to latest
Published: Sep 6, 2026 License: Apache-2.0 Imports: 21 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 non-generated columns, 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)).
  • Automatic repair: When inconsistencies are detected, the checksum automatically repairs differences by recopying affected chunks.
  • 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: A brief table lock establishes a consistent snapshot before being released. The checksum remains immune to concurrent modifications during execution.
  • 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. 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., latin1utf8mb4) 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. This includes adding a unique index on non-unique data, or a lossy data type conversion (e.g., VARCHAR(100)VARCHAR(10) when records exist requiring more than 10 characters). Both are important cases to handle, and prevent a cutover operation from executing.

Implementations

The checksum package contains three implementations:

  1. SingleChecker - Compares two tables on the same MySQL server (for schema changes, or 1:1 moves)
  2. DistributedChecker - Compares a source table against multiple distributed target databases (for sharded scenarios)
  3. ContinuousChecker - A lock-free, eventually-consistent verifier that runs indefinitely against a live system where the target lags the source by a small replication delay (used by spirit sync, and by spirit migrate while waiting on a deferred cutover)

SingleChecker and DistributedChecker take a brief table lock to establish a consistent REPEATABLE READ snapshot; ContinuousChecker deliberately does not (see Continuous checksum below).

All three use the same underlying checksum algorithm: CRC32 with XOR aggregation. This technique computes a checksum for each chunk of rows and can efficiently detect differences without comparing individual rows.

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

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.

Chunk repair

When a chunk mismatches and FixDifferences is set, 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.

Pacing and scaling

SingleChecker and DistributedChecker pace themselves 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 SetThrottler (the ThrottleAware capability) — 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. On stock MySQL this is the only shedding lever, and recovery is capped at the configured concurrency.

      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 the DistributedChecker, 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 (the default), any server on load only no no
scaling enabled, stock MySQL on load only yes no (recovers to the configured count only)
scaling enabled, Aurora 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 apart from the backlog lever. AutoscaleConfig.Enabled--enable-experimental-autoscaling for migrate — is what builds the scaler, and the scaler is where both shedding and growth live.

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.

ContinuousChecker is not covered by any of this: it manages its own pacing through MinPassInterval and its retry queue, and takes no table lock or snapshot pool.

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.

Continuous checksum

ContinuousChecker 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 and passes once the target's CRC matches a source CRC the checker has witnessed. A chunk whose source keeps changing (a "hot chunk") cycles to the back of the queue without blocking the pass.

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 two config fields:

  • Recopier — when set, 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. MySQLRecopier is the production implementation used by 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.
  • DivergenceIsFatal — selects the policy explicitly, rather than inferring it from Recopier presence:
    • true (e.g. spirit migrate's deferred-cutover check): replication keeps the new table in sync, so a confirmed stable divergence is a real bug. Run returns ErrPermanentDivergence and the caller aborts the cutover. No Recopier is configured.
    • false (e.g. spirit sync): the target is expected to converge, so divergences self-heal via the Recopier. A Recopier is required in this mode; without one, divergence is treated as fatal.

The two are decoupled: DivergenceIsFatal: true aborts even if a Recopier is supplied. Passes are paced by MinPassInterval so a small table is not re-checksummed back-to-back. 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 provides online checksum functionality. Two tables on the same MySQL server can be compared with only an initial lock. It is not in the row/ package because it requires a replClient to be passed in, which would cause a circular dependency.

Package checksum provides online checksum functionality. Two tables on the same MySQL server can be compared with only an initial lock. It is not in the row/ package because it requires a replClient to be passed in, which would cause a circular dependency.

Index

Constants

View Source
const (
	DefaultContinuousConcurrency  = 4
	DefaultContinuousMaxQueueSize = 1024
)

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

Variables

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")

	// ErrDifferencesExhausted is returned by Run when every attempt completed
	// but kept finding row differences. 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 found differences on every attempt")

	// ErrAttemptsExhausted is returned by Run when every attempt errored before
	// it could compare the whole table — killed connections, a cancelled
	// context, a failure inside a pass. Nothing has been proven about the data,
	// and the condition may well be gone by the next attempt. It wraps the last
	// attempt's error, which is the one worth triaging.
	ErrAttemptsExhausted = errors.New("checksum errored on every attempt")

	// 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 (
	// ContinuousMinPassInterval is the production value callers pass as
	// MinPassInterval: 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. (Not a constructor default — a zero MinPassInterval
	// legitimately means "back-to-back", which the package's own tests rely on.)
	ContinuousMinPassInterval = 1 * time.Hour
	// DefaultContinuousRetryDelay is the constructor default for RetryDelay: the
	// wait before re-reading a mismatched chunk, giving in-flight replication
	// time to converge so transient lag isn't mistaken for real divergence.
	DefaultContinuousRetryDelay = time.Minute
)

Shared continuous-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 ErrPermanentDivergence = errors.New("checksum: permanent divergence detected")

ErrPermanentDivergence is returned by Run when a chunk fails twice in a row with the source CRC unchanged AND no Recopier is configured — i.e. the target has data the source does not, the source is not racing, and the checker has no way to self-heal. With a Recopier configured this error is never returned: stable divergence triggers a Recopy and the chunk is counted in the per-pass "recopies" bucket.

This can technically false-positive if replication lag exceeds the retry delay — there may be changes that are still pending but we've not observed them yet. The retry delay defaults to 1 minute for that reason.

Functions

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.

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 {
	// Run performs the checksum operation.
	Run(ctx context.Context) error
	// 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 returns the number of chunks where a source/target
	// mismatch was detected during the most recent (or in-flight) pass.
	// Useful for callers that need to distinguish "clean cancellation" from
	// "cancellation while a fix may have been mid-flight" — the continuous-
	// checksum loop uses it to decide whether a sentinel-drop swallow is
	// safe.
	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. sourceDBs contains the source database connections (one for single-source migrations, multiple for N:M moves). The distributed checker aggregates checksums across all sources. The single checker uses sourceDBs[0].

type CheckerConfig

type CheckerConfig struct {
	Concurrency int
	// TargetChunkTime is reporting-only: it is the target the chunk-size
	// distribution summary is compared against at the end of each pass, so it
	// should match the TargetChunkTime the caller built the chunker with
	// (table.ChunkerDefaultTarget unless the caller overrode it). It does not
	// itself size anything — chunk sizing lives entirely in the chunker.
	TargetChunkTime time.Duration
	DBConfig        *dbconn.DBConfig
	Logger          *slog.Logger
	FixDifferences  bool
	Watermark       string // optional; defines a watermark to start from
	MaxRetries      int
	Applier         applier.Applier // optional; indicates it is a distributed checker
	// RepairApplier is the write path the single-server checker rewrites a
	// mismatched chunk through (see SingleChecker.replaceChunk). Required for
	// that checker, whether or not FixDifferences is set — a checker that cannot
	// repair should fail to build, not on the first mismatch hours in. Ignored
	// when Applier is set, because that selects the distributed checker, which
	// repairs through Applier itself.
	RepairApplier applier.Applier
	YieldTimeout  time.Duration // maximum duration for a single checksum pass before yielding to release long-running transactions
	// 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
}

func NewCheckerDefaultConfig

func NewCheckerDefaultConfig() *CheckerConfig

type ContinuousChecker added in v0.15.0

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

ContinuousChecker is the eventually-consistent checker. Construct via NewContinuousChecker; use Run to drive it until ctx is cancelled or a permanent failure surfaces. Concurrent calls to Stats and FirstCleanPass are safe at any time.

func NewContinuousChecker added in v0.15.0

func NewContinuousChecker(
	sourceDB, targetDB *sql.DB,
	chunker table.Chunker,
	feed change.Source,
	cfg ContinuousCheckerConfig,
) (*ContinuousChecker, error)

NewContinuousChecker constructs a checker with the given dependencies and config. sourceDB and targetDB must be distinct connections to the source and target databases respectively. chunker must be Open before Run; the checker Resets it between passes but does not close it.

func (*ContinuousChecker) DifferencesFound added in v0.16.0

func (c *ContinuousChecker) 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 ContinuousChecker 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 / DistributedChecker. 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 (*ContinuousChecker) FirstCleanPass added in v0.15.0

func (c *ContinuousChecker) 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 (*ContinuousChecker) Run added in v0.15.0

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

Run drives the checker until ctx is cancelled or a permanent failure is detected. On ctx cancellation Run returns ctx.Err() (typically context.Canceled or context.DeadlineExceeded); callers that want to treat a clean shutdown as nil should filter that themselves (see how datasync.Runner.runContinuous does it). A permanent failure — a chunk that mismatched twice in a row with the source CRC unchanged and no Recopier was configured — returns ErrPermanentDivergence. Errors from the chunker walker (chunker.Next failures) are wrapped and returned.

MaxQueueSize is a soft backpressure threshold rather than a hard cap: when the retry queue reaches it, the dispatcher stops reading fresh chunks from the walker until existing retries drain enough to make room. The walker blocks on its send; workers continue draining. WalkerStalls in the stats snapshot counts how often this has fired.

func (*ContinuousChecker) Stats added in v0.15.0

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

type ContinuousCheckerConfig added in v0.15.0

type ContinuousCheckerConfig struct {
	// Concurrency is the number of worker goroutines. Default 4.
	Concurrency int

	// 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 1m, because changes are queued in the replication
	// applier for 30s by default.
	RetryDelay time.Duration

	// MaxQueueSize is the cap on entries in the delayed-retry queue. When
	// exceeded, Run returns an error rather than silently falling behind on
	// verification. Default 1024.
	MaxQueueSize int

	// Recopier is invoked when the retry path detects stable target
	// divergence (src CRC unchanged across a retry window, target still
	// wrong) and DivergenceIsFatal is false. When nil, that condition
	// surfaces as ErrPermanentDivergence from Run — useful for tests and
	// for callers that prefer to halt rather than self-heal. Production
	// sync callers should provide MySQLRecopier.
	Recopier Recopier

	// DivergenceIsFatal selects the policy for a confirmed stable divergence,
	// making explicit whether the caller should abort or heal rather than
	// inferring it from Recopier presence:
	//   - true  (migration's cutover gate): the target is kept in sync by
	//     replication, so a confirmed difference means something is genuinely
	//     wrong. Run returns ErrPermanentDivergence and the caller aborts. No
	//     Recopier is configured.
	//   - false (datasync): the checker's job is to find and re-copy diverged
	//     rows, so a confirmed difference is repaired via Recopier and the run
	//     continues.
	// When false, a Recopier must be set; without one a divergence is treated as
	// fatal anyway (there is nothing to heal with).
	DivergenceIsFatal bool

	// MinPassInterval is the minimum wall-clock time between the start of one
	// pass and the start of the next, measured from the previous pass's start
	// (a pass that already ran longer than MinPassInterval incurs no extra
	// wait). The very first pass always runs immediately. Zero means passes
	// run back-to-back, which is convenient for tests but heavy in production:
	// the migration and datasync runners both pass ContinuousMinPassInterval
	// (1h) so a small table whose pass finishes in seconds does not re-scan
	// continuously. The wait honours context cancellation.
	MinPassInterval time.Duration

	Logger *slog.Logger
}

ContinuousCheckerConfig configures a ContinuousChecker. See Default for the runtime defaults applied by the constructor when fields are zero.

type ContinuousCheckerStats added in v0.15.0

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

	// CurrentPass is the 1-indexed pass number in flight (0 before the
	// first pass starts).
	CurrentPass uint64

	// ChunksThisPass is how many chunks the walker has emitted in the
	// current pass.
	ChunksThisPass uint64

	// ChunksPassedThisPass is how many chunks have gone clean in the
	// current pass (either initially or via retry).
	ChunksPassedThisPass uint64

	// MismatchesThisPass is how many chunks mismatched on their initial
	// (fresh-walk) read in the current pass and were enqueued for retry.
	// On a clean pass this equals PassedSecondAttemptThisPass +
	// PassedUnder5AttemptsThisPass + PassedUnder10AttemptsThisPass +
	// RecopiesThisPass — i.e. every chunk that needed at least one retry
	// to converge. 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-9 attempts
	// 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
	// configured Recopier rewrote the chunk from source. Zero when no
	// Recopier is configured (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

	// 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

	// 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

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

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

type DistributedChecker added in v0.10.1

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

func (*DistributedChecker) ChecksumChunk added in v0.10.1

func (c *DistributedChecker) ChecksumChunk(ctx context.Context, chunk *table.Chunk) error

func (*DistributedChecker) ChunkSize added in v0.17.0

func (c *DistributedChecker) ChunkSize() uint64

ChunkSize reports the row count of the most recently checksummed chunk. See the SingleChecker equivalent.

func (*DistributedChecker) DifferencesFound added in v0.14.0

func (c *DistributedChecker) 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 continuous-checksum loop to decide whether a cancellation swallow is safe.

func (*DistributedChecker) ExecTime added in v0.10.1

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

func (*DistributedChecker) GetProgress added in v0.10.1

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

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

func (*DistributedChecker) IsThrottled added in v0.16.0

func (c *DistributedChecker) IsThrottled() bool

IsThrottled reports whether the throttler is currently pausing dispatch.

func (*DistributedChecker) Run added in v0.10.1

func (*DistributedChecker) SetThrottler added in v0.16.0

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

SetThrottler installs the throttler the checksum paces itself against — the load-only narrowing of t, per loadOnlyThrottler. See SingleChecker.SetThrottler for why this is not done at construction.

func (*DistributedChecker) StartTime added in v0.10.1

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

func (*DistributedChecker) Threads added in v0.16.0

func (c *DistributedChecker) Threads() int

Threads reports the live worker count. See the SingleChecker equivalent.

type MySQLRecopier added in v0.15.0

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

MySQLRecopier is the production Recopier used by `spirit sync`. Given a chunk that the continuous checker has identified as stably diverged (source CRC unchanged across the retry window, target still wrong), it rewrites the chunk's rows on the target from the source.

The operation is the cross-DB analog of SingleChecker.replaceChunk:

  1. DELETE the chunk's key range on the target.
  2. SELECT the chunk's rows from the source.
  3. Hand the rows to the applier's Apply method, which upserts them on the target through the same write path the change feed uses.

Recopies are serialized by an internal mutex. Concurrent DELETE + INSERT on overlapping chunks can deadlock on secondary indexes (see the transcript in single.go around line 211); serialization keeps the fix-up path safe at the cost of a small stall if multiple chunks need recopy at once. Since stable divergence is rare, this is acceptable.

The DELETE and Apply run under a context derived from context.WithoutCancel(ctx) so a parent cancellation between them does not leave the target with rows deleted but not yet rewritten. A bounded timeout (10 minutes) still protects against a hung Apply.

func NewMySQLRecopier added in v0.15.0

func NewMySQLRecopier(sourceDB, targetDB *sql.DB, app applier.Applier, dbConfig *dbconn.DBConfig, logger *slog.Logger) (*MySQLRecopier, error)

NewMySQLRecopier constructs a recopier for the source/target pair. The applier must be Started before Recopy is called (the production wiring in datasync.Runner starts the applier during the copy phase and leaves it running through continuous sync, so this is satisfied naturally).

func (*MySQLRecopier) Recopy added in v0.15.0

func (r *MySQLRecopier) Recopy(ctx context.Context, chunk *table.Chunk) error

Recopy rewrites the chunk's rows on the target from the source. See MySQLRecopier's struct doc for the operation's shape and concurrency rules.

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 continuous 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) 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 continuous-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) Run added in v0.10.1

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

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 ThrottleAware added in v0.16.0

type ThrottleAware interface {
	SetThrottler(t throttler.Throttler)
}

ThrottleAware is the optional capability a Checker exposes when it can pace itself against a throttler installed after construction. SingleChecker and DistributedChecker implement it; runners build their checker before the throttlers are open, so they type-assert for this and wire it later.

It is an optional interface rather than part of Checker so that test doubles and the continuous checker (which manages its own pacing) do not have to carry a method they have no use for.

Jump to

Keyboard shortcuts

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