migration

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: 33 Imported by: 1

README

Migration

The migration package is used to orchestrate schema changes to one or more tables. Each change is tracked in the runner, with the copier and checker largely agnostic that they may be copying multiple tables at once.

There is one change source (change.Source) for all changes, and a subscription is added for each table:

┌─────────────────────────────────────────────────────────────────────────────────────────┐
│                                    SPIRIT MIGRATION                                     │
│                                                                                         │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │                   RUNNER (Orchestrator) — pkg/migration/runner.go                   │ │
│ │                                                                                     │ │
│ │          Owns and coordinates every component below, across the lifecycle:          │ │
│ │            1. Setup (_new + checkpoint tables)   2. Start change source             │ │
│ │           3. Run copier   4. Disable watermark opt.   5. Initial checksum           │ │
│ │           6. Wait on defer-cutover sentinel    7. Cutover (RENAME TABLE)            │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│                                                                                         │
│   ┌────────────────────┐        ┌────────────────────┐        ┌────────────────────┐    │
│   │       COPIER       │        │   CHANGE SOURCE    │        │      CHECKER       │    │
│   │                    │        │   (binlogClient)   │        │     (Checksum)     │    │
│   │  reads the source  │        │   reads binlog;    │        │   verifies that    │    │
│   │  table in chunks   │        │ one sub. per table │        │   source == _new   │    │
│   └──────────┬─────────┘        └──────────┬─────────┘        └────────────────────┘    │
│              │ row images                  │ row images                                 │
│              │ (buffered copier)           │ (always)                                   │
│              └──────────────┬──────────────┘                                            │
│                             ▼                                                           │
│                     ┌───────────────┐                                                   │
│                     │    APPLIER    │ ── REPLACE INTO _new VALUES (…) ──►  _new table   │
│                     │  pkg/applier  │                                                   │
│                     └───────────────┘                                                   │
│                                                                                         │
└─────────────────────────────────────────────────────────────────────────────────────────┘

The copier and change source are running in parallel during step 3. The only hard requirement is that the change source starts before the copier so we can ensure that all changes are tracked.

The copier is essentially a dirty-copy, in that each chunk does not correctly keep track of changes being made. This is reconciled via the change source, which detects any changes that have been made and applies them to the new table consistently.

Once copying is complete, a checksum process is started. This ensures that all data has safely made it to the new table, and it is safe to cutover.

What parts of the process are locking?

To answer this question, we need to understand that there are two types of locks:

  1. Data locks, aka InnoDB row-level locks. These are configurable via innodb_lock_wait_timeout: the server default is 50s, but spirit overwrites this to 3s.
  2. Metadata locks (MDL). These are configurable via lock_wait_timeout: the server default is 1 year(!), but spirit overwrites this to 30s (configurable by --lock-wait-timeout).

Spirit takes no data locks on the source table. The copier reads rows into Spirit and writes them to _new through the applier (REPLACE INTO _new VALUES (...)), and the change source is likewise buffered — it applies binlog row images and never runs SELECT FROM original. The checksum's chunk repair works the same way (see pkg/checksum/README.md): it reads the chunk into Spirit and rewrites it through the applier, rather than the REPLACE INTO _new ... SELECT FROM original it used historically, whose SELECT half did take shared row locks on the source. None of these paths hold shared row locks on the source, so there is no contention with production workloads on hot rows. (The applier's writes do take locks, but only on _new, which nothing else touches.) See pkg/change/README.md for the subscription design.

So the locking that does apply is metadata locks. When we describe Spirit as a "non-blocking schema change tool" that is a bit of a white lie: we don't hold an MDL for the entire 10h schema change, as built-in MySQL DDL often does, but we do need a brief exclusive MDL at a few points:

  • Spirit initially attempts INSTANT/INPLACE DDL. If this is compatible, it requires an exclusive metadata lock on the table.
  • Starting a checksum requires an initial exclusive metadata lock to ensure that all data is synchronized between the checksum threads.
  • The cutover operation requires an exclusive metadata lock.

