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

status

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: 13 Imported by: 2

README

Status

The status package provides state management, progress reporting, and periodic task monitoring for Spirit migrations. It defines the lifecycle states that a migration passes through and the infrastructure for background checkpointing and status logging.

State Machine

State is an int32 enum representing the current phase of a migration. It uses atomic.LoadInt32/atomic.StoreInt32 for lock-free concurrent access, since the migration runner and watcher goroutines read and write the state simultaneously.

The states are defined in lifecycle order:

Initial → CopyRows → ApplyChangeset → RestoreSecondaryIndexes → AnalyzeTable → Checksum → PostChecksum → WaitingOnSentinelTable → CutOver → ReverseWindow → Close → ErrCleanup

This ordering is deliberate — the code uses ordinal comparisons (e.g., state >= CutOver) to determine when to stop checkpointing and status reporting. WaitingOnSentinelTable sorts after PostChecksum because that is when it runs. The checkpoint gates in migration and move persist checksum resume evidence only in the window [Checksum, WaitingOnSentinelTable): the sentinel wait discards that evidence, so a restart during the wait re-verifies the whole range.

ReverseWindow is entered only by a move run with --reverse-window set. It sorts immediately after CutOver: the forward cutover is done and traffic is on the target, but Spirit keeps the source current in change-only mode so the move can still be rolled back. Because it is >= CutOver, the background status/checkpoint loops have already stopped (the reverse-window driver manages its own checkpoint writes) while orchestration can still observe that a revert is possible.

Tracker

Tracker wraps a State with per-state wall-clock timing, and is what the runners hold in place of a bare State field. Phases with a clear extent run under Do(state, fn), which transitions to state, runs fn, and attributes fn's elapsed time (panic inclusive) to that state:

err := r.status.Do(status.CopyRows, func() error {
    return r.copier.Run(ctx)
})

Set remains the transition primitive for states with no bracketable extent from the setter's perspective (Close, ErrCleanup); it closes the previous state's still-open interval (a gap after a completed Do stays unattributed), preserving the historical "one state ends when the next starts" semantics. In both cases the state stays current after the phase's code completes — Get() and the ordinal comparisons above behave exactly as before.

Because the tracker owns the timing, the runners no longer carry ad-hoc fields like copyDuration or sentinelWaitStartTime: the status block header renders Elapsed() (time in the current state) and final summaries render Duration(state) (total time attributed to a state, accumulating across repeat visits). The two can disagree after a bracket completes: Duration(state) freezes when the bracket closes, while Elapsed() keeps growing until the next transition.

The tracker assumes spirit's linear execution model — one goroutine advances through the phases in order, and the only concurrent transition is a fatal Set(ErrCleanup) racing an open bracket (time accrues to the bracketed state up to the fatal transition; the bracket's own exit becomes a no-op). It is not designed for concurrent or overlapping phases. Begin() marks the start of a run and resets all timing; runners call it once at the top of Run.

Metrics and reusable-run evidence

Every runner passes its existing metrics.Sink to Tracker. Generic sinks receive phase-entry and completed-duration values. A sink that also implements status.WorkflowMetricsSink receives typed, synchronous callbacks for each bracketed Do attempt (started, then finished with succeeded, failed, or cancelled) and one exact uint64 copy aggregate. These are attempt outcomes, not workflow/SLO outcomes: a failed checksum may be retried successfully, and a cancelled pod may later resume. Set-only transitions such as ErrCleanup do not enter the typed attempt stream.

The typed capability deliberately extends metrics.Sink rather than creating another observer mechanism. A runner with the default metrics.NoopSink disables transition delivery entirely and adds no transition allocations. Sink calls happen outside the tracker's timing mutex, their latency is excluded from phase duration, and a panic in a typed callback is recovered so telemetry cannot change migration behavior.

Copy totals count work settled during the current Run invocation and are emitted even when the copy attempt fails or is cancelled. The chunker restores its RowsCopied count from the checkpoint so that progress continues across a resume, while its chunk count starts afresh, and the runner subtracts the restored rows from the aggregate, so a resumed invocation reports only the rows and chunks it settled itself.

Durable mutation and physical ownership are correctness facts, not metrics. migration.Runner.Result and move.Runner.Result return status.WorkflowResult after Run; failures also preserve machine-checkable status.ErrDurableMutation and status.ErrOwnershipAmbiguous markers through errors.Is. Result-bearing forward and reverse cutover callbacks carry the same two independent facts, so a caller can report a confirmed partial write without inventing ownership ambiguity.

Task Interface

The Task interface defines the contract that a migration runner must implement: reporting progress, returning a status string, dumping checkpoints, and cancelling. All three runners (migration.Runner, move.Runner and datasync.Runner) implement this interface.

Background Monitoring

