status

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: 11 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:

InitialCopyRowsWaitingOnSentinelTableApplyChangesetRestoreSecondaryIndexesAnalyzeTableChecksumPostChecksumCutOverReverseWindowCloseErrCleanup

This ordering is deliberate — the code uses ordinal comparisons (e.g., state >= CutOver) to determine when to stop checkpointing and status reporting.

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 optimistic chunker does not persist its actual-row counter, so a resumed invocation reports only rows and chunks settled after resume.

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. Both the migration.Runner and move.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. 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 copier.Copier Percentage and counts from CopyProgress(), 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, per-table Tables progress, Checksum progress, and — from #844Resume and Throttle.

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.
everything else Zero value. Nothing there consults a throttler: the sentinel wait runs the continuous checker (which takes none), and the changeset applies 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 a sentinel wait look paused.

A move reports no throttling at all — it copies through a Noop throttler for now.

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. 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. Resumed copies may exclude work from before the checkpoint when the watermark does not retain row counts; 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
)

Functions

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 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 (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.

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

	// 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 the initial checksum so that
	// `state >= Checksum` is true while the sentinel-wait blocks the cutover.
	// 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 >= Checksum` stays true) 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; may exclude earlier work on 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) 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