What causes all metadata lock issues? (hint: it's not spirit)

Any open transactions will have shared metadata locks on any of the tables that you are modifying. If you have long transactions that have not yet committed/rolled back, Spirit's exclusive lock will be queued waiting for them to finish. This then looks like a Spirit problem because any shared lock requests that arrive after Spirit's exclusive lock request will then be queued behind Spirit. So the solution is to keep your transactions as short as possible.

The fix for Spirit is that it will force-kill the specific connections that are blocking it from acquiring an exclusive lock. This is always enabled: retrying MDL acquisition over and over while blocked is more dangerous to production systems than targeted killing of the blockers.

Using Spirit migration as a Go package

I will assume that similar to our use-case, you are probably wrapping some sort of automation around Spirit. If this automation is written in Go, I would encourage you to use the Spirit API and not the CLI executable.

The following is a simplification of what we use ourselves:

func (sm *Spirit) Execute(ctx context.Context, m *ExecutableTask) error {
	startTime := time.Now()
	runner, err := migration.NewRunner(&migration.Migration{
		Host:              m.Cluster.Host,
		Username:          m.Cluster.Username,
		Password:          &m.Cluster.Password,
		Database:          m.Cluster.DatabaseName,
		Statement:         m.Statement,
		Threads:           m.Concurrency,
		LockWaitTimeout:   m.LockWaitTimeout,
		InterpolateParams: true,
	})
	if err != nil {
		return errors.Wrap(err, "failed to create spirit migration runner")
	}
	defer runner.Close()
	if m.Metrics != nil {
		runner.SetMetricsSink(m.Metrics)
	}
	sm.Lock()
	sm.progressCallback = func() string {
		return runner.Progress().Summary
	}
	sm.Unlock()
	runner.SetLogger(m.Logger)
	if err = runner.Run(ctx); err != nil {
		return errors.Wrap(err, "failed to run spirit migration")
	}
	m.Logger.Infof("spirit migration completed in %s", time.Since(startTime))
	return nil
}

Documentation

Overview

Package migration contains the logic for running online schema changes.

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(db *sql.DB, config []*cutoverConfig, feed change.Source, dbConfig *dbconn.DBConfig, logger *slog.Logger) (*CutOver, error)

NewCutOver contains the logic to perform the final cut over. It can cutover multiple tables at once based on config. A replication feed which is used to ensure consistency before the cut over.

func (*CutOver) Run

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

type Migration

type Migration struct {
	Host         string  `name:"host" help:"Hostname" optional:""`
	Username     string  `name:"username" help:"User" optional:""`
	Password     *string `name:"password" help:"Password" optional:""`
	Database     string  `name:"database" help:"Database" optional:""`
	ConfFile     string  `name:"conf" help:"MySQL conf file" optional:"" type:"existingfile"`
	Threads      int     `` /* 158-byte string literal not displayed */
	WriteThreads int     `` /* 150-byte string literal not displayed */

	// MaxConnections is the size of the main connection pool, set verbatim and
	// never recomputed (see the MaxOpenConnections assignment in Runner.Run).
	//
	// The connections it spends are the server's max_connections, shared with
	// the production workload, so this is spirit's claim on someone else's
	// budget rather than a description of what spirit could use. Ask for more
	// than the server can spare and the copy does not slow down, it dies on
	// `Error 1040: Too many connections`.
	//
	// The thread ceilings bound how far the copier scales its own workers and
	// can exceed this. When they do, the workers contend for connections instead
	// of each being guaranteed one, which costs throughput and nothing else.
	//
	// Zero means "use the default" (normalizeOptions fills it in), matching
	// Threads and WriteThreads. Negative is rejected by Validate, as is any
	// value too small for the migration to finish on; see minPoolSize.
	MaxConnections int `` /* 203-byte string literal not displayed */

	// EnableExperimentalAutoscaling turns on dynamic thread scaling driven by
	// throttler feedback. When it engages (an Aurora target with at least
	// autoscale.MinVCPUs) it takes over both thread counts: Threads and
	// WriteThreads are ignored, and each pool's starting size and ceiling are
	// derived from the instance instead — see the override in
	// setupCopierCheckerAndReplClient and autoscale.ReadBounds. See issue #831.
	EnableExperimentalAutoscaling bool `` /* 257-byte string literal not displayed */
	// TargetChunkSize is the in-memory byte budget the copier sizes each copy
	// chunk against (the memory signal; see table.DefaultTargetChunkBytes and
	// pkg/table/README.md). A zero value means "use the default"
	// (normalizeOptions fills it in), so callers that construct Migration
	// programmatically don't have to set it.
	// The Kong default below must stay equal to table.DefaultTargetChunkBytes.
	TargetChunkSize      uint64        `name:"target-chunk-size" help:"In-memory byte budget per copy chunk (in bytes)" optional:"" default:"16777216"`
	ReplicaDSN           string        `` /* 157-byte string literal not displayed */
	ReplicaMaxLag        time.Duration `` /* 299-byte string literal not displayed */
	LockWaitTimeout      time.Duration `name:"lock-wait-timeout" help:"The DDL lock_wait_timeout required for checksum and cutover" optional:"" default:"30s"`
	SkipDropAfterCutover bool          `name:"skip-drop-after-cutover" help:"Keep old table after completing cutover" optional:"" default:"false"`
	DeferCutOver         bool          `name:"defer-cutover" help:"Defer cutover (and checksum) until sentinel table is dropped" optional:"" default:"false"`
	Statement            string        `name:"statement" help:"The SQL statement to run" required:""`

	// TLS Configuration
	TLSMode            string `` /* 142-byte string literal not displayed */
	TLSCertificatePath string `name:"tls-ca" help:"Path to custom TLS CA certificate file" optional:""`

	CheckpointMaxAge     time.Duration `name:"checkpoint-max-age" help:"Maximum age of a checkpoint before refusing to resume from it" optional:"" default:"168h"`
	ChecksumYieldTimeout time.Duration `` /* 203-byte string literal not displayed */

	// MaxCommitLatency throttles when observed commit latency exceeds this
	// threshold. Currently auto-enabled only on Aurora (auto-detected); the
	// default 100ms is intentionally a high upper bound to only cut the most
	// extreme tail latencies. See issue #468.
	MaxCommitLatency time.Duration `` /* 160-byte string literal not displayed */

	// Hidden options for now (supports more obscure cash/sq usecases)
	InterpolateParams bool `name:"interpolate-params" help:"Enable interpolate params for DSN" optional:"" default:"false" hidden:""`
	// Used for tests so we can concurrently execute without issues even though
	// the sentinel name is shared. Basically it will be true here, but false
	// in the tests unless we set it explicitly true.
	RespectSentinel bool `name:"respect-sentinel" help:"Look for sentinel table to exist and block if it does" optional:"" default:"true" hidden:""`
	// contains filtered or unexported fields
}

func (*Migration) Run

func (m *Migration) Run() error

func (*Migration) Validate added in v0.11.0

func (m *Migration) Validate() error

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

The cross-flag check on MaxConnections is the exception, and it is here because it has nowhere else to be: the pool is set to that number verbatim and never recomputed, so a number too small to work is a migration that stalls somewhere in the middle rather than one that fails at startup.

type Runner

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

func NewRunner

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

func (*Runner) Cancel

func (r *Runner) Cancel()

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

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

func (*Runner) SetMetricsSink

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

func (*Runner) Status

func (r *Runner) Status() string

Status returns the periodic report on the whole migration: a header line plus one indented row per subsystem (see status.Block). It deliberately absorbs what used to be separate periodic lines from the change feed (flushes, rotations) and the checkpoint dumper, which each ran on their own interval — see github.com/block/spirit/issues/329.

Directories

Path Synopsis
Package check provides various configuration and health checks that can be run against a sql.DB connection.
Package check provides various configuration and health checks that can be run against a sql.DB connection.

Jump to

Keyboard shortcuts

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