WatchTask launches two background goroutines:

  1. Status logger: Logs task.Status() every 30 seconds until the migration reaches cutover. This provides a regular heartbeat in the logs.
  2. Checkpoint dumper: Calls task.DumpCheckpoint() every 50 seconds until cutover. If a checkpoint write fails (with anything other than ErrWatermarkNotReady or context.Canceled), the task is cancelled immediately. A task that implements Aborter is stopped with Abort(cause), where the cause is the write error marked by FatalAbort, so it can return that error instead of context.Canceled; any other task is stopped with Cancel(). The rationale is that it is better to fail early than to discover after a multi-day migration that progress was never being saved.

The checkpoint dumper also handles a race condition where the state transitions past cutover mid-checkpoint — the checkpoint table may have already been dropped, so this case is handled gracefully rather than treated as an error.

One report, not three lines

The status report is deliberately the only recurring INFO output a run emits. It used to compete with a per-checkpoint line and a per-flush line from the change feed, each on its own interval, which made the log hard to read (#329). Those events now report themselves here instead, and still log their detail at DEBUG.

Task.Status() still returns a string; each runner now builds a Block and returns its rendered form — a header line plus one indented row per subsystem. All three runners build it the same way.

migration status: state=copyRows total-time=2m6s copier-time=2m0s
  copier   30.84%  5048712/16370180  chunk-size=92220  eta=4m39s  throttled=false
  applier queue=128/128  workers=4  wait-p50=1.323s  write-p50=32ms  write-p90=127ms
  binlog  deltas=0  rotations=962 (0 forced)  flushed 0s ago (took 3µs, 0 rows)
  ckpt    20s ago  binlog.000123:41909012
Row Source Contents
copier chunker + copier.Copier Percentage and counts of settled rows against the tables' cardinality estimates — the same figures Progress().Copy reports, not the copier's own keyspace measure — then chunk-size= (rows in the most recently claimed chunk — the dynamic chunker's current sizing decision, previously visible only inside the checkpoint line's watermark JSON), the ETA, and whether a throttler is pausing the copy.
applier applier.Stats queue= is occupancy, not progress: at capacity is the healthy steady state for a copy, and a queue that empties means the pipeline has gone read-limited. See pkg/applier/README.md for which fields render and which appear only when they carry a diagnosis.
binlog runner + change.FeedStats deltas= is the runner's unapplied-change count; the rest is the feed. rotations= replaces go-mysql's per-rotation rotate to next binlog line, which spirit now demotes to DEBUG, and (n forced) is the subset spirit caused itself by issuing FLUSH BINARY LOGS from BlockWait when the buffered position stalled.
ckpt status.LastCheckpoint How long ago the checkpoint was persisted and the change-feed coordinate it saved — where a resumed run would restart reading. The pair is what answers whether that point is still within the source's binlog retention. never before the first checkpoint; a multi-source move renders key=position per source.
checksum checksum.Checker Replaces the copier row during the checksum phase, with its own chunk-size= (the checksum sizes chunks dynamically too) plus threads= / throttled=, for the same reason the copier row reports throttling.
sentinel runner Only in waitingOnSentinelTable: how long it has been waiting and the limit.

The flush figures read as a phrase — flushed 30s ago (took 9µs, 0 rows) — because two of them are durations of different kinds. Side by side as key=0s pairs, "flushed just now" and "the flush was instant" are indistinguishable.

Two things the block gives up, deliberately: the whole report is one log record with newlines in it, which the default slog handler (what the CLI uses) prints as written but a quoting handler (TextHandler, JSON) will escape; and the applier-/binlog- field prefixes are gone, since the row label carries them.

conns-in-use was dropped: it reported sql.DB pool occupancy, which tracks the configured thread count and says nothing an operator acts on.

Progress Reporting

Progress is a struct (not just a string) containing the current state and a summary. It is designed as a struct specifically to allow future expansion for GUI wrappers and external tooling.

Alongside the summary it carries structured fields for the things a wrapper would otherwise have to parse out of prose or scrape from the logs: ETA, the runner-wide Copy counts, per-table Tables progress, Checksum progress, and — from #844 — Resume and Throttle.

Copy

Copy is the runner-wide row copy as a CopyProgress{RowsCopied, RowsTotal}. It is the sum of Tables, so the two always reconcile: both count settled rows against the tables' cardinality estimates, and neither is the optimistic chunker's keyspace position, which is what the copier paces on and what the ETA is derived from. It is populated as soon as the copy chunker exists and keeps its final reading through the later phases, so "how much did this run copy" stays answerable after the copy ends.

Two caveats carry over from the per-table counts. RowsCopied counts rows the copy settled, so a row the binlog applier wrote before the copy reached it is not counted (the copy inserts with INSERT IGNORE, which reports it as unaffected), and on a busy table the copy finishes short of RowsTotal; a resume restores the count from the checkpoint and continues it, though a move deletes and re-copies the rows at or above the resume position, so the rows among them settled before the checkpoint are counted again. On an auto_increment key, the ETA uses keyspace distance unless the key space is more than five times wider than the row estimate, when it switches to settled rows. Until that switch, gaps can make Copy and the ETA's pacing measure disagree; Summary carries both halves.

Resume

Resume is true when the run resumed from a checkpoint left by an earlier run. A resumed run walks the whole state machine again (CopyRows, Checksum, ...) even when those phases are near-instant, so a wrapper watching only CurrentState sees what looks like a migration starting over — confusing when the previous pod died while waiting on the sentinel table.

CurrentState is deliberately not overloaded with a synthetic "recovering" value: callers parse it for phase display, so a new state would be a breaking change. Pair Resume with the progress fields instead — a resumed run whose copy and checksum progress are both near-complete is one to render as "recovering" rather than "starting".

Throttle

Throttle reports whether the current phase is paused by a throttler, and why:

Field Meaning
Throttled The phase is paused right now. Branch on this. False in phases that pace against nothing — see below.
Reason Display string naming the signal and comparison, e.g. commit-latency 128ms >= 100ms. Multiple concurrent signals are joined with "; ". May be "" even while throttled (see below).
Utilization Load relative to the throttle point: 1.0 = at the point throttling begins, >1.0 = over, lower = further below. 0 does not mean idle — see below.

Two traps for consumers:

  • Reason is for display, not for branching. It is empty when the configured throttler cannot explain itself (see ReasonedThrottler), and it is sampled independently of Throttled rather than atomically with it, so on a fast-changing signal the two can briefly disagree.
  • Utilization is also 0 when no continuous load signal exists — notably when throttling is replica-lag-only, which is a budget rather than a load gauge. So a copy paused on replica lag reports Throttled with Utilization 0: treat 0 as "unknown" and hide the gauge, rather than drawing an idle server.

Which signals count depends on the phase, and the runner reports only the ones that phase actually honours — so Throttled means the same thing everywhere:

Phase Reported
CopyRows The whole composite. The copier writes, so it honours every signal.
Checksum Load signals only, matching checksum's loadOnlyThrottler — a read-only snapshot pass cannot cause replica lag, so pausing it on lag would only hold the snapshot open for longer.
WaitingOnSentinelTable Load signals only while a continuous checksum pass is active; zero value between passes.
everything else Zero value. Changeset drains and cutover are not paced.

That last row matters for a wrapper polling after a run ends: a loaded server — or a replica-lag throttler that fails closed once its poll loop has stopped — must not make a finished migration, a cutover, or an interval between continuous checksum passes look paused.

Migration and move follow the same phase rules. A move reports the combined load signal of its Aurora targets, whether or not autoscaling is enabled: the whole signal in CopyRows, load signals only in Checksum and — while its continuous checksum runs — in WaitingOnSentinelTable, and the zero value elsewhere. A sync reports its Aurora target's load signal in CopyRows and ApplyChangeset. A target that is not Aurora, or whose Aurora probe fails, contributes no signal.

See Also

  • pkg/migration - Migration runner that implements the Task interface
  • pkg/move - Move runner that implements the Task interface
Structured runner progress

Migration, move and datasync use TablesFromChunker to return table progress in a stable order. Multi-source identifiers retain the source qualifier so equally named tables remain distinct. Copy ETA is populated during CopyRows and cleared afterwards; the Copy counts persist past it. Migration and move also expose the finite initial checksum counts; datasync’s continuous verifier has no corresponding finite phase.

All three use multiline status blocks. Datasync includes copier-time while copying and state-time while restoring indexes, alongside its existing binlog and checkpoint rows. Sentinel progress polling returns a summary without emitting logs; periodic logging remains the responsibility of WatchTask.

Per-table RowsCopied is the actual settled row count from chunk feedback; RowsTotal is the estimated table cardinality, which may change and is not an upper bound. These are not the optimistic chunker’s keyspace-distance counters. The chunkers carry the count forward in their watermark, so a resumed copy includes the work done before the checkpoint; a watermark written before a chunker persisted its count resumes at zero. Use IsComplete for completion rather than requiring equality of the two counts.

Summary remains runner-specific: move and migration leave it empty in some phases (such as index restoration), while datasync falls back to the state name. Structured fields and multiline status formatting are aligned; summary text is not a shared API format.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrMismatchedAlter         = errors.New("alter statement in checkpoint table does not match the alter statement specified here")
	ErrBinlogNotFound          = errors.New("checkpoint binlog file not found on server")
	ErrCheckpointTooOld        = errors.New("checkpoint is too old to safely resume")
	ErrCheckpointCollision     = errors.New("checkpoint belongs to a different table (truncation collision)")
	ErrCouldNotWriteCheckpoint = errors.New("could not write checkpoint")
	ErrWatermarkNotReady       = errors.New("watermark not ready")
	// ErrOwnershipAmbiguous marks a failure after which spirit cannot tell
	// which side owns the table(s): a DDL or RENAME that the server may have
	// committed before the client lost its acknowledgement, or a caller-owned
	// traffic switch whose outcome is unknown. Spirit never retries past one
	// of these, because a retry that guesses wrong can move ownership a
	// second time. Callers should test for it with errors.Is and escalate to
	// a human rather than re-running.
	ErrOwnershipAmbiguous = errors.New("ownership ambiguous; verify table ownership manually before retrying")
	// ErrDurableMutation marks an error returned after the current invocation
	// authoritatively completed a durable write. It is orthogonal to
	// ErrOwnershipAmbiguous: callers may know a write happened without knowing
	// which side owns traffic, or may know ownership despite later cleanup
	// failing.
	ErrDurableMutation = errors.New("durable mutation completed before failure")
)
View Source
var (
	CheckpointDumpInterval = 50 * time.Second
	StatusInterval         = 30 * time.Second
)
View Source
var ErrFatalAbort = errors.New("fatal abort")

ErrFatalAbort marks the cause a runner cancels its own context with when the change feed (or another out-of-band check) reports a fatal condition. Wrap a cause with FatalAbort before cancelling; AbortCause substitutes only causes that carry it.

Functions

func AbortCause added in v0.18.0

func AbortCause(ctx context.Context, err error) error

AbortCause returns the cause ctx was cancelled with in place of err, when err is the context.Canceled that cancellation produced and the cause carries ErrFatalAbort. Runners cancel their context with such a cause (see FatalAbort) when the change feed reports a fatal condition; without this substitution every phase would return context.Canceled, and the abort would be reported, and recorded as a phase outcome, as if an operator had cancelled the run.

err is returned unchanged when it is nil or not a cancellation, when ctx is not cancelled, when the cause does not carry ErrFatalAbort (an operator cancellation: the runner's Cancel, or the caller cancelling its own context, with or without a cause of its own), and when err carries ErrDurableMutation or ErrOwnershipAmbiguous, evidence the caller must still be able to inspect.

func FatalAbort added in v0.18.0

func FatalAbort(err error) error

FatalAbort marks err with ErrFatalAbort. The result has err's message unchanged, and errors.Is and errors.As still match err and everything it wraps.

func WatchTask

func WatchTask(ctx context.Context, task Task, logger *slog.Logger) (wait func())

WatchTask periodically does the status reporting for a task. This includes writing to the logger the current state, and dumping checkpoints.

It returns a wait function the caller can invoke during shutdown to block until the spawned goroutines have exited. This avoids races where a still-running checkpoint goroutine writes a fresh row after the caller has closed/torn down the surrounding state — a pattern that has produced flakes in tests that mutate the checkpoint table after Run() returns (see #773).

Types

type Aborter added in v0.18.0

type Aborter interface {
	Abort(cause error)
}

Aborter is implemented by a Task that can stop itself with a cause. When a checkpoint cannot be written, the checkpoint dumper aborts such a task with the write error marked by FatalAbort, so the task can return that error (see AbortCause) instead of the context.Canceled a plain Cancel produces. A Task that does not implement it is stopped with Cancel.

type Block added in v0.17.0

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

Block is the multi-line report a runner returns from Status(): a header line followed by one indented row per subsystem (copier, applier, change feed, checkpoint).

The single-line form it replaces had grown to twenty-odd space-separated fields, which is dense but not readable: a value that changes width shifts every field after it, so the eye cannot follow one number down a scrollback. Grouping by subsystem and padding the labels to a common width is what makes a spike in one field visible across ticks. See github.com/block/spirit/issues/329.

Note that the whole block is one log record with newlines in it. Under the default slog handler (what the CLI uses) that prints as written. A handler that quotes the message — slog's TextHandler, or a JSON handler — will render the newlines escaped instead.

The zero value is not useful; construct with NewBlock.

func NewBlock added in v0.17.0

func NewBlock(format string, args ...any) *Block

NewBlock starts a block with the given header line.

func (*Block) Row added in v0.17.0

func (b *Block) Row(label, format string, args ...any) *Block

Row appends a labelled row of fields. A row whose text is empty is dropped, so a caller can pass a helper that reports nothing — a nil applier, a change feed that does not publish stats — without having to test for it first. Trailing spaces are trimmed for the same reason: they are what is left when such a helper contributes nothing to the end of a row.

func (*Block) String added in v0.17.0

func (b *Block) String() string

String renders the block, with the labels padded to a common width so every row's fields start in the same column.

type ChecksumProgress added in v0.16.0

type ChecksumProgress struct {
	RowsChecked uint64 // rows verified so far
	RowsTotal   uint64 // total rows to verify
}

ChecksumProgress tracks progress of the checksum phase, where Spirit verifies the copied data against the source before cutover. RowsChecked and RowsTotal are 0 outside the checksum phase.

func (ChecksumProgress) Fraction added in v0.17.0

func (c ChecksumProgress) Fraction() float64

Fraction returns progress in 0..1, for callers that need the ratio rather than the rendered percentage. 0 before the row estimate is known.

func (ChecksumProgress) String added in v0.16.0

func (c ChecksumProgress) String() string

String renders the checksum progress for the human-readable summary line, e.g. "71436/221193 32.30%".

type CopyProgress added in v0.17.0

type CopyProgress struct {
	RowsCopied uint64 // rows copied so far
	RowsTotal  uint64 // estimated total rows to copy
}

CopyProgress tracks progress of the row copy. It is the numeric form of what the copier used to report only as a preformatted string, so the status block can lay the percentage and the counts out as separate fields.

func CopyFromTables added in v0.18.0

func CopyFromTables(tables []TableProgress) CopyProgress

CopyFromTables sums a per-table snapshot into the runner-wide copy progress. Deriving it from the same snapshot is what lets Progress.Copy and Progress.Tables reconcile: both count settled rows against the tables' cardinality estimates, whichever chunker is doing the copying.

func (CopyProgress) Fraction added in v0.17.0

func (c CopyProgress) Fraction() float64

Fraction returns progress in 0..1, for callers that need the ratio rather than the rendered percentage. 0 before the row estimate is known.

func (CopyProgress) String added in v0.17.0

func (c CopyProgress) String() string

String renders the copy progress, e.g. "1031251/16370180 6.30%".

type ETA added in v0.16.0

type ETA struct {
	State    ETAState
	Duration time.Duration
}

ETA is the structured form of the ETA embedded in Summary. State reports whether Duration is available yet — e.g. ETAMeasuring during the initial window before a copy rate is known — so callers can show "calculating" rather than a misleading 0. Duration is the estimated remaining row-copy time, valid only when State is ETAReady and 0 otherwise.

func EstimateETA added in v0.18.0

func EstimateETA(copiedRows, totalRows uint64, pct float64, rowsPerSecond uint64, startTime time.Time) ETA

EstimateETA returns the estimated remaining copy time and the state of that estimate. The duration is meaningful only when the state is ETAReady. No estimate is available before a copy rate has been measured (the first etaInitialWaitTime, or while no rows have been timed; ETAMeasuring) or once the copy is essentially complete (pct > 99.99; ETADue) — the callers present each case (GetETA renders "TBD"/"DUE", GetETAState returns the state and 0 seconds).

func (ETA) String added in v0.18.0

func (e ETA) String() string

String renders the ETA the way the copy summary shows it: "TBD" while the rate is still being measured, "DUE" once the copy has reached its estimated end, and otherwise the remaining duration.

type ETAState added in v0.16.0

type ETAState string

Progress is returned as a struct because we may add more to it later. It is designed for wrappers (like a GUI) to be able to summarize the current status without parsing log output. ETAState describes the availability of the row-copy ETA estimate, so callers can distinguish "still measuring" from a real estimate without parsing the Summary string. It mirrors the cases GetETA renders as text.

const (
	// ETANone means there is no copy ETA because the migration is not in the
	// row-copy phase. Duration is 0.
	ETANone ETAState = ""
	// ETAMeasuring means a copy is in progress but no copy rate has been measured
	// yet, so no estimate is available (Summary shows "ETA TBD"). Duration is 0.
	ETAMeasuring ETAState = "measuring"
	// ETAReady means Duration holds a current remaining-time estimate.
	ETAReady ETAState = "ready"
	// ETADue means the copy is essentially complete (Summary shows "ETA DUE").
	// Duration is 0.
	ETADue ETAState = "due"
)

type LastCheckpoint added in v0.17.0

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

LastCheckpoint records what the last successful checkpoint write saved, so a runner can report it as the ckpt row of its periodic status block ("ckpt 12s ago binlog.000123:4567" — see Row) instead of the checkpoint dumper logging a line of its own every time it runs. See github.com/block/spirit/issues/329.

The age and the position belong together because together they answer the only question worth asking about a checkpoint mid-run: if this process died now, where would the next one resume, and is that point still available on the source? A position the source has since purged is not resumable, and the age is what tells you how fast the run is drifting toward that.

The zero value is ready to use and reports "never" / "none". Safe for concurrent use: the recorder and the status goroutine are always different goroutines. The timestamp and the position are stored separately, so a reader can in principle pair an age from one write with the position from the previous one — harmless at status-line cadence, where both are approximate by construction.

Contains sync/atomic values, so it must not be copied after first use.

func (*LastCheckpoint) Age added in v0.17.0

func (c *LastCheckpoint) Age() string

Age renders how long ago the last checkpoint was written, rounded to the second, or "never" if none has been.

func (*LastCheckpoint) At added in v0.17.0

func (c *LastCheckpoint) At() time.Time

At returns when the last checkpoint was written, or the zero time if none has been.

func (*LastCheckpoint) Position added in v0.17.0

func (c *LastCheckpoint) Position() string

Position returns the resume coordinate the last checkpoint saved, or "none" if no checkpoint has been written yet (or the source reported no position). Never returns the empty string: the status block needs something to render.

func (*LastCheckpoint) Record added in v0.17.0

func (c *LastCheckpoint) Record(position string)

Record marks a checkpoint as having just been persisted at position, which is the opaque resume coordinate the change feed reported: a binlog file:offset, a GTID set, or whatever an alternative source encodes.

func (*LastCheckpoint) Row added in v0.17.0

func (c *LastCheckpoint) Row() string

Row renders the checkpoint row of a status block: how long ago the last checkpoint was written and the position it saved, or "never" before the first one.

type Progress

type Progress struct {
	CurrentState State  // current state, i.e. CopyRows
	Summary      string // text based representation, i.e. "12.5% copyRows ETA 1h 30m"

	// Resume is true when this run resumed from a checkpoint left by an earlier
	// run, rather than starting the copy from scratch.
	//
	// It exists because a resumed run walks the whole state machine again
	// (CopyRows, Checksum, ...) even when those phases are near-instant, so a
	// wrapper watching CurrentState sees what looks like a migration starting
	// over. CurrentState is deliberately left alone — callers parse it for phase
	// display — and this reports the fact alongside it: pair it with
	// Tables/Checksum progress to decide whether to render the run as
	// "recovering" rather than "starting".
	Resume bool

	// Throttle reports whether the current phase is paused by a throttler, and
	// why. Which signals count depends on the phase: the copy honours all of
	// them, while a checksum only honours load signals (a read-only snapshot
	// pass cannot cause replica lag, so pausing it on lag would only hold the
	// snapshot open for longer).
	Throttle ThrottleStatus

	// ETA is the structured remaining row-copy estimate and its availability.
	ETA ETA

	// Copy is the runner-wide row copy: the sum of Tables, so the two reconcile
	// by construction. Both count settled rows against the tables' cardinality
	// estimates, never the chunker's keyspace position, which is what the
	// copier itself paces on for an auto_increment key and what the ETA is
	// derived from. Copy is zero until the copy chunker exists and keeps its
	// final reading through the later phases, so a caller can read how much
	// the run copied at any point. RowsTotal is an estimate, so RowsCopied can
	// exceed it.
	//
	// Two consequences of that split follow. RowsCopied counts rows the copy
	// settled, so a row the binlog applier wrote before the copy reached it
	// is not counted (the copy inserts with INSERT IGNORE, which reports it
	// as unaffected), and on a busy table the copy finishes short of
	// RowsTotal. A resume restores the count from the checkpoint and
	// continues it; a move deletes and re-copies the rows at or above the
	// resume position, so the rows among them settled before the checkpoint
	// are counted again. And the ETA, including its DUE state, is paced on the
	// keyspace for an auto_increment key, so over a sparse key range the copy
	// can read close to complete here while the ETA is still counting down,
	// or the reverse. Summary carries both halves.
	Copy CopyProgress

	// Checksum is the structured progress of the post-copy checksum phase,
	// populated while CurrentState is Checksum and zero otherwise. It is the
	// structured form of the checksum progress embedded in Summary.
	Checksum ChecksumProgress

	// Tables contains per-table progress for multi-table migrations.
	// For single-table migrations, this will have one entry.
	Tables []TableProgress
}

type State

type State int32
const (
	Initial State = iota
	CopyRows
	ApplyChangeset // first mass apply
	RestoreSecondaryIndexes
	AnalyzeTable
	Checksum
	PostChecksum // second mass apply
	// WaitingOnSentinelTable comes after PostChecksum because that is when it
	// runs. The checkpoint gates persist checksum resume evidence only in
	// [Checksum, WaitingOnSentinelTable): the sentinel wait discards it, so a
	// restart during the wait re-verifies the whole range. During this state
	// Spirit also runs the "continuous checksum" loop described in
	// docs/migrate.md.
	WaitingOnSentinelTable
	CutOver
	// ReverseWindow is the post-cutover reverse window, entered only when
	// --reverse-window > 0: traffic is on the target and spirit keeps the source
	// current in change-only mode while watching for a revert request. It sorts
	// after CutOver (so `state >= CutOver` stays true: fatalError is a no-op and
	// the status/checkpoint loops stop) and lets orchestration surface that a
	// revert is still possible.
	ReverseWindow
	Close
	ErrCleanup
)

func (State) String

func (s State) String() string

type TableProgress added in v0.11.0

type TableProgress struct {
	TableName  string // name of the table being migrated
	RowsCopied uint64 // actual rows settled, carried forward across a resume
	RowsTotal  uint64 // estimated table cardinality, not an upper bound
	IsComplete bool   // true if this table's copy is complete
}

TableProgress tracks progress for a single table in the migration.

func TablesFromChunker added in v0.17.0

func TablesFromChunker(chunker table.Chunker) []TableProgress

TablesFromChunker returns a stable, structured snapshot for runner progress. Multi-source chunkers preserve their source-qualified table identifiers.

type Task

type Task interface {
	Progress() Progress
	Status() string // prints to logger, to return value
	DumpCheckpoint(ctx context.Context) error
	Cancel() // a callback to be able to cancel the task.
}

type ThrottleStatus added in v0.17.0

type ThrottleStatus struct {
	// Throttled is true while a throttler is telling the current phase to
	// pause. This is the field to branch on.
	//
	// It is false in phases that do not pace themselves against a throttler at
	// all — which is every phase except the row copy and the checksum. A loaded
	// server is not reported as pausing a cutover or a sentinel wait, because
	// nothing there is reading that signal.
	Throttled bool

	// Reason names the signal and the comparison that tripped it, in the form
	// "<signal> <observed> <op> <threshold>" — e.g. "commit-latency 128ms >= 100ms"
	// or "redo-aware 24 > 17". When several signals throttle at once they are
	// joined with "; ", because clearing only one of them will not resume the
	// copy.
	//
	// It is intended for display, not for branching: it is "" when Throttled is
	// false, and may also be "" when the configured throttler cannot explain
	// itself (see throttler.ReasonedThrottler).
	//
	// It is also sampled independently of Throttled rather than atomically with
	// it, so on a signal that is changing underneath the poll the two can
	// disagree: a throttler that clears in between yields Throttled with an
	// empty Reason, and a reason can quote a comparison that has just stopped
	// holding. Both are display-level staleness on a value that is a snapshot
	// anyway — not a bug to report.
	Reason string

	// Utilization is load relative to the point at which throttling begins:
	// 1.0 is exactly at that point, >1.0 is over it, and lower values are
	// further below it. It lets a wrapper show "running at 40% of the load
	// limit" rather than only a long ETA.
	//
	// 0 is ambiguous and must not be rendered as idle. It is also what this
	// field reports when no continuous load signal exists at all — notably when
	// throttling is replica-lag-only, which is a budget rather than a load gauge
	// (see throttler.GradualThrottler). A copy paused on replica lag therefore
	// reports Throttled with Utilization 0, so a wrapper drawing a load gauge
	// should treat 0 as "unknown" and hide it rather than show an idle server.
	Utilization float64
}

ThrottleStatus reports whether the current phase is paused by a throttler, and why. Before this, throttling was only visible in the logs, so a wrapper polling status saw a migration that had gone quiet with no way to say why (issue #844).

type Tracker added in v0.17.0

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

func (*Tracker) Begin added in v0.17.0

func (t *Tracker) Begin()

Begin marks the start of a run: it resets all timing (start time, per-state durations) and enters Initial, so setup work before the first phase is attributed to Initial and TotalElapsed measures from here. Runners call it at the top of Run, where they previously recorded a startTime field. Calling Begin again starts a fresh run rather than extending the previous one.

func (*Tracker) Do added in v0.17.0

func (t *Tracker) Do(state State, fn func() error) (err error)

Do runs fn as the given state: it transitions to state, runs fn, and attributes fn's wall-clock time (panic and runtime.Goexit inclusive) to state. The state remains current after Do returns — as with Set, the next state begins only when it is entered.

The typed sink outcome describes this one bounded attempt, not the eventual workflow result. A non-cancellation error or abnormal unwind is failed; cancellation/deadline errors are cancelled; a nil error is succeeded.

func (*Tracker) DoContext added in v0.18.0

func (t *Tracker) DoContext(ctx context.Context, state State, fn func() error) error

DoContext is Do for a phase that runs under ctx: when ctx was cancelled with a fatal-abort cause, the context.Canceled error fn returns is replaced by that cause (see AbortCause), so the phase is recorded as failed rather than cancelled and the caller receives the cause.

func (*Tracker) Duration added in v0.17.0

func (t *Tracker) Duration(state State) time.Duration

Duration returns the total time attributed to state so far, including the still-running interval when state is current. States visited more than once accumulate. Note that once a bracket completes its interval is closed: Duration(state) freezes while Elapsed keeps growing until the next transition — they answer different questions.

func (*Tracker) Elapsed added in v0.17.0

func (t *Tracker) Elapsed() time.Duration

Elapsed returns how long the current state has been current. It reports 0 before the first transition. This is the value to render on the status block's header ("copier-time", "sentinel-wait-time", ...).

func (*Tracker) Get added in v0.17.0

func (t *Tracker) Get() State

Get returns the current state.

func (*Tracker) RecordCopyCompleted added in v0.17.0

func (t *Tracker) RecordCopyCompleted(rows, chunks uint64)

RecordCopyCompleted reports the copy aggregate settled during this Runner.Run invocation. It is emitted even when the copy phase later returns an error; rows and chunks count completed work, not phase success.

func (*Tracker) Set added in v0.17.0

func (t *Tracker) Set(state State)

Set transitions to state without a bracket: it closes the previous state's still-open interval (if a completed Do already closed it, the gap since is left unattributed) and state begins accruing now. Prefer Do wherever the phase has a clear extent.

func (*Tracker) SetMetricsSink added in v0.17.0

func (t *Tracker) SetMetricsSink(sink metrics.Sink, logger *slog.Logger)

SetMetricsSink installs the sink that phase transitions are reported to, and the logger used when a send fails. A nil or metrics.NoopSink disables reporting without adding work to state transitions.

Reporting is synchronous, so a slow sink slows transitions — bounded by metrics.SinkTimeout per send, and there are only a dozen transitions in a run. It is deliberately the same trade-off the copier already makes for its per-chunk metrics, which send far more often.

func (*Tracker) StartTime added in v0.17.0

func (t *Tracker) StartTime() time.Time

StartTime returns when the run began: Begin, or the first transition if Begin was never called. It is the zero time before either, and stable for the life of a run — migration derives timestamped _old table names from it.

func (*Tracker) TotalElapsed added in v0.17.0

func (t *Tracker) TotalElapsed() time.Duration

TotalElapsed returns how long the tracker has been running: the time since Begin (or, if Begin was never called, since the first transition). It reports 0 before either. This is the value to render as "total-time".

type WorkflowMetricsSink added in v0.17.0

type WorkflowMetricsSink interface {
	metrics.Sink
	RecordWorkflowPhaseStarted(State)
	RecordWorkflowPhaseFinished(State, WorkflowPhaseOutcome)
	RecordWorkflowCopyCompleted(rows, chunks uint64)
}

WorkflowMetricsSink is the optional typed workflow capability of the runner's existing metrics.Sink. Generic metrics consumers need only implement metrics.Sink; callers that need exact phase-attempt and aggregate semantics implement these methods on the same sink rather than installing a second observer.

type WorkflowPhaseOutcome added in v0.17.0

type WorkflowPhaseOutcome uint8

Tracker owns the current State plus per-state wall-clock timing. Runners embed it in place of a bare State field so that state transitions and phase timing cannot drift apart, and so per-phase durations no longer need ad-hoc fields on the runner (copyDuration, sentinelWaitStartTime, ...).

The zero value is ready for use.

Phases with a clear extent run under Do, which times exactly the function it brackets. Set remains the primitive for transitions whose "phase" has no meaningful end from the setter's perspective (Close, ErrCleanup); it closes out the previous state's running interval, matching the historical "one state ends when the next starts" semantics.

Tracker assumes spirit's linear execution model: a single goroutine advances through the phases in order, and the only concurrent transition is a fatal Set (ErrCleanup) racing an open bracket. In that case time accrues to the bracketed state up to the fatal transition and the bracket's own exit becomes a no-op. It is not designed for concurrent or overlapping phases. WorkflowPhaseOutcome is how one bounded Tracker.Do attempt returned. It is deliberately not a workflow/SLO result: a retry or a resumed process may later run the same phase again.

const (
	WorkflowPhaseOutcomeInvalid WorkflowPhaseOutcome = iota
	WorkflowPhaseOutcomeSucceeded
	WorkflowPhaseOutcomeFailed
	WorkflowPhaseOutcomeCancelled
)

type WorkflowResult added in v0.17.0

type WorkflowResult struct {
	DurableMutation   bool
	TerminalOwnership WorkflowTerminalOwnership
}

WorkflowResult is the reusable evidence a runner retained when its Run invocation returned. It is separate from phase metrics: durable mutation and physical ownership are correctness facts, not time-series dimensions.

func (WorkflowResult) OwnershipAmbiguous added in v0.17.0

func (r WorkflowResult) OwnershipAmbiguous() bool

OwnershipAmbiguous reports that the runner could not prove which side owns the table or traffic.

func (WorkflowResult) ReverseFinalized added in v0.17.0

func (r WorkflowResult) ReverseFinalized() bool

ReverseFinalized reports that reverse cutover definitively restored the source as owner.

type WorkflowTerminalOwnership added in v0.17.0

type WorkflowTerminalOwnership uint8

WorkflowTerminalOwnership is authoritative terminal physical ownership evidence from a runner. Zero means the runner has no special terminal ownership fact to report.

const (
	WorkflowTerminalOwnershipNone WorkflowTerminalOwnership = iota
	WorkflowTerminalOwnershipReverseFinalized
	WorkflowTerminalOwnershipAmbiguous
)

Jump to

Keyboard shortcuts

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