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

move

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

README

move

The move package implements multi-table database migration for MoveTables and resharding operations. While the migration package performs online schema changes (ALTER TABLE on one or more tables within the same server), move copies one or more tables between different MySQL servers, optionally resharding data across multiple targets.

Lifecycle

A move operation follows this sequence:

  1. Pre-run checks — validate configuration before opening database connections.
  2. Setup — discover source tables, create targets, run preflight/post-setup checks.
  3. Copy rows — bulk-copy all source tables to their targets using the copier.
  4. Initial checksum (post-copy phase) — flush the binlog backlog, restore any secondary indexes that were deferred during table creation (see DeferSecondaryIndexes below), run ANALYZE TABLE, and verify data consistency between source and targets. This is the correctness gate; cutover does not proceed unless this checksum succeeds.
  5. Sentinel wait — optionally pause before cutover, allowing external orchestration to confirm readiness. While the sentinel blocks cutover, a continuous checksum loop runs in the background to keep re-verifying the data; the loop is interrupted as soon as the sentinel is dropped.
  6. Cutover — acquire a table lock, flush remaining replication changes, execute the caller-provided cutover function, and rename original tables out of the way.
  7. Reverse window (optional) — when ReverseWindow is set, the move does not exit after cutover; it holds a change-only reverse feed (targets → the source's _old tables) for the configured duration so the move can be rolled back. See Reverse Window below.

Design Decisions

Multi-table Awareness

move discovers and copies all tables in each source database (or a specified subset). Each source has one change feed tracking its tables. With multiple sources, the final renames run per source under the source locks; there is no cross-server atomic RENAME TABLE. migration also supports multiple tables, with one atomic rename on its single server.

Sharding Support

When a ShardingProvider is configured, each source table is annotated with a sharding column and hash function during discovery. The applier uses this metadata to route rows to the correct target based on key ranges. Without a sharding provider, the operation is a simple 1:1 move to a single target.

Autoscaling

Every move monitors its Aurora targets, whether or not autoscaling is skipped. pkg/host groups target connections independently of schema and credentials; the same groups drive index restoration and Aurora monitor ownership. One monitor per host feeds a maximum-utilization multi-throttler, which pauses the copy and narrows binlog flushes when any target is overloaded.

Unless SkipAutoscaling is set, move adds conservative host-aware thread scaling on top of that signal, for both single-target and sharded moves. The copier resizes every shard's write pool together, and both the initial and continuous checksum passes consume the same signal. A small Aurora target selects fixed single-threaded operation instead of a controller. Bounds account for targets sharing a host and the client CPU budget. See the flag documentation for eligibility, fallback and limitations.

Deferred Secondary Indexes

The DeferSecondaryIndexes option creates target tables without secondary indexes, adding them back before cutover. This can significantly speed up the bulk copy phase since index maintenance is avoided until the data is in place.

However, this optimization is not always safe:

  • You can run out of temporary disk space
  • The History List Length will grow during re-adding indexes due to MySQL bug #113476.
Checkpoint and Resume

Move operations write periodic checkpoints to a _spirit_move_checkpoint table on the first target. If a move is interrupted, the runner detects the existing checkpoint during setup and resumes from the last recorded binlog position rather than starting over. DDL changes on source tables during a move invalidate the checkpoint to prevent resuming into an inconsistent state. The name is intentionally distinct from migration's shared _spirit_checkpoint and datasync's _spirit_sync_checkpoint — see below for why that distinctness matters.

The checkpoint records progress into the targets (the copier watermark, and the rows deleteAboveWatermark prunes on resume), so it lives alongside the data it describes. With N sources and M targets, both slices are sorted deterministically and the checkpoint always lands on the first target (targets[0]), so a resume looks in the same place regardless of the order the caller supplied sources or targets. (Earlier 1:N reshard versions stored the checkpoint on the single source; that convention is no longer used.)

A run interrupted before its first checkpoint dump leaves the checkpoint table present but empty. Because _spirit_move_checkpoint is uniquely named, only a move can have created it — transiently, after the target tables passed the empty-target validation and before any row is copied — so everything on the target is that attempt's partial copy, and a re-run recovers automatically: it wipes the target tables and starts a fresh copy, no --force needed. The unique name is what makes this safe: a leftover _spirit_checkpoint from an unrelated migration is not proof of a dead move, so move never keys ownership on it. --force remains for the states spirit cannot prove it owns (a non-empty target with no move checkpoint table, or a checkpoint written by an incompatible spirit version).

Sentinel Table

When DeferCutOver is enabled, the runner creates a _spirit_sentinel table on the first target (targets[0], alongside the checkpoint) during setup (before the copy starts) and then blocks before cutover until it is dropped by an external actor. The wait sits between the initial checksum and the cutover. This provides a coordination point for orchestration systems that need to perform additional steps between copy completion and cutover.

Only a deferred move waits, and DeferCutOver is read once, at startup. Without it, the runner neither creates nor waits on a sentinel: it cuts over even while a _spirit_sentinel exists on targets[0], whether created by hand during the run or left behind by an earlier deferred run that was cancelled. The rule is flags.Cutover.WaitsOnSentinel, shared with migrate.

While the sentinel blocks the cutover, the runner re-runs the checksum in a loop (the "continuous checksum") so that the data is re-verified close to the moment of cutover, even if the sentinel sits for hours. The first iteration starts one hour after the initial checksum, and subsequent iterations are capped at one per hour; the wait is interrupted when the sentinel is dropped. Both checksum modes use the same lockless checker, aggregating each chunk across every source and every target. The continuous checksum does not repair: a divergence that survives a drain of the change feeds aborts the move, and a resumed move's initial checksum repairs it. See docs/move.md for the user-facing description.

Cutover Function

The cutover is split into two parts: a caller-provided function and a table rename. The caller's function runs under a table lock with all replication changes flushed, giving it a consistent view. If it succeeds, the runner renames the original tables to _old suffixes.

This separation allows orchestration systems to perform routing changes, such as updating a DNS server or Vitess topology server as part of the atomic cutover.

Before the caller's function runs (still under the source locks, after the final flush), each target table's AUTO_INCREMENT counter is raised to at least the highest counter among the sources. The copy and the change feed only carry rows, so without this a target would issue again any id the source had issued above the highest row it copied, e.g. rows inserted and deleted during the move. A reverse-window rollback does the same in the other direction before the source's _old tables are put back into service. Tables without an AUTO_INCREMENT column are skipped.

The final flush and the cutover function must complete within 10 minutes. Spirit sets wait_timeout=600 on every connection, and no statement is sent on the source lock sessions between LOCK TABLES and the rename. If the flush plus the cutover function take longer, MySQL closes the lock sessions and releases the source locks, and writes can reach the source tables before they are renamed. The reverse cutover's function (SetReverseCutover) has the same limit for the target locks.

Reverse Window

ReverseWindow (the --reverse-window flag) makes a cutover reversible for a bounded period. Instead of exiting after the cutover rename, the runner stands up a change-only reverse feed — the former targets become change sources and the source's now-retired _old tables become the write target (reversefeed.go, from the reverse-feed foundations) — and holds it for the window (reversewindow.go). The feed's start position is captured in the cutover's postSwitch hook, under the source lock, so no target write is missed.

The window ends in one of three ways: it elapses (finalize forward — the source stays retired, checkpoint dropped), the feed dies (finalize forward — rollback is no longer safe), or a rollback is requested.

A rollback is requested by creating a _spirit_move_revert marker table on the first target (revertmarker.go), mirroring the sentinel but with inverted polarity (the operator creates it to act, rather than dropping it to proceed). The reverse cutover mirrors the forward one with roles swapped, plus one asymmetry: the source's _old tables are renamed back to their real names before traffic returns. The former targets are then retired to a _revert suffix — distinct from _old so a subsequent move can recognize and drop them (dropStaleRevertTables clears leftovers, making revert→retry idempotent). A separate reverseCutoverFunc (registered via SetReverseCutover, the mirror of the cutover function above) performs the routing switch back to the source.

Two constraints and one resume note:

  • Unsharded source only — reverse-window is a 1→M forward move reversed as M→1; a sharded source would need an M:N reverse and is rejected at startup.
  • Stale-marker guard — a _spirit_move_revert present at pre-flight or pre-cutover aborts the run, so a leftover from an interrupted rollback is never read as a fresh request.
  • Resume — the checkpoint gains move_phase (reverse_window / reverting) and cutover_at columns, so a move killed during the window resumes back into it rather than re-copying. During the window the checkpoint's position is advanced to the reverse feeds' flushed positions every CheckpointDumpInterval, so the resumed feeds start from what they had already applied, not from the cutover. The reverting phase (mid-rollback) is not auto-resumed and must be completed manually.

Reverse-window writes use the configured WriteThreads count (including its default), independently of the forward autoscaler’s resolved count. This keeps uninterrupted and resumed reverse windows consistent without applying forward-target capacity assumptions to the reverse destinations.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type CutOver

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

func NewCutOver

func NewCutOver(sources []CutOverSource, cutoverFunc func(ctx context.Context) error, dbConfig *dbconn.DBConfig, logger *slog.Logger) (*CutOver, error)

NewCutOver creates a new CutOver that handles multiple sources.

func (*CutOver) Run

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

func (*CutOver) SetChecksUnderLock added in v0.18.0

func (c *CutOver) SetChecksUnderLock(fn func(ctx context.Context) error)

SetChecksUnderLock registers checks to run under the source locks after the final flush and before the traffic switch. See CutOver.checksUnderLock.

func (*CutOver) SetCutoverWithResult added in v0.17.0

func (c *CutOver) SetCutoverWithResult(fn CutoverResultCallback)

SetCutoverWithResult installs a result-bearing forward cutover callback. It is mutually exclusive with the legacy error-only callback.

func (*CutOver) SetPostSwitch added in v0.16.0

func (c *CutOver) SetPostSwitch(fn func(ctx context.Context) error)

SetPostSwitch registers a hook to run once under the source locks, after the traffic switch and before the source rename. See CutOver.postSwitch.

func (*CutOver) SetPreSwitch added in v0.17.0

func (c *CutOver) SetPreSwitch(fn func(ctx context.Context) error)

SetPreSwitch registers a hook after the final source flush and before the traffic switch, under the source locks. See CutOver.preSwitch.

type CutOverSource added in v0.13.0

type CutOverSource struct {
	DB         *sql.DB
	ReplClient change.Source
	Tables     []*table.TableInfo
}

CutOverSource holds per-source state needed for the cutover.

type CutoverResult added in v0.17.0

type CutoverResult struct {
	DurableMutation    bool
	OwnershipAmbiguous bool
}

CutoverResult reports authoritative evidence from a caller-owned cutover callback, including failures after a durable mutation and failures whose ownership outcome cannot be determined.

type CutoverResultCallback added in v0.17.0

type CutoverResultCallback func(context.Context) (CutoverResult, error)

CutoverResultCallback is the result-bearing cutover callback form.

A cutover callback (this form or the plain one passed to SetCutover or SetReverseCutover) runs while the move holds LOCK TABLES on sessions it sends no statements on. The final flush of the change feeds and the callback together must complete within 10 minutes, the wait_timeout spirit sets on every connection. Otherwise MySQL closes the idle lock sessions and releases the locks before the tables are renamed, and writes can reach the tables the traffic switch moved away from.

type Move

type Move struct {
	// Common holds the flags shared with migrate and sync: thread counts
	// (--write-threads is per target), --max-connections (each source and
	// target pool; dedicated monitor/advisory pools are separate),
	// --max-commit-latency (any target), autoscaling (the busiest target host
	// scales every shard together), TLS and --checkpoint-max-age.
	flags.Common
	// Cutover holds the flags shared with migrate: lock timeouts and
	// --defer-cutover (the sentinel lives on the first target).
	flags.Cutover

	SourceDSN             string `name:"source-dsn" help:"Where to copy the tables from." default:"spirit:spirit@tcp(127.0.0.1:3306)/src"`
	TargetDSN             string `name:"target-dsn" help:"Where to copy the tables to." default:"spirit:spirit@tcp(127.0.0.1:3306)/dest"`
	DeferSecondaryIndexes bool   `` /* 140-byte string literal not displayed */
	// Force makes the runner wipe the target tables and start the copy fresh when
	// it cannot resume from a checkpoint (e.g. the checkpoint is from an
	// incompatible spirit version, or the target is in a state resume can't
	// validate). Without it, an unresumable non-empty target is a hard error.
	Force bool `` /* 152-byte string literal not displayed */

	// ReverseWindow, when > 0, keeps the move alive after cutover in change-only
	// reverse mode (targets→source) for this long, so the move can be rolled back
	// before the source is retired. 0 (the default) is a normal cutover. During
	// the window the source's now-retired _old tables are kept current from the
	// targets; an operator rolls back by creating the _spirit_move_revert table on
	// the first target (see revertmarker.go), otherwise the window elapses and the
	// move finalizes forward. A sharded (multi-DSN) source additionally requires
	// ReverseShardingProvider and SourceKeyRanges so the reverse feed can route
	// rows back to the correct source shard — see the guard in Runner.Run. The
	// data plane is ReverseFeed (reversefeed.go); the post-cutover driver is
	// reverseWindow (reversewindow.go).
	ReverseWindow time.Duration `` /* 166-byte string literal not displayed */

	// SourceTables optionally specifies a list of tables to move.
	// If empty, all tables in the source database will be moved.
	// This is useful for Vitess MoveTables operations where only specific tables should be moved.
	SourceTables []string

	// ShardingProvider optionally provides vindex metadata for resharding operations.
	// If nil, tables will not have vindex configuration (suitable for simple MoveTables 1:1 operations).
	// For resharding operations (1:many), this should be set to provide sharding key information.
	// The provider is called during table discovery to configure ShardingColumn and HashFunc
	// on each TableInfo.
	// SourceDSNs optionally specifies multiple source DSNs for N:M resharding operations.
	// When set, each DSN represents a separate source shard. All sources must have identical
	// table schemas. If empty, SourceDSN is used as the single source.
	SourceDSNs []string `kong:"-"`

	// SourceKeyRanges optionally specifies each source shard's Vitess-style key
	// range ("-80", "80-", ...), parallel to SourceDSNs (SourceKeyRanges[i] is
	// SourceDSNs[i]'s range). Required, together with ReverseShardingProvider,
	// when ReverseWindow > 0 and the source is sharded (len(SourceDSNs) > 1):
	// the reverse feed routes rows flowing back from the targets to the source
	// shard whose range contains the row's hash. Unused otherwise.
	SourceKeyRanges []string `kong:"-"`

	ShardingProvider table.ShardingMetadataProvider `kong:"-"`

	// ReverseShardingProvider provides the SOURCE keyspace's sharding metadata
	// (vindex column + hash) for the reverse feed of a reverse-window move with
	// a sharded source. It is consulted for each moved table when the window
	// opens; a table without metadata is a hard error there, because reverse
	// writes could not be routed to a source shard. Note the asymmetry with
	// ShardingProvider, which describes the TARGET keyspace for the forward copy.
	ReverseShardingProvider table.ShardingMetadataProvider `kong:"-"`

	Targets []applier.Target `kong:"-"`
}

func (*Move) Run

func (m *Move) Run() error

func (*Move) Validate added in v0.15.0

func (m *Move) Validate() error

Validate is called by Kong after parsing to check for invalid flag values. Zero values mean "use the default" (NewRunner fills them in), so they are not rejected here; only explicitly-negative or otherwise invalid values are caught. Mirrors migration.Migration.Validate.

type ReverseFeed added in v0.16.0

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

ReverseFeed is a running change-only reverse feed: one change.Source per ReverseSource, all sharing a single applier that writes to U (mirrors the forward move, where N sources share one applier).

func NewReverseFeed added in v0.16.0

func NewReverseFeed(ctx context.Context, cfg ReverseFeedConfig) (_ *ReverseFeed, err error)

NewReverseFeed wires the feeds and their shared applier. It does not open any binlog stream — call Start for that — but it does query each source server once: change.NewAutoClient selects (and validates) the change-source coordinate scheme per source, so e.g. a GTID-set Position on a server that no longer has GTIDs enabled fails here with a clear error rather than as a stream failure at Start.

func (*ReverseFeed) AllChangesFlushed added in v0.17.0

func (rf *ReverseFeed) AllChangesFlushed() bool

AllChangesFlushed reports whether every feed has applied its whole buffer.

Flush returning nil is not the same question, and callers that are about to discard the buffer must ask this one instead. A drain may decline to finish — lock contention it could not resolve in its budget, or a drain cut short to bound how long it holds the flush mutex — and reports that by leaving the changes buffered rather than by erroring, because for the periodic flusher "try again next tick" is the right response and failing the whole change is not. Flush's own loop compounds it: it exits once the backlog is merely *trivial*, not empty, so a residual below that threshold returns nil by design.

Mirrors the forward cutover's check in cutover.go, which pairs the two calls for exactly this reason.

func (*ReverseFeed) Close added in v0.16.0

func (rf *ReverseFeed) Close()

Close stops periodic flush and closes all feeds. Safe to call more than once. The applier is never Started (the subscription apply path is synchronous), so there is nothing to Stop on it.

func (*ReverseFeed) Err added in v0.16.0

func (rf *ReverseFeed) Err() error

Err returns the first fatal feed error, if any.

func (*ReverseFeed) Flush added in v0.16.0

func (rf *ReverseFeed) Flush(ctx context.Context) error

Flush drains all feeds synchronously (e.g. before a health check or a reverse cutover, so U reflects everything written to the sources so far).

func (*ReverseFeed) Positions added in v0.16.0

func (rf *ReverseFeed) Positions() []string

Positions returns each source's current safe-to-resume position, in the same order as the configured sources. Intended for the caller's checkpoint so the window can resume in reverse mode after a restart.

func (*ReverseFeed) Start added in v0.16.0

func (rf *ReverseFeed) Start(ctx context.Context) error

Start opens every feed (StartFromPosition when a Position is set, else from current head), switches it to change-only mode, and begins periodic flush.

type ReverseFeedConfig added in v0.16.0

type ReverseFeedConfig struct {
	Sources []ReverseSource
	// Target is the U side when the former move source was a single database.
	// Target.DB's default database MUST be U's schema (see ReverseSource.DB).
	// Mutually exclusive with Targets.
	Target applier.Target
	// Targets is the U side when the former move source was SHARDED: one entry
	// per former source shard, each with its Vitess-style key range set. Rows
	// are routed by the WATCHED table's sharding metadata, so every
	// ReverseSource table must have ShardingColumn and HashFunc set (the source
	// keyspace's vindex) — NewReverseFeed fails otherwise. Each Targets[i].DB's
	// default database MUST be that shard's schema (see ReverseSource.DB).
	// Mutually exclusive with Target.
	Targets []applier.Target
	// TargetTables maps each watched (reverse-source) table NAME to the U-side
	// TableInfo it is written to, built on Target.DB (with Targets, on any one
	// shard: the schemas are identical and the name is unqualified, so each
	// shard's own connection determines where the write lands). It is a map,
	// not a slice, because the names can differ: after a forward cutover the
	// source tables are renamed to their _old form, so a watched "t1" is
	// written to "t1_old".
	TargetTables map[string]*table.TableInfo

	Logger        *slog.Logger
	DBConfig      *dbconn.DBConfig
	Threads       int           // applier write threads; 0 => default (4)
	FlushInterval time.Duration // 0 => change.DefaultFlushInterval
}

ReverseFeedConfig configures a ReverseFeed.

type ReverseSource added in v0.16.0

type ReverseSource struct {
	DB       *sql.DB            // connection to S (default DB == its schema)
	Addr     string             // host:port for the binlog syncer
	User     string             // binlog syncer user
	Password string             // binlog syncer password
	Tables   []*table.TableInfo // S-side tables to watch, built on DB
	// Position is the opaque change.Source position to resume from: the one
	// captured at cutover, or on a resumed window the feed's flushed position
	// as last checkpointed during the window (see
	// reverseWindow.checkpointPositions). Empty means start from the source's
	// current head. Its encoding
	// also selects the change-source coordinate scheme, exactly like a
	// checkpoint resume (see change.NewAutoClient): a GTID set resumes through
	// the GTID client (and requires the server to still have GTIDs enabled), a
	// file:offset position through the binlog client, and the empty head-start
	// case probes the server so the scheme matches what a cutover capture on
	// that server would have produced.
	Position string
}

ReverseSource is one reverse source: a former move target (shard S) whose binlog is tailed to keep the former move source (U) current.

DB's default database MUST be this source's schema. table.TableInfo.SetInfo resolves columns via information_schema WHERE table_schema=DATABASE() — the connection's default database, not the SchemaName argument — so a connection defaulting to the wrong schema silently loads the wrong table's definition, surfacing only at apply time as a fatal column-count mismatch.

type Runner

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

func NewRunner

func NewRunner(m *Move) (*Runner, error)

func (*Runner) Abort added in v0.18.0

func (r *Runner) Abort(cause error)

Abort stops a running move with cause (see status.Aborter). The checkpoint dumper calls it when it cannot write a checkpoint, so Run returns the write error instead of context.Canceled.

func (*Runner) Cancel

func (r *Runner) Cancel()

Cancel stops a running move. It is an operator cancellation: Run returns context.Canceled.

func (*Runner) Close

func (r *Runner) Close() error

func (*Runner) DumpCheckpoint

func (r *Runner) DumpCheckpoint(ctx context.Context) error

DumpCheckpoint is called approximately every minute. It writes the current state of the migration to the checkpoint table, which can be used in recovery. Previously resuming from checkpoint would always restart at the copier, but it can now also resume at the checksum phase.

func (*Runner) Progress

func (r *Runner) Progress() status.Progress

func (*Runner) Result added in v0.17.0

func (r *Runner) Result() status.WorkflowResult

Result returns correctness evidence retained from the most recent Run invocation. It is intentionally separate from phase metrics.

func (*Runner) Run

func (r *Runner) Run(ctx context.Context) (retErr error)

func (*Runner) SetCutover

func (r *Runner) SetCutover(cutover func(ctx context.Context) error)

SetCutover installs the caller-owned forward traffic switch. It runs under the source table locks and, together with the final flush, must complete within 10 minutes (see CutoverResultCallback).

func (*Runner) SetCutoverWithResult added in v0.17.0

func (r *Runner) SetCutoverWithResult(cutover CutoverResultCallback)

SetCutoverWithResult installs a result-bearing forward cutover callback. It is mutually exclusive with SetCutover; the most recent setter wins.

func (*Runner) SetLogger

func (r *Runner) SetLogger(logger *slog.Logger)

func (*Runner) SetMetricsSink added in v0.17.0

func (r *Runner) SetMetricsSink(sink metrics.Sink)

SetMetricsSink installs the destination for this run's metrics, including the workflow phase transitions reported by status.Tracker. It must be called before Run; a nil sink is ignored.

func (*Runner) SetReverseCutover added in v0.16.0

func (r *Runner) SetReverseCutover(fn func(ctx context.Context) error)

SetReverseCutover registers the legacy rollback traffic switch used if a revert is requested during the reverse window. It runs under the target table locks and, together with the reverse feed's final flush and the source renames, must complete within 10 minutes (see CutoverResultCallback).

func (*Runner) SetReverseCutoverWithResult added in v0.17.0

func (r *Runner) SetReverseCutoverWithResult(fn CutoverResultCallback)

SetReverseCutoverWithResult installs the result-bearing reverse cutover callback. It is mutually exclusive with SetReverseCutover.

func (*Runner) Status

func (r *Runner) Status() string

Status returns the periodic report on the whole move: a header line plus one indented row per subsystem (see runtime.Snapshot.Status).

Jump to

Keyboard shortcuts

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