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

applier

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

README

Appliers

Appliers write rows to one or more targets. The copier and the change feed's subscriptions (pkg/change) both write through one.

Applier is the interface. MySQLApplier (mysql_applier.go, built with applier.New(targets, cfg)) is the implementation. It handles every topology Spirit uses:

  • One target (schema changes, datasync, and unsharded moves). The target covers the whole key space, so every row goes to it and no routing happens.
  • Several targets (moves to a Vitess-style sharded destination). Each row is routed to the target whose key range contains the hash of its sharding column.

There used to be two implementations, SingleTargetApplier and ShardedApplier. They were forks of the same pipeline and drifted apart, so they were merged. A single target is the N=1 case of the sharded design.

Design goals

Sources are MySQL-like, targets may not be

The applier is where Spirit's read side hands off to its write side. The two sides make different assumptions:

  • Sources are expected to be MySQL-like: MySQL, Aurora, or Vitess. Rows arrive typed by the source table's MySQL column types, from the chunker's SELECT or from the change feed's row images. The applier renders each value using its source column type.
  • Targets are behind an interface, so a future implementation could write somewhere other than MySQL — for example, applying changes to PostgreSQL. The copier and the change feed only call Applier; they do not know what the target is.

Spirit's schema changes will stay MySQL-only. A non-MySQL target would be for moving or synchronizing data out of MySQL.

Moves and reshards

Sources and targets are both lists, which is what lets pkg/move do Vitess-style moves and reshards: N source shards to M target shards. Each target is a Target with its own connection and a Vitess-style key range ("-80", "80-", "80-c0"; "", "0" or "-" for the whole key space).

The data flow is one reader per source, then a fan-out. Spirit reads each source once, with multi-threaded copying and one change feed, and routes every row to the target that owns it. Each target has its own pool of write workers.

Vitess's built-in reshard workflows work the other way round: each target shard streams from every source shard and keeps only the rows in its own key range. That is in theory more scalable, because the work spreads across the targets. But every source serves M streams, so the load on the sources grows with the number of targets.

Spirit prefers to be kind to the source: each source serves one reader, however many targets there are. This matters most on Aurora. There, Spirit has to read from the source's writer instance: Aurora read replicas have no binlog, so they cannot serve a change feed or report a binlog position to resume from. Every stream a design adds therefore lands on the one instance that is also serving production writes. Spirit keeps that to one stream per source.

Reading from the writer is also simpler, because it takes replicas out of the topology. A replica can lag or break replication, and a design that reads from replicas has to detect and handle both. Where a design can be kind to the source, it should be.

Verifying a move: the checksum uses the same targets

A move is only done once its data has been verified, and verification follows the same topology. The lockless checksum (pkg/checksum) compares N sources against M targets. It takes the target list from the applier (GetTargets), and for each chunk it XORs the CRCs and sums the row counts across every server. When it finds a mismatched chunk, it repairs it through the same applier: it deletes the range on every target and re-copies it from every source.

This is a lot cheaper than Vitess's VDiff. VDiff streams every column of every row to a client and compares them there, so its cost grows with the size of the table. Spirit's checksum is computed on each server and returns one row per chunk. It drops to per-row comparison only for the chunks that differ. See Compared with other consistency checks for the full comparison, including the case where a row-by-row tool is the better choice.

Several shards per host, autoscaled per host

Standard Vitess runs one shard per host. Spirit's goal is to support several Vitess shards on one physical host: several targets whose connections point at the same MySQL server, each with its own key range (and typically its own schema).

So autoscaling is host-oriented, not shard-oriented. pkg/move groups the targets by host (host.GroupConfigs), and each host gets one load probe. The worker budgets are divided by the number of shards on the busiest host. Co-located shards then share one host's capacity instead of each sizing itself as if it had the host to itself. The applier itself knows nothing about hosts. It gives each target its own worker pool, and move's autoscaler sets all of the pools together through SetWriteWorkers, based on the load of the busiest host.

Hash functions are Vitess-compatible without depending on Vitess

Routing uses two fields on table.TableInfo, set per table:

  • ShardingColumn: the column to hash (the Vitess primary vindex column).
  • HashFunc: a table.HashFunc, func(value any) (uint64, error).

The hash is a 64-bit keyspace id, compared against Vitess-style key ranges, so any Vitess vindex that maps one column value to a keyspace id fits the interface. Spirit does not import Vitess on purpose, to keep its dependency list short. A program that uses Spirit as a library can wrap a Vitess hash function (for example the hash or xxhash vindex) in a table.HashFunc and set it on each table. The tests use testutils.EvenOddHasher, which needs no Vitess code.

A single target that covers the whole key space needs neither field.

History

The original implementation of Spirit relied on statements such as INSERT .. SELECT and REPLACE INTO, sending as much work as possible back to MySQL for processing. We now refer to this implementation as the unbuffered algorithm.

The unbuffered algorithm has the advantage that there are fewer edge cases to handle that can corrupt data (accidental, charset/timezone conversions), and it does not send as much data across the network (which takes CPU cycles from both MySQL and Spirit to process). It has two downsides:

  1. INSERT .. SELECT statements are locking, and do not use MVCC on the SELECT side.
  2. It cannot be used to ship data between MySQL servers, for example in move/copy operations.

The first downside can be mitigated by using smaller chunks to yield the lock periodically, but there is no option to address the second.

Appliers were created to support an algorithm which we refer to as buffered, which is an implementation of DBLog. Changes are extracted from the source table(s), and then sent to the applier to be loaded into an underlying target.

The buffered copier is now the only implementation — it became the default for schema changes in v0.15.0 (#908) and the legacy unbuffered copier has since been removed. The applier is used by the copier and is also always used by the replication client's bufferedMap subscription, which writes row images directly from the binlog instead of issuing REPLACE INTO ... SELECT (see issue #746).

Why an Applier Abstraction?

Applier is an abstraction which encompasses all changes that can be applied to a target. The advantage of having an interface for this is:

  1. Complex Scenarios: Abstract away resharding operations and other complex topologies.
  2. Future Targets: Support non-MySQL targets or targets with different performance characteristics.

We do not intend for Spirit to support schema changes on anything other than MySQL, but it could in future be possible to use it to synchronize data between MySQL and a downstream such as PostgreSQL or Iceberg.

The applier layer provides several critical functions:

  1. Optimal Batching: Rows are split into "chunklets" that respect both MySQL's max_allowed_packet limit and optimal write sizes
  2. Parallel Processing: Multiple write workers fan-out and process chunklets concurrently for ideal use of group commit.
  3. Async Feedback: Callers are notified via callbacks when writes complete, allowing the copier to advance its watermark.
  4. Mixed Operations: Supports both async bulk copying (Apply) and synchronous operations (DeleteKeys, UpsertRows) needed by the subscription.

Without the applier layer, the copier would need to handle all of this complexity itself, making the code harder to maintain and test. The copier is agnostic to sharded migrations.

Core Concepts

Chunklets

A "chunklet" is an internal batching unit used by appliers. This is different from the "chunk" concept in pkg/table/, which refers to the range of rows the copier reads from the source table.

When the copier calls Apply() with a batch of rows (typically from one chunk), the applier splits those rows into smaller "chunklets" for writing. The limits are fixed, not configurable through ApplierConfig:

  • Row count: Maximum 1,000 rows per chunklet
  • Size: Maximum 1 MiB of estimated data per chunklet

The size limit exists because MySQL's max_allowed_packet is typically 64 MiB by default, and 1 MiB stays well clear of it even though the estimate is rough. The row count limit provides a reasonable upper bound for tables with narrow rows. Which cap is in force cannot be read off the config — it depends on the table's width and column types.

The estimate is deliberately cheap and deliberately biased low. It runs on every value of every copied row, on top of the rendering the write does anyway, so it cannot afford reflection — utils.EstimateRenderedRowSize (shared with the copier's chunk sizing and the change feed's flush batching and buffer accounting) is a type switch that measures []byte and string exactly and assumes typical widths for everything else, including the int64 and float64 the driver returns for integer and floating-point columns. Three cases under-measure on purpose: a []byte bound to a binary column renders as 0x-hex at two characters per byte, a string grows under escaping, and an integer is assumed to be 10 digits when an int64 can render 20. Under-measuring is covered by the ~64x headroom between the byte budget and max_allowed_packet; over-measuring is not free, because it shrinks every chunklet.

That is not hypothetical. The previous implementation measured len(fmt.Sprintf("%v", v)), and a text-protocol Scan into *any returns []byte for string, temporal and DECIMAL columns — which %v renders as [49 50 51 …], about four characters per byte. It over-estimated by ~2.7x, so chunklets were cut well short of the budget they were sized for, and nothing failed, because an over-estimate is safe. It also cost ~2.2µs and 12 allocations per row, which on the copy path was more than building the statement it was sizing.

Important: A single row can exceed the byte budget by itself. In this edge case, the row will be placed in its own chunklet regardless of size, relying on max_allowed_packet being large enough. This is rare in practice.

Async vs Sync Operations

The applier interface provides both asynchronous and synchronous methods:

Asynchronous (used by the copier and the checksum's chunk repair):

  • Apply(ctx, chunk, rows, callback): Queues rows for writing and returns immediately. The callback is invoked when all rows have been written. Emits INSERT IGNORE INTO target (cols) VALUES (...), not REPLACE — see the checksum's Chunk repair for why repair depends on that.

Synchronous (used by subscription):

  • DeleteKeys(ctx, sourceTable, targetTable, keys, locks): Deletes rows by primary key and waits for completion. Emits DELETE FROM target WHERE (pk) IN (...).
  • UpsertRows(ctx, mapping, rows, locks): Upserts rows using a ColumnMapping and waits for completion. Emits REPLACE INTO target (cols) VALUES (...) — see REPLACE INTO semantics below.

locks is a []*dbconn.TableLock. Leave it nil for ordinary flushes; under-lock writes require exactly one lock per distinct target *sql.DB. Targets sharing a database handle must share one lock covering all their tables; duplicate lock entries for the same handle are rejected. These synchronous methods execute on the calling flush goroutines, independently of the asynchronous copy-worker pool.

This distinction exists because:

  • The copier processes large batches of rows and benefits from async processing with callbacks to advance its watermark.
  • The subscription processes individual binlog events and needs immediate confirmation that changes have been applied before advancing the binlog position.
REPLACE INTO semantics and eventual consistency

UpsertRows uses REPLACE INTO target (cols) VALUES (...), not INSERT ... ON DUPLICATE KEY UPDATE. Per MySQL's manual:

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

Two consequences for callers:

  1. REPLACE may delete rows whose PKs are not in the rows argument. If a new row's image collides on a unique key with some other row currently in the destination (the previous holder of that unique value), REPLACE deletes that other row to make room. A single REPLACE statement may therefore delete more than one row. This is what makes the multi-row VALUES list order-independent — within a batch, every row's conflicts (on PK or any unique index) are resolved before its insert runs.

  2. The destination is only eventually consistent with source mid-flush. Between the moment REPLACE deletes a row to resolve a unique-key conflict and the moment that row's own event re-inserts it, the destination is briefly missing that row. Spirit relies on the replication client's bufferedMap being an up-to-date and disjoint representation of pending changes — every PK in the buffer holds the latest image MySQL has emitted for it — so any transiently-deleted row is guaranteed to be re-inserted as flushes progress. The destination converges back to source's current state once every event for each affected PK has been applied. The post-copy checksum, which repairs, is the backstop for any divergence that survives.

The row image is supplied inline (the binlog reader stored it on HasChanged); the applier never re-reads source. This avoids the binlog/visibility race fixed in #746 that earlier REPLACE INTO ... SELECT paths could lose to.

Why this matters for workloads that move unique values

The motivating case is a source-side transaction that legally moves a unique value between two rows:

START TRANSACTION;
UPDATE t SET slot_id = NULL WHERE id = 1;  -- was 'S'
UPDATE t SET slot_id = 'S'  WHERE id = 2;  -- was NULL
COMMIT;

With INSERT ... ON DUPLICATE KEY UPDATE, the random map iteration order in the subscription could land "activate id=2" before "deactivate id=1" in the same multi-row statement; MySQL would resolve id=2's UPDATE branch, then fail with Error 1062 (23000): Duplicate entry 'S' because id=1 still held the value. With REPLACE INTO the same batch in any order works: each REPLACE deletes the prior holder of 'S' before inserting its own row. See block/spirit#847.

Callbacks and Feedback

When the copier calls Apply(), it provides a callback function:

started := time.Now()
callback := func(affectedRows int64, err error) {
    if err != nil {
        // Handle error
        return
    }
    // All rows have been written, advance watermark
    chunker.Feedback(chunk, time.Since(started), uint64(affectedRows))
}
if err := rowApplier.Apply(ctx, chunk, rows, callback); err != nil {
    return err
}

The applier tracks all pending work internally and invokes the callback only when:

  1. All chunklets for that batch have been written.
  2. OR an error occurs in any chunklet.

This allows the copier to continue reading and queuing more work without blocking, while still maintaining correctness by only advancing the watermark after writes complete.

Pipeline observability (Stats())

The applier exposes a point-in-time Stats() snapshot (see stats.go): queue depth/capacity, pending work, live write workers, and rolling p50/p90 of four per-chunklet phases. This exists because the copier's chunk feedback is end-to-end — read + queue wait + write — so a saturated write side otherwise presents as a read/chunker problem. A queue pegged at capacity with queue-wait far above write time means the pipeline is write-limited; a near-empty queue means it is read-limited.

The four phases together account for a write worker's whole cycle, which is what makes the follow-up question answerable — given the write side is the limit, which part of it?

Phase What it measures What a large value means
queue wait Apply() offering a chunklet to the buffer until a worker dequeues it, including send-side backpressure Workers cannot keep up with the copier (or are blocked further down)
build time Client-side statement construction: a datum conversion and string format per value, so it scales with rows × columns Spirit's own CPU is the limit. No server-side signal reports this, and more write workers cannot fix it. Contained within write time, not additional to it
write time Build plus the round trip to the target(s), including retry backoff Subtract build time to get time actually at the server
handoff Publishing the completion after the write finished Workers are queued behind the single feedbackCoordinator, which invokes the chunk callback inline — so one slow callback backs up every worker at once

The distinction matters because only write time minus build time is the target's write capacity. A pipeline that stops responding to added write workers looks identical in the aggregate whether the ceiling is the server, spirit's CPU, or the completion path; these four separate those cases.

Stats() carries all of it — and the metrics sink emits all of it — but Stats.String() renders only a subset onto the applier row of the runner status block, since that report is read by a human every 30 seconds (#329). Always shown: queue, workers, wait-p50, write-p50, write-p90. Shown only when they carry a diagnosis: build-p50 once build is ≥25% of write and at least 1ms (client-CPU bound), and handoff-p50 once handoff reaches 1ms (blocked behind the completion path). Both are silent on a healthy run, so their presence is the signal — read their absence as "not the problem", not as a missing field.

Stats().RowsPerChunklet (mean rows per chunklet since start) reports which chunklet cap — row count or byte budget — is actually binding for this table, which cannot be read off the config: it depends on the table's width and column types. Tuning the cap that isn't binding is a no-op. One caveat when reading it: the mean also drops when Apply() batches are small (every chunk's remainder is a short chunklet), and with several targets the same chunk is split per shard after fan-out, producing more, shorter chunklets. The row-cap-vs-byte-cap reading is sound on the copy path's large steady-state batches; a mean at the row cap always means the row cap binds.

Implementation Details

See the inline documentation in mysql_applier.go.

Vindex columns must be immutable

The applier only tracks changes by PRIMARY KEY, not by sharding column (vindex). With several targets, DeleteKeys must therefore broadcast to every target, and the vindex column must be immutable: if an UPDATE changed it, the old row would stay on its old target. The change feed enforces this and fails the move if an UPDATE changes the sharding column (see change.checkImmutableColumn). Vitess also recommends that vindex columns be immutable.

Writing under a table lock

DeleteKeys and UpsertRows take an optional list of table locks, used for the final flush at cutover. There must be exactly one lock per distinct target *sql.DB, acquired on that handle and covering every target table that uses it. Targets sharing a handle share that lock. The applier matches locks to targets by connection identity, and returns an error before writing anything if a handle has no lock, has two, or a lock matches no target. Executing a target's statements on another server's lock connection would write the rows to the wrong server.

Write-worker scaling

Each target owns a workerPool for growth, cooperative retirement, shutdown and restart. Throttling stays in the copier.

ApplierConfig.Threads and SetWriteWorkers(n) set the worker count per target, with a minimum of one. SetInitialWriteWorkers(n) sets the count the next Start spawns. Workers retire between chunklets, so an in-flight write always reports its completion. Stop joins all workers before closing the completion channels; resizing during shutdown is a no-op. ActiveWriteWorkers() and Stats().ActiveWorkers are the total across all targets. Move's autoscaler drives all pools together from the busiest target host's load (see Several shards per host).

Documentation

Index

Constants

View Source
const (

	// MaxStatementSizeBytes is the byte budget for the estimated rendered
	// size of a single multi-row DML statement (1 MiB). Both write paths
	// batch against it: the copy path splits chunks into chunklets
	// (splitRowsIntoChunklets) and the binlog-apply path cuts flush
	// batches (pkg/change) so a REPLACE/DELETE can't grow unbounded with
	// wide rows. Still far below the typical 64 MiB max_allowed_packet
	// because the size estimates are rough — utils.EstimateRenderedRowSize is
	// deliberately biased low (hex encoding, string escaping and wide
	// integers all under-measure; see its doc) and leans on that ~64x
	// headroom — and a single row larger than the budget still goes in
	// its own statement.
	//
	// One bound to respect when changing this: chunklets must keep the write
	// pool fed, so chunks-in-flight x chunklets-per-chunk has to stay above the
	// write worker count. At a 16 MiB target chunk, ~22 chunks in flight and a
	// write ceiling of 188 that means >= ~8.5 chunklets per chunk, i.e. the
	// budget can only roughly double before the largest pools start starving.
	// (When chunkletMaxRows binds instead, chunklets-per-chunk is set by the
	// row count and this bound is much looser.)
	MaxStatementSizeBytes = 1024 * 1024
)

Variables

This section is empty.

Functions

func IsFullKeyRange added in v0.18.0

func IsFullKeyRange(kr string) bool

IsFullKeyRange reports whether kr is a valid Vitess-style key range that covers the whole key space ("", "0", "-", "0-"). A one-target applier with such a range writes every row without routing; any other range routes, so rows outside it are refused. Callers that write one logical target use this to reject a partial range before setup.

func StatusRow added in v0.17.0

func StatusRow(a Applier) string

StatusRow renders a's Stats() as the applier row of a runner status block. Runner Status() can be called before the applier is constructed, so this must be nil-safe; the empty string it returns then makes the block drop the row.

func ValidateKeyRanges added in v0.16.0

func ValidateKeyRanges(ranges []string) error

ValidateKeyRanges parses each Vitess-style key range and checks that no two overlap — the same rules New enforces at construction. It exists so callers can fail fast on a bad shard layout before doing any work (e.g. move validates reverse-window source key ranges before the copy, since the sharded reverse applier is only constructed after the forward cutover).

Types

type Applier

type Applier interface {
	// Start initializes the applier and starts its workers
	Start(ctx context.Context) error

	// Apply sends rows to be written to the target(s).
	// The chunk parameter provides metadata about the source table and target table.
	// The rows parameter contains the actual row data to be written.
	// The callback is invoked when all rows are safely flushed.
	//
	// For the copier: callback will call chunker.Feedback()
	// For the subscription: callback will update binlog coordinates
	Apply(ctx context.Context, chunk *table.Chunk, rows [][]any, callback ApplyCallback) error

	// Stats returns a point-in-time snapshot of the write pipeline: queue
	// occupancy, pending work, live workers, mean rows per chunklet, and
	// rolling percentiles of the four per-chunklet phases (queue-wait, build,
	// write, handoff). Safe to call concurrently with Apply; values are
	// approximate. See the Stats type for field semantics.
	Stats() Stats

	// DeleteKeys deletes rows by their key values synchronously. Each entry
	// in keys is one key tuple of the original (typed) column values, in
	// sourceTable.KeyColumns order.
	// An empty locks slice means no under-lock flush: the delete runs on the
	// regular write connection(s). When locks is non-empty, the delete is
	// executed under the supplied table lock(s): one lock per target, each
	// acquired on that target's own connection (the same *sql.DB as
	// Target.DB). Each target's statements run under that target's lock,
	// matched by connection identity. A missing lock for any target, or a lock
	// that matches no target, is an error.
	// Returns the number of rows affected and any error.
	DeleteKeys(ctx context.Context, sourceTable, targetTable *table.TableInfo, keys [][]any, locks []*dbconn.TableLock) (int64, error)

	// UpsertRows performs an upsert (REPLACE INTO ... VALUES) synchronously.
	// The rows are LogicalRow structs containing the row images.
	// An empty locks slice means no under-lock flush; when locks is non-empty
	// the upsert is executed under the supplied table lock(s). See DeleteKeys
	// for the lock contract (one lock per target).
	// Returns the number of rows affected and any error.
	UpsertRows(ctx context.Context, mapping *table.ColumnMapping, rows []LogicalRow, locks []*dbconn.TableLock) (int64, error)

	// Wait blocks until all pending work is complete and all callbacks have been invoked
	Wait(ctx context.Context) error

	// Stops the applier workers
	Stop() error

	// GetTargets returns target information for direct database access.
	// This is used by operations like checksum that need to query targets directly.
	GetTargets() []Target
}

Applier is an interface for applying rows to one or more target databases. MySQLApplier is the implementation: it writes to a single target, or fans out to multiple targets based on a hash function.

The Applier is responsible for: - Batching/splitting rows into optimal write sizes - Tracking pending writes - Invoking callbacks when writes are complete

func NewSingleTargetForTest added in v0.13.0

func NewSingleTargetForTest(t *testing.T, db *sql.DB) Applier

NewSingleTargetForTest builds a single-target MySQLApplier suitable for use as the repl client's applier. The repl client requires a non-nil applier — every memory-comparable PK routes through bufferedMap, which calls Applier.UpsertRows / DeleteKeys. See issue #746.

type ApplierConfig

type ApplierConfig struct {
	Threads  int // number of write threads
	Logger   *slog.Logger
	DBConfig *dbconn.DBConfig
	// MetricsSink, when non-nil, makes the applier periodically report its
	// Stats() snapshot as gauges (see pkg/metrics applier_* names). Nil
	// disables emission entirely — no goroutine is started.
	MetricsSink metrics.Sink
}

ApplierConfig configures the write workers and their connections. Chunklet limits are fixed at chunkletMaxRows and MaxStatementSizeBytes.

func NewApplierDefaultConfig

func NewApplierDefaultConfig() *ApplierConfig

NewApplierDefaultConfig returns a default config for the applier.

func (*ApplierConfig) Validate

func (cfg *ApplierConfig) Validate() error

Validate checks the ApplierConfig for required fields.

type ApplyCallback

type ApplyCallback func(affectedRows int64, err error)

ApplyCallback is invoked when rows have been safely flushed to the target(s). affectedRows is the total number of rows affected across all targets. err is non-nil if there was an error applying the rows.

type LogicalRow

type LogicalRow struct {
	IsDeleted bool
	RowImage  []any
}

LogicalRow represents the current state of a row in the subscription buffer. This could be that it is deleted, or that it has RowImage that describes it. If there is a RowImage, then it needs to be converted into the RowImage of the newTable.

type MockApplier added in v0.18.0

type MockApplier struct {
	// Inner is the applier to delegate to. Nil means this mock stands alone
	// and every method succeeds without doing anything.
	Inner Applier

	// Targets is what GetTargets reports when Inner is nil.
	Targets []Target

	// FixedStats is what Stats reports when Inner is nil. The zero value is
	// an idle pipeline, which is what a test that does not care about stats
	// wants; set it when the code under test reads a field such as
	// ActiveWorkers.
	FixedStats Stats

	// Error injection. Each is returned by its method instead of delegating,
	// so a nil Inner is not required to make one fire.
	StartErr error
	ApplyErr error
	WaitErr  error
	StopErr  error

	// CallbackErr is reported through Apply's callback rather than by Apply
	// itself — the way a real applier surfaces a write that failed in its
	// coordinator goroutine, which is a different code path in the caller
	// from an Apply that returns an error.
	CallbackErr error
	// contains filtered or unexported fields
}

MockApplier is the shared Applier test double. It lives here rather than in each consumer's test files because every package that drives an applier — the checksum's repair path, the copier, the change feed's subscriptions, a sync runner — wants the same three things from a double, and had grown its own slightly different version of each: delegate to a real applier, or stand in for one entirely; record what was asked of it; and fail on demand.

It is a decorator, not a stub. With Inner set, every method delegates after recording, so a test can drive a real write path and still assert on the calls or inject a failure into one of them. With Inner nil, every method is a success-shaped no-op, which is what a test that only needs the interface satisfied wants. The two modes are the same type because tests routinely start as one and become the other.

It is safe to use from several goroutines. Recording is under a mutex; the error fields are not, so set them before the applier is handed to the code under test (or between synchronous calls, which is how the repair tests use them).

There is one case this is deliberately not for: a stub that exists only to satisfy a parameter the code under test must never call. Embed a nil applier.Applier there instead, so an unintended call panics and names itself rather than quietly succeeding.

func (*MockApplier) Apply added in v0.18.0

func (m *MockApplier) Apply(ctx context.Context, chunk *table.Chunk, rows [][]any, callback ApplyCallback) error

func (*MockApplier) ApplyCalls added in v0.18.0

func (m *MockApplier) ApplyCalls() [][][]any

ApplyCalls returns the row batches passed to Apply, in call order. The slices are the caller's own — do not retain them past the assertion.

func (*MockApplier) DeleteCalls added in v0.18.0

func (m *MockApplier) DeleteCalls() [][][]any

DeleteCalls returns the key batches passed to DeleteKeys, in call order.

func (*MockApplier) DeleteKeys added in v0.18.0

func (m *MockApplier) DeleteKeys(ctx context.Context, sourceTable, targetTable *table.TableInfo, keys [][]any, locks []*dbconn.TableLock) (int64, error)

func (*MockApplier) GetTargets added in v0.18.0

func (m *MockApplier) GetTargets() []Target

func (*MockApplier) Reset added in v0.18.0

func (m *MockApplier) Reset()

Reset clears the recorded calls without touching Inner or the error fields, so one mock can span several phases of a test.

func (*MockApplier) Start added in v0.18.0

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

func (*MockApplier) Starts added in v0.18.0

func (m *MockApplier) Starts() int

Starts, Stops and Waits report the lifecycle calls made so far. Tests assert on these to pin down who owns an applier's lifecycle — whether a repair starts one per chunk or reuses the caller's, for instance.

func (*MockApplier) Stats added in v0.18.0

func (m *MockApplier) Stats() Stats

func (*MockApplier) Stop added in v0.18.0

func (m *MockApplier) Stop() error

func (*MockApplier) Stops added in v0.18.0

func (m *MockApplier) Stops() int

func (*MockApplier) UpsertCalls added in v0.18.0

func (m *MockApplier) UpsertCalls() [][]LogicalRow

UpsertCalls returns the row batches passed to UpsertRows, in call order.

func (*MockApplier) UpsertRows added in v0.18.0

func (m *MockApplier) UpsertRows(ctx context.Context, mapping *table.ColumnMapping, rows []LogicalRow, locks []*dbconn.TableLock) (int64, error)

func (*MockApplier) Wait added in v0.18.0

func (m *MockApplier) Wait(ctx context.Context) error

func (*MockApplier) Waits added in v0.18.0

func (m *MockApplier) Waits() int

type MySQLApplier added in v0.18.0

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

MySQLApplier applies rows to one or more MySQL targets. Each target owns a key range, its own write connection and its own pool of write workers.

With one target covering the whole key space (the migration, datasync and unsharded-move case) every row goes to that target and no routing happens. With several targets (a move to a Vitess-style sharded destination) each row is routed by hashing its sharding column and picking the target whose key range contains the hash. The sharding column and hash function are configured per table in TableInfo.ShardingColumn and TableInfo.HashFunc, so different tables in one multi-table move can use different sharding keys.

func New added in v0.18.0

func New(targets []Target, cfg *ApplierConfig) (*MySQLApplier, error)

New creates a MySQLApplier that writes to targets. There must be at least one target, and no two key ranges may overlap. A single target may leave KeyRange empty (or "0"): it then covers the whole key space.

ApplierConfig.Threads is the write-worker count for EACH target, not a total divided between them (see pkg/move/move.go:WriteThreads).

func (*MySQLApplier) ActiveWriteWorkers added in v0.18.0

func (a *MySQLApplier) ActiveWriteWorkers() int

ActiveWriteWorkers returns the number of live write workers summed across all targets. SetWriteWorkers is per target, so with N targets this is N times the count SetWriteWorkers asked for.

func (*MySQLApplier) Apply added in v0.18.0

func (a *MySQLApplier) Apply(ctx context.Context, chunk *table.Chunk, rows [][]any, callback ApplyCallback) error

Apply sends rows to be written to the target(s). With several targets, rows are distributed across them based on the sharding column and hash function configured in the chunk's Table.ShardingColumn and Table.HashFunc.

func (*MySQLApplier) DeleteKeys added in v0.18.0

func (a *MySQLApplier) DeleteKeys(ctx context.Context, sourceTable, targetTable *table.TableInfo, keys [][]any, locks []*dbconn.TableLock) (int64, error)

DeleteKeys deletes rows by their key values synchronously, broadcasting to all shards. Each entry in keys is one primary-key tuple of the original (typed) column values, in sourceTable.KeyColumns order. If locks is non-empty, each shard's delete is executed under the table lock that was acquired on that shard's own connection (one lock per shard, matched via resolveShardLocks).

Note: we only track modifications by PRIMARY KEY, not by shard key (aka primary vindex). For this reason we can't extract the vindex value, and must instead broadcast the deletes to all shards. The vindex value is considered immutable, and we will error if it changes on an update.

targetTable may be nil, meaning the same name as sourceTable. A different name is used by migrations (the `_new` table) and by the reverse feed of a sharded-source move, which writes back to the source's retired `_old` tables.

func (*MySQLApplier) GetTargets added in v0.18.0

func (a *MySQLApplier) GetTargets() []Target

GetTargets returns the target database configurations for direct access. This is used by operations like checksum that need to query targets directly.

func (*MySQLApplier) SetInitialWriteWorkers added in v0.18.0

func (a *MySQLApplier) SetInitialWriteWorkers(n int)

SetInitialWriteWorkers sets the per-target worker count that subsequent starts spawn, without spawning workers. Call before Start (or after Stop has returned). A running applier is unchanged.

func (*MySQLApplier) SetWriteWorkers added in v0.18.0

func (a *MySQLApplier) SetWriteWorkers(n int)

SetWriteWorkers reconciles the live write-worker count of EACH target to n, spawning new workers or parking existing ones as needed. It is idempotent and safe to call repeatedly from an autoscaler. n is clamped to a minimum of 1 so every target always makes some progress. Calls before Start or after Stop() begins are no-ops.

Parking is cooperative: closing a worker's quit channel makes it exit the next time it returns to its select (after finishing any chunklet currently in flight), so no completion is ever lost. With several targets a controller may drive all of them from the busiest target's load, so a busy shard slows the entire move.

func (*MySQLApplier) Start added in v0.18.0

func (a *MySQLApplier) Start(ctx context.Context) error

Start initializes all shard workers and begins processing. This does not control the synchronous methods like UpsertRows/DeleteKeys. This method is idempotent and can restart the applier after Stop() is called.

Lifecycle: callers MUST call Stop() to terminate the per-shard write workers and the single feedbackCoordinator. Cancelling the ctx passed here does NOT by itself shut down the goroutine pipeline — it only aborts in-flight writes. Workers for a shard exit when its chunkletBuffer is closed (by Stop) or when their quit channel is closed (by SetWriteWorkers scaling down), and the coordinator exits when every shard's chunkletCompletions has been closed (by Stop after joining each shard's workers). Failing to call Stop() will leak goroutines.

func (*MySQLApplier) Stats added in v0.18.0

func (a *MySQLApplier) Stats() Stats

Stats returns a point-in-time snapshot of the write pipeline, aggregated across shards: queue depth/cap are summed, and active workers is the sum of each shard's pool count — goroutines currently running, which during a shrink can briefly exceed the number of quit channels the pool still tracks, since a retired worker is removed from quits before it returns. The embedded mutex is held so the buffer reads cannot race Start()'s channel reinitialization on restart; len/cap on a closed channel are safe.

func (*MySQLApplier) Stop added in v0.18.0

func (a *MySQLApplier) Stop() error

Stop signals the applier to shut down gracefully. This does not control the synchronous methods like UpsertRows/DeleteKeys, which can continue after Stop() is called. This method is idempotent - calling it multiple times is safe.

func (*MySQLApplier) UpsertRows added in v0.18.0

func (a *MySQLApplier) UpsertRows(ctx context.Context, mapping *table.ColumnMapping, rows []LogicalRow, locks []*dbconn.TableLock) (int64, error)

UpsertRows performs an upsert (REPLACE INTO ... VALUES) synchronously, distributing rows across shards. The rows are LogicalRow structs containing inline row images from the binlog. If locks is non-empty, each shard's upsert is executed under the table lock that was acquired on that shard's own connection (one lock per shard, matched via resolveShardLocks).

REPLACE semantics, and why we use them:

MySQL's `REPLACE INTO target (cols) VALUES (...)` treats each value tuple as an INSERT, except that for any row in `target` that conflicts with the new row on PRIMARY KEY *or any UNIQUE index*, the old row is deleted before the new row is inserted. Per the docs, conflicts on multiple unique indexes can lead to multiple deletions for a single new row.

Two implications matter for callers reading this code:

  1. A single REPLACE may delete rows whose PKs are *not* in the `rows` argument. If row B's image collides on a unique key with some other row A currently in the destination (because A was the previous holder of that unique value), REPLACE deletes A while inserting B. A is then transiently missing from the destination until its own event arrives in a later flush (or a later batch in the same flush) and re-inserts it. This is what restores the order-independence the pre-#821 deltaMap had with `REPLACE INTO ... SELECT`. See block/spirit#847.

  2. Eventual consistency. Between the moment REPLACE deletes A and the moment A's image is re-applied, the destination is not a valid snapshot of source — it has fewer rows. Spirit relies on the bufferedMap being an *up-to-date and disjoint* representation of pending changes (each PK appears at most once, holding the latest row image) so that every transiently-deleted row will be re-inserted as flushes progress. The destination converges back to source's current state once the last unflushed event for each affected PK has been applied. The post-copy checksum, which repairs, is the backstop that catches any divergence that survives.

We supply inline row images rather than `REPLACE INTO ... SELECT FROM source`, so the read-after-commit race that motivated #746 does not apply.

Sharding: we only track modifications by PRIMARY KEY, not by shard key (aka primary vindex). For this reason we could get in trouble if there was a PK update that mutated the vindex column. This is because we would only see the last operation (modification) and not know to DELETE from one of the shards.

The way we address this, is we consider the vindex column immutable. The replication client is told that it should error if there are any updates to it, and the entire operation is canceled. The enforcement lives in pkg/change: the subscription resolves the sharding column to an ordinal (Subscription.ImmutableColumnOrdinal) and feedCore.processRowsEvent fails fatally when an UPDATE's before/after images differ at that position (see change.checkImmutableColumn).

This is likely not too big of a limitation, as Vitess itself recommends that vindex columns be immutable. If it turns out to be a problem, we can revisit tracking by other columns later.

The sharding column and hash always come from the mapping's SOURCE table — the watched table whose row images we are routing.

func (*MySQLApplier) Wait added in v0.18.0

func (a *MySQLApplier) Wait(ctx context.Context) error

Wait blocks until all pending work is complete and all callbacks have been invoked. Checking callbacksInFlight in addition to len(pendingWork) is what upholds the "all callbacks have been invoked" half of the contract: claimed work has already left the map, but its callback may still be running (#765).

type Stats added in v0.16.0

type Stats struct {
	// QueueDepth is the number of chunklets currently waiting in the
	// buffer(s) — summed across shards for the sharded applier.
	QueueDepth int
	// QueueCap is the total buffer capacity (summed across shards).
	QueueCap int
	// PendingWork is the number of chunks accepted by Apply() whose
	// callback has not fired yet (queued + in-flight).
	PendingWork int
	// ActiveWorkers is the number of live write workers.
	ActiveWorkers int
	// RowsPerChunklet is the mean rows per chunklet since the applier started.
	// splitRowsIntoChunklets cuts on whichever of chunkletMaxRows or
	// MaxStatementSizeBytes binds first, and which one that is depends on the
	// table's width and column types — so it cannot be predicted from the
	// config, only measured. It matters because the chunklet is the unit of
	// nearly everything on the write path: one statement, one completion, one
	// handoff. A value at chunkletMaxRows means the row cap binds and raising
	// MaxStatementSizeBytes would do nothing.
	//
	// A value below the row cap does NOT by itself mean the byte cap binds:
	// the mean also drops when the batches handed to Apply() are small — each
	// chunk's remainder is a short chunklet, and on the sharded applier a
	// chunk is split per shard *after* fan-out, so the same chunk yields more,
	// shorter chunklets. The caps-question reading is only sound on the copy
	// path's large steady-state batches, where the remainder is noise; there,
	// well below the row cap means the byte cap is what's cutting.
	//
	// A mean rather than a percentile: the question is which cap is in force,
	// which the mean answers, and the last chunklet of every chunk is a short
	// remainder that would skew a low percentile.
	RowsPerChunklet float64

	// Rolling percentiles over the last timingRingSize chunklets. Zero when no
	// chunklet has completed yet. Together these account for a write worker's
	// whole cycle, which matters when the pipeline stops responding to more
	// workers: each phase is limited by something different, and only one of
	// them is the target's write capacity.
	//
	// QueueWait is the time a chunklet spent between Apply() offering it to
	// the buffer and a write worker dequeueing it (including send-side
	// backpressure when the buffer is full).
	QueueWaitP50 time.Duration
	QueueWaitP90 time.Duration
	// BuildTime is the client-side cost of turning the chunklet's rows into an
	// INSERT statement — a datum conversion and a string format per value, so
	// it scales with rows × columns and is spent on spirit's own CPU while
	// holding no connection. It is a *component of* WriteTime, not additional
	// to it; subtract it to get time actually spent at the server. A BuildTime
	// approaching WriteTime means the client is the bottleneck, which no
	// server-side signal (CPU, commit latency, Threads_running) can report and
	// which more write workers cannot fix.
	BuildTimeP50 time.Duration
	BuildTimeP90 time.Duration
	// WriteTime is the time spent turning the chunklet into a statement and
	// executing it against the target(s) — BuildTime plus the round trip,
	// including any retry backoff inside it.
	WriteTimeP50 time.Duration
	WriteTimeP90 time.Duration
	// Handoff is the time a write worker spent publishing its completion after
	// the write finished. A single feedbackCoordinator goroutine drains those
	// completions and invokes the chunk callback inline, so this is where a
	// slow callback shows up as backpressure on every write worker at once.
	// Non-trivial Handoff with the queue pegged means workers are blocked
	// behind the completion path rather than the target, and adding workers
	// will not help.
	HandoffP50 time.Duration
	HandoffP90 time.Duration
}

Stats is a point-in-time snapshot of an applier's write pipeline. It exists so status blocks and metrics can distinguish a read-limited pipeline (queue near empty) from a write-limited one (queue pegged at capacity with queue-wait far above write time) — without this, write-side saturation is invisible: the copier's end-to-end chunk feedback misattributes it to the read side. All fields are approximate; they are read without pausing the pipeline.

func (Stats) String added in v0.16.0

func (s Stats) String() string

String renders the snapshot as the applier row of a runner's status block, so migrate, move and sync report identical fields. Durations are rounded to the millisecond — finer precision is noise at status cadence. The fields are not prefixed with "applier-": the row is labelled, which is the whole point of the block layout.

It renders a deliberately small subset of Stats, because the status block is read every 30 seconds by a human and a field that reads the same on every healthy run costs attention without paying it back (#329). Five fields are always present — queue occupancy, worker count, queue wait, and the write p50/p90 — because those are what you steer by.

Two more appear only when they have something to say: build time when it is a large enough share of write time to mean the client is the bottleneck (and large enough in absolute terms to be worth reading), and handoff when it rises off the floor. Both diagnose a pipeline that has stopped responding to more write workers (see github.com/block/spirit/issues/1097), and both are silent on a healthy run — so their *presence* is the signal, and their absence is not a gap.

Nothing is lost by trimming: every field stays on Stats, and the metrics sink emits them all, which is what dashboards should read anyway.

type Target

type Target struct {
	DB       *sql.DB
	Config   *mysql.Config
	KeyRange string // Vitess-style key range: "-80", "80-", "80-c0"; "", "0" or "-" for unsharded
}

Target represents a shard target with its database connection, configuration, and key range. Key ranges are expressed as Vitess-style strings (e.g., "-80", "80-", "80-c0"). An empty string, "0" or "-" means all key space (unsharded).

Jump to

Keyboard shortcuts

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