Documentation
¶
Overview ¶
Package change contains binary log subscription functionality.
Index ¶
- Constants
- Variables
- func GTIDEnabled(ctx context.Context, db *sql.DB) (bool, error)
- func IsGTIDPosition(pos string) bool
- func NewServerID() uint32
- func StatusRow(srcs ...Source) string
- type BufferedSubscriptionConfig
- type ClientConfig
- type DrainBudgetReporter
- type FatalReason
- type FeedStats
- type FlushShape
- type FlushShapeReporter
- type ParkReporter
- type Source
- func NewAutoClient(ctx context.Context, db *sql.DB, host string, username, password string, ...) (Source, error)
- func NewBinlogClient(db *sql.DB, host string, username, password string, appl applier.Applier, ...) Source
- func NewGTIDClient(db *sql.DB, host string, username, password string, appl applier.Applier, ...) Source
- type StatsReporter
- type Subscription
Constants ¶
const ( // DefaultBatchSize is the maximum number of rows in each batched // REPLACE/DELETE statement that the binlog applier emits against the // _new table. Larger is better, but we need to keep the run-time of // the statement well below dbconn.maximumLockTime so that it doesn't // prevent copy-row tasks from failing. On Aurora tables with // out-of-cache workloads that copy ~300 rows per second this is close // to the safe ceiling. // // Batches are additionally capped by their estimated rendered byte // size (applier.MaxStatementSizeBytes, shared with the copy path's // chunklet splitting) so that wide rows can't accumulate into a // statement larger than max_allowed_packet. Whichever cap is reached // first cuts the batch; see flushMapLocked / flushQueueLocked in // subscription_buffered.go. // // Was previously an initial value for an adaptive sizer (feedback() // driven by p90 apply time). That mechanism was meaningful when the // applier issued `REPLACE INTO _new ... SELECT FROM source` and S-locked // rows on the live table, but after #853 the applier emits inline // VALUES against _new only — no source-side locks — and the batches // are strictly serial inside flushBatch. There's nothing left to // throttle, so the batch size is just a constant. See issue #869. DefaultBatchSize = 1000 // DefaultFlushInterval is the time that the client will flush all binlog changes to disk. // Longer values require more memory, but permit more merging. // I expect we will change this to 1hr-24hr in the future. DefaultFlushInterval = 30 * time.Second // DefaultFlushConcurrency is the number of applier batches a // map-mode flush keeps in flight concurrently. The binlog apply // path is synchronous REPLACE/DELETE statements — it does not use // the copy path's write worker pool — so each stream tops out at // DefaultBatchSize rows per statement round trip. On large tables // where secondary index maintenance dominates, that is only a few // hundred rows/s, which a busy source's distinct-key write rate can // permanently outrun: the buffer pins at the soft limit and the // migration never converges, however long it runs. Map-mode flush // batches are disjoint by key and order-free (REPLACE/DELETE on // distinct keys commute), so applying them concurrently is safe and // multiplies the ceiling. Queue-mode drains (non-memory-comparable // PK, post-copy) and under-lock (cutover) flushes remain serial. DefaultFlushConcurrency = 8 // DefaultSubscriptionSoftLimitBytes caps the approximate memory held // per subscription before HasChanged starts blocking on the buffered // map's condition variable. The cap is "soft": a single oversized // row admitted when the buffer is empty will exceed the limit, and // the next caller will park until that row drains. This keeps wide // rows (LONGTEXT / BLOB / large JSON) from OOMing the migrator // while still guaranteeing forward progress regardless of row width. // See pkg/change/subscription_buffered.go for the accounting model. // // Three behaviours keep the cap from starving the change reader: // map-mode overwrites of already-buffered keys bypass it (dedup // stays live under backpressure), parking requests an immediate // flush rather than waiting for the periodic interval, and flushes // release capacity per applied batch — the reader resumes as soon // as the first batch lands, not when the whole buffer has drained. // // Operators should be aware that pausing the binlog reader for an // extended period risks falling past the source's binlog retention // (binlog_expire_logs_seconds). Tune this value, or the source's // retention, accordingly. DefaultSubscriptionSoftLimitBytes = 256 << 20 // DefaultSubscriptionSoftLimitChanges caps the number of pending // changes per subscription before HasChanged parks, alongside // DefaultSubscriptionSoftLimitBytes. Whichever binds first parks the // reader. // // The byte cap alone is not enough because bytes and count measure // different costs. Bytes bound memory, which is what a handful of wide // LONGTEXT rows threatens. Count bounds how long the drain that empties // the buffer takes: the flush applies rows in batches of at most // DefaultBatchSize per round trip, so drain time scales with count and // is indifferent to row width. A narrow-row table therefore reaches an // unworkable drain long before it reaches 256MiB — in production, a // table averaging ~600 bytes per change filled to over 450k pending // changes while still well inside the byte cap, and the drain that // followed ran for 21m37s holding flushMu for its full duration. // // 50k targets a drain of roughly two minutes rather than twenty. Scaling // that production drain — at most 452,571 rows in 21m37s — down to 50k // gives about 2m23s, and that is a floor rather than an estimate: the // 452,571 figure is the backlog at flush *start*, so if fewer rows actually // landed the per-row cost is higher and the scaled time is longer. // // Two minutes is not "one flush interval", and it is not meant to be. What // matters is that the drain *completes*, because only a complete drain // reports allChangesFlushed=true and only that advances the flushed // position; a cap tight enough to fit one DefaultFlushInterval would // truncate every drain and freeze the position just as before. Overlapping // flushes are not a concern either — flushMu serializes them, so a tick // arriving mid-drain waits rather than piling on. // // It is well above binlogTrivialThreshold, so it does not interfere with // the "flush until trivial" loops, and it still lets dedup absorb hot-row // workloads — map-mode overwrites of already-buffered keys bypass the cap // entirely. DefaultSubscriptionSoftLimitChanges = 50000 // DefaultTimeout is how long BlockWait is supposed to wait before returning errors. DefaultTimeout = 30 * time.Second )
Variables ¶
var ( // ErrChangesNotFlushed indicates that not all changes have been flushed from the replication feed. ErrChangesNotFlushed = errors.New("not all changes flushed") )
var ErrPositionNotFound = errors.New("change.Source: cannot resume from position; it is no longer available on the source")
ErrPositionNotFound is returned by StartFromPosition when the underlying source can no longer resume from the requested opaque position — most commonly because the binlog file has been purged on a MySQL source. Wrapped with %w so callers can errors.Is against it.
Functions ¶
func GTIDEnabled ¶ added in v0.17.0
GTIDEnabled reports whether the server has GTIDs enabled — gtid_mode=ON and enforce_gtid_consistency=ON — i.e. whether it can serve the GTID-based change source (COM_BINLOG_DUMP_GTID, and @@GLOBAL.gtid_executed / gtid_purged for resume validation). The permissive modes (ON_PERMISSIVE / OFF_PERMISSIVE) report false: in those modes the server may still write anonymous transactions, which have no GTID to resume from.
func IsGTIDPosition ¶ added in v0.17.0
IsGTIDPosition reports whether pos — an opaque position previously returned by Source.Position() — is a GTID-set coordinate rather than a binlog file:offset coordinate. The two encodings cannot collide: a GTID set is "uuid:interval[,uuid:interval]..." and a binlog coordinate is "<file>:<offset>" where the file ("binlog.000001") never parses as a UUID. The empty string (no position observed yet) is not a GTID position.
func NewServerID ¶
func NewServerID() uint32
NewServerID allocates IDs in [1001, 4294967295], avoiding typical MySQL server IDs. IDs do not repeat within a process until the range is exhausted. The random starting point reduces, but cannot eliminate, cross-process collisions.
func StatusRow ¶ added in v0.17.0
StatusRow renders the feed stats of srcs as the binlog row of a runner status block, or "" when no source can report. Runner Status() can be called before the feed is constructed, so nil sources are skipped.
Multiple sources (a sharded move reads one feed per source) are merged into one set of fields: counters are summed, and the flush figures are taken from the feed that flushed least recently, since that is the one holding the position back.
Types ¶
type BufferedSubscriptionConfig ¶
type BufferedSubscriptionConfig struct {
// CurrentTable is the source-side TableInfo. Required.
CurrentTable *table.TableInfo
// NewTable is the destination-side TableInfo. May be nil for
// MoveTables/import flows where source and destination share the
// same schema; in that case Subscription.Tables() returns just
// [CurrentTable].
NewTable *table.TableInfo
// Applier writes batched changes to the target. Required.
Applier applier.Applier
// Chunker provides the watermark filter + column mapping. Required.
Chunker table.MappedChunker
// Logger receives diagnostic events. Defaults to slog.Default()
// when nil.
Logger *slog.Logger
// SoftLimitBytes is the per-subscription byte cap before
// HasChanged blocks waiting on the flush path. Zero disables the
// cap. See bufferedMap.softLimitBytes for the semantics.
SoftLimitBytes int64
// SoftLimitChanges is the per-subscription cap on pending change
// *count* before HasChanged parks, applied alongside SoftLimitBytes;
// whichever binds first parks the reader. Zero disables it. See
// bufferedMap.overSoftLimitLocked for why both exist.
SoftLimitChanges int
// FlushRequest, when non-nil, receives the parked subscription (a
// non-blocking send) each time HasChanged parks on the soft limit.
// Owners that flush on a periodic ticker should select on it and
// flush the received subscription first, then run their normal
// all-subscription pass — flushing others first would leave the
// change reader parked for those entire drains. Optional; nil
// disables the signal.
FlushRequest chan<- Subscription
// FlushConcurrency is the maximum number of applier batches a
// map-mode flush keeps in flight concurrently. Zero or negative
// means serial, preserving prior behaviour for callers that do not
// set it; the in-tree clients pass DefaultFlushConcurrency. Queue-
// mode and under-lock flushes are always serial regardless.
FlushConcurrency int
// BatchSize is the maximum number of rows one flush batch renders
// into a single statement. Zero means DefaultBatchSize, preserving
// prior behaviour for callers that do not set it.
//
// It is not independent of FlushConcurrency: their product is the
// rows a drain has in flight, so a caller raising one should lower
// the other. autoscale.FlushBounds returns the pair.
BatchSize int
// UnderLoad is ClientConfig.UnderLoad: the server-load signal the drain
// narrows itself on. Optional; nil disables load shedding entirely.
UnderLoad func() bool
}
BufferedSubscriptionConfig configures NewBufferedSubscription.
type ClientConfig ¶
type ClientConfig struct {
Logger *slog.Logger
ServerID uint32
DBConfig *dbconn.DBConfig // Database configuration including TLS settings
// CancelFunc is an optional callback from the caller (e.g. migration or move runner).
// It is called when a DDL change is detected on a subscribed table
// (FatalReasonSchemaChange), or when a fatal stream error occurs, such as
// minimal RBR detection or exhausted streamer recreation attempts
// (FatalReasonStreamError). The caller is expected to handle cancellation
// and cleanup, using reason to decide whether persisted resume state
// (e.g. a checkpoint) must be invalidated (schema change) or is still
// safe to resume from (stream error).
// It returns true if the error was acted upon (caller actually cancelled),
// or false if it was ignored (e.g. because the caller is already past cutover).
CancelFunc func(reason FatalReason) bool
// DDLFilterSchema, when set, broadens DDL detection to cancel on any DDL change
// in the specified schema, rather than only on exact table matches against subscriptions.
// This is used by the move runner to detect DDL on any table in the source database.
DDLFilterSchema string
// DDLFilterTables, when set alongside DDLFilterSchema, narrows the schema-level
// DDL detection to only the specified table names. This is used for partial moves
// where only specific tables from a schema are being moved — DDL on unrelated
// tables in the same schema should not trigger cancellation.
// If empty (and DDLFilterSchema is set), all tables in the schema trigger cancellation.
DDLFilterTables []string
// SubscriptionSoftLimitBytes overrides DefaultSubscriptionSoftLimitBytes
// for new subscriptions. Set to a negative value to disable the cap
// entirely (HasChanged will never block on memory). Zero (the
// zero-value default) means use DefaultSubscriptionSoftLimitBytes.
SubscriptionSoftLimitBytes int64
// SubscriptionSoftLimitChanges overrides
// DefaultSubscriptionSoftLimitChanges for new subscriptions: the cap on
// pending change *count* before HasChanged parks, applied alongside
// SubscriptionSoftLimitBytes. Set to a negative value to disable the cap
// entirely. Zero (the zero-value default) means use
// DefaultSubscriptionSoftLimitChanges.
SubscriptionSoftLimitChanges int
// FlushConcurrency overrides DefaultFlushConcurrency for new
// subscriptions: the maximum number of applier batches a map-mode
// flush keeps in flight concurrently. Set to a negative value to
// force serial flushing. Zero (the zero-value default) means use
// DefaultFlushConcurrency.
FlushConcurrency int
// BatchSize overrides DefaultBatchSize for new subscriptions: the
// maximum number of rows one map-mode flush batch renders into a
// single statement. Zero (the zero-value default) means use
// DefaultBatchSize; a negative value is clamped to one row per
// statement.
//
// This travels with FlushConcurrency rather than being set on its
// own, because the two together decide how many rows a drain has in
// flight. See autoscale.FlushBounds, which is what sets both when
// the migration runner sizes them from the instance.
BatchSize int
// UnderLoad reports whether the target is currently loaded enough that the
// flush should narrow. Nil (the zero value) means no signal, and the drain
// runs at its configured width exactly as it did before this existed.
//
// This is the change feed's only view of server load, and it exists because
// the feed was previously the one write path with no such view at all. The
// flush is deliberately not throttled — the binlog position has to keep
// advancing or the migration loses its retention window — and the original
// reasoning was that the copier would absorb the load on its behalf. That
// held while the flush was a fixed 8 batches wide. Once the width became
// instance-derived (up to 32) the absorbing side kept shedding while the
// widened side never did, so under sustained load the copier would shed to
// almost nothing while the flush stayed at full width and the total barely
// moved. See bufferedMap.adaptFlushLoad.
//
// It is a func rather than a throttler because the change feed has no
// business importing one, and because the migration runner swaps its
// throttler during setup — a value captured at construction would be the
// wrong one.
UnderLoad func() bool
}
func NewClientDefaultConfig ¶
func NewClientDefaultConfig() *ClientConfig
NewClientDefaultConfig returns a default config for the copier.
type DrainBudgetReporter ¶ added in v0.17.0
type DrainBudgetReporter interface {
LastDrainHitBudget() bool
}
DrainBudgetReporter is implemented by Subscription implementations that bound how long one flush spends dispatching work and can report whether the last one hit that bound. Optional, for the same reason ParkReporter is: a subscription that always drains what it holds has nothing to report.
type FatalReason ¶ added in v0.16.0
type FatalReason int
FatalReason tells the caller's CancelFunc why the change client hit a fatal condition, so the caller can decide which of its state (if any) must be invalidated before cancelling.
const ( // FatalReasonSchemaChange means DDL was detected on a watched table. // Persisted resume state (checkpoints) describes the table's old // definition, so a caller that keeps such state must invalidate it: // resuming against the changed table could corrupt data. FatalReasonSchemaChange FatalReason = iota // FatalReasonStreamError means the change stream itself failed fatally // (streamer recreation attempts exhausted, or a row event could not be // processed). The watched tables are not known to have changed, so // persisted resume state remains valid and a retry can resume from it. FatalReasonStreamError )
func (FatalReason) String ¶ added in v0.16.0
func (f FatalReason) String() string
String implements fmt.Stringer for logging.
type FeedStats ¶ added in v0.17.0
type FeedStats struct {
// LastFlushAt is when the most recently completed flush finished, or the
// zero time before the first flush completes.
LastFlushAt time.Time
// LastFlushDuration is how long that flush took.
LastFlushDuration time.Duration
// LastFlushRows is how many buffered changes were pending when that flush
// started — the "batch size" of the flush. Zero is normal and meaningful:
// it is what a feed that is keeping up looks like, and it is what
// Source.Flush always ends on (it loops until the backlog is trivial and
// then flushes once more).
LastFlushRows int
// BufferedPosition is how far the feed has *read*, in the same opaque
// encoding Source.Position uses. Empty before the feed has read anything.
//
// This is deliberately not the resume coordinate. Source.Position and the
// ckpt row both report the *flushed* position, which only advances when a
// flush lands every buffered change — so while any change is held back the
// checkpoint is frozen by design, and the status block goes silent about
// the reader even though it is working normally. That is indistinguishable
// from a genuinely stalled feed, which is the case an operator most needs
// to tell apart. Reporting the buffered position alongside restores the
// distinction: if it advances between status blocks the reader is fine and
// only publication is blocked, and the gap to the ckpt row is how much
// re-reading a restart would cost.
BufferedPosition string
// BufferedEventAt is the source's own wall-clock timestamp on the newest
// event the reader has read — i.e. when the source committed the
// transaction that BufferedPosition names. Zero before the feed has read
// an event carrying a timestamp.
//
// Rendered as an age next to BufferedPosition, which is the only form in
// which the position is legible as *progress*. A GTID coordinate says
// nothing about how far behind the feed is: on a resumed run the number
// looks the same whether it is seconds or a week stale, and the count of
// GTIDs to go cannot be turned into a time without knowing the source's
// commit rate, which nothing in the status block reports. The age answers
// it directly — and it answers it from data the reader already has, with no
// extra query against the source.
//
// This is the field to read when deciding whether a resumed migration can
// converge. A migration that resumes from a week-old checkpoint has to
// replay a week of binlog before it can cut over, and until now the only
// tell was the copier starting at 99.x%. It is also the honest measure of
// checkpoint staleness that Record.Age() is not: that measures when the
// checkpoint row was last written, which on a progressing run is always
// seconds ago no matter how stale the position inside it is.
//
// Measured against this host's clock, so clock skew against the source
// shifts it. At the multi-hour lags it exists to expose that is noise; at
// "caught up" it is why the rendering floors at zero rather than showing a
// negative age.
BufferedEventAt time.Time
// Rotations counts binlog rotations the feed has followed. Duplicate
// rotate events (the server sends a real one and an artificial one
// carrying the same position) are counted once.
Rotations int64
// ForcedRotations counts the `FLUSH BINARY LOGS` statements the feed
// issued itself, which only happens when BlockWait sees the buffered
// position stall. This is the number to watch when the question is
// whether cutover-time waiting is churning through binlogs; a rising
// count with a flat Rotations count means we are the one doing it.
ForcedRotations int64
// Parks counts, cumulatively, how many times a subscription has parked
// the binlog reader on one of its soft limits. Summed across the feed's
// subscriptions.
//
// Parking is normal under a write rate the applier cannot match, and a
// single sustained episode of backpressure produces many parks — flushes
// release capacity per applied batch, so the reader is woken and re-parks
// repeatedly while one drain runs. The number to read is therefore the
// *rate* between status blocks, not the absolute value.
Parks int64
// IsParked is true when at least one of the feed's subscriptions is
// parked at the instant the status block was rendered. Together with
// Parks this separates the two cases an operator cares about: a rising
// Parks with is-parked=false is a reader being briefly throttled and
// recovering, while is-parked=true across consecutive status blocks is a
// reader being held off for minutes at a time, which is what puts the
// source's binlog retention at risk.
IsParked bool
// FlushShape is how wide a map-mode drain is running right now, and
// ConfiguredFlushShape is how wide it would run with no AIMD penalty
// outstanding. Both are taken from the same subscription, so they are
// always comparable; see mergeFlushShapes for which subscription that is.
//
// These are reported for the same reason ActiveWorkers is on the applier
// row: the number is no longer a constant anyone can assume. Since #1173
// the width is derived from the instance rather than fixed, so an operator
// reading a status block has no other way to learn what it is — it is not a
// flag they set and not a default they can look up.
//
// The pair, rather than the effective figure alone, is what makes the AIMD
// controller legible. A bare `flush=2x250` is ambiguous between a small
// instance running at its derived width and a large one that contention has
// halved twice, which are opposite situations. The controller does log each
// step it takes, but those are events in a log that may be hours deep on a
// migration measured in days, whereas this is state, re-rendered every
// status block — so a width that is stuck down is visible without going
// looking for it, and so is its recovery.
FlushShape FlushShape
ConfiguredFlushShape FlushShape
}
FeedStats is a point-in-time summary of what the change feed has been doing. It exists so the runners can fold the feed's activity into the binlog row of their single periodic status block, instead of the feed logging about itself on its own schedule (see github.com/block/spirit/issues/329).
The zero value means "nothing to report yet" and renders as a feed that has not flushed.
func (FeedStats) String ¶ added in v0.17.0
String renders the stats as the binlog row of a runner's status block.
The flush figures read as a phrase — "flushed 30s ago (took 9µs, 0 rows)" — rather than as three separate duration fields, because two of them are durations of different kinds: how long ago the flush was, and how long it took. Side by side as bare `key=0s` pairs those are genuinely ambiguous; as a phrase the reading is forced.
type FlushShape ¶ added in v0.17.0
FlushShape is the width of a map-mode drain: how many applier batches run concurrently, and how many rows each of them renders into one statement.
The two travel together because the AIMD controller moves them together — one contention step halves both, so it costs 4x, and reporting either alone would understate what a backed-off feed has given up. They are also the two terms of the lock footprint that produced the back-off in the first place: batch size sets how many records one statement locks, concurrency sets how many such statements are in flight to collide.
func (FlushShape) String ¶ added in v0.17.0
func (f FlushShape) String() string
String renders the shape as it appears in the binlog row, e.g. "8x1000".
type FlushShapeReporter ¶ added in v0.17.0
type FlushShapeReporter interface {
FlushShapes() (effective, configured FlushShape)
}
FlushShapeReporter is implemented by Subscription implementations whose drains have an adjustable width and can report it. Optional, for the same reason ParkReporter is: a queue-mode-only or out-of-tree subscription that drains serially has no shape to report and contributes nothing rather than having to grow a method.
type ParkReporter ¶ added in v0.17.0
ParkReporter is implemented by Subscription implementations that apply backpressure to the change reader and can report on it. Optional, for the same reason StatsReporter is: a subscription that never parks contributes nothing rather than having to grow a method.
type Source ¶
type Source interface {
// AddSubscription constructs a bufferedMap from (currentTable,
// newTable, chunker) and registers it. ROW events matching the
// registered (schema, table) pair are pushed to the subscription's
// HasChanged. Must be called before Start / StartFromPosition.
AddSubscription(currentTable, newTable *table.TableInfo, chunker table.MappedChunker) error
// Start begins streaming from the current source head and spawns the
// reader goroutine. Returns once the reader is running; the stream
// itself continues until Close is called or ctx is cancelled.
// Implementations perform any required validation (privileges,
// connectivity, server settings) before returning.
Start(ctx context.Context) error
// StartFromPosition is the resume-time entry point. It primes the
// source's internal position to the opaque string previously
// returned by Position(), then begins streaming as if Start had
// been called. Implementations validate the position is still
// resumable (e.g. MySQL: the binlog file has not been purged); an
// unresumable position is returned wrapped with ErrPositionNotFound.
StartFromPosition(ctx context.Context, pos string) error
// Position returns the latest safe-to-resume position as an opaque
// string. The implementation owns the encoding; spirit never parses
// it. Advances only at transaction commit boundaries. Returns "" if
// no position has been observed yet, signaling that a fresh Start is
// required.
//
// Position reports this *running* feed's in-memory progress and does no
// server I/O — contrast CurrentPosition, which reads the live server head.
Position() string
// CurrentPosition queries the source server for its current head position
// and returns it in the same opaque encoding as Position (so the result is
// a valid StartFromPosition input for this implementation).
//
// It is mechanically different from Position:
// - Position returns in-memory state: the safe-to-resume point a *running*
// feed has flushed, advancing only at commit boundaries and "" before
// the feed has observed anything. No server round-trip.
// - CurrentPosition issues a live query and needs no running feed. In
// binlog mode it FLUSHes and reads SHOW [BINARY LOG|MASTER] STATUS
// (file:offset); in GTID mode it reads @@GLOBAL.gtid_executed (a GTID
// set). Because the encoding is per-implementation, this is why the
// capture belongs on Source rather than a binlog-only helper.
//
// Its purpose is to capture a "start here, as of now" point to hand to a
// later StartFromPosition — e.g. seeding a reverse feed at cutover, before
// that feed has been started.
CurrentPosition(ctx context.Context) (string, error)
// Flush requests that all registered subscriptions flush their
// buffered changes to their targets. Blocks until the flush
// completes or ctx cancels.
Flush(ctx context.Context) error
// FlushUnderTableLock is the cutover-time variant of Flush: the
// caller holds table locks and we drain the in-flight backlog
// against that quiescent state. locks carries one lock per target
// server being written to (a single lock for single-target
// migrations; one per shard for sharded moves) — the applier
// executes each target's statements under that target's own lock,
// since LOCK TABLES blocks writes from every other connection.
FlushUnderTableLock(ctx context.Context, locks []*dbconn.TableLock) error
// BlockWait blocks until all events received from the underlying
// stream up to call-time have been delivered to their subscriptions.
// Used by the runner around cutover to drain the in-flight backlog.
// Returns when drained or ctx cancels.
BlockWait(ctx context.Context) error
// GetDeltaLen returns the total number of pending changes across
// all registered subscriptions. Used by callers to decide whether
// the backlog is small enough to consider cutover.
GetDeltaLen() int
// FlushResidual reports what the most recently completed flush left
// behind: residual is the pending-change count observed immediately
// after that flush, and flushes is a monotonic count of completed
// flushes. Both are 0 before the first flush completes.
//
// This is the quantity that says whether the feed is keeping up, and it
// has to be sampled here rather than polled by the caller. GetDeltaLen
// is a sawtooth: it climbs on every sample between flushes and drops
// when one lands, so a poller observes the residual plus however many
// writes arrived since the flush. That second term is large enough on a
// busy table to swamp the residual itself, and it does not average out
// — a polling ticker and the flush ticker hold a fixed phase
// relationship whenever their intervals are commensurate, which at the
// defaults (30s flush) they are for any poll interval that divides it.
//
// A caller watching for a feed that is losing ground should compare
// residuals only across distinct flushes, which is what flushes is for.
// A residual that stays near zero means the feed is keeping up however
// heavy the write load; one that climbs flush over flush means work is
// surviving flushes and accumulating.
FlushResidual() (residual, flushes int)
// SetWatermarkOptimization toggles the high/low watermark
// optimization across all subscriptions. Disabled before
// checksum/cutover to ensure all changes are flushed regardless of
// watermark position.
SetWatermarkOptimization(ctx context.Context, enabled bool) error
// StartPeriodicFlush spawns a background goroutine that flushes the
// changeset at the given interval. Used by the migrator to advance
// the safe-flushed position. Calling Start while a periodic flush
// is already running or after Close has been called is a no-op.
StartPeriodicFlush(ctx context.Context, interval time.Duration)
// StopPeriodicFlush stops the goroutine started by
// StartPeriodicFlush. Safe to call when no periodic flush is
// running (no-op).
StopPeriodicFlush()
// AllChangesFlushed reports whether the buffered position has been
// caught up to the flushed position (i.e. no in-flight changes
// remain). For non-binlog implementations, this is equivalent to
// "have all received events been applied?".
AllChangesFlushed() bool
// Stop ends delivery of events to subscriptions. Everything else stays
// live: the source keeps reading and tracking its position, and Flush /
// BlockWait / AllChangesFlushed / Position keep working. Close, not Stop,
// releases resources. One-way and idempotent.
//
// Cutover calls it once the tables are renamed and while it still holds
// the exclusive lock, so no write can be in flight — after UNLOCK TABLES
// the first post-cutover write is a race, and those events no longer
// decode against the subscriptions' TableInfo (see cutover.go). Hence two
// requirements: Stop must not block, because every write to the table is
// stalled behind it, and it must leave the source flushable, because a
// rename that fails ambiguously is retried via Flush and BlockWait.
Stop()
// Close releases all resources, cancelling and joining the reader and any
// periodic flush loop. Subsequent StartPeriodicFlush calls are no-ops.
// Safe to call more than once.
Close()
}
Source is the abstraction spirit uses to consume a stream of row changes from a source database. It exists so spirit's replication pipeline is not pinned to the MySQL binlog protocol — alternative implementations (e.g. Vitess VStream) can plug in without touching the applier, the bufferedMap, or any other spirit-side machinery.
The built-in implementation that uses go-mysql's BinlogSyncer lives in this package and backs the existing Client. Out-of-tree implementations construct their own Source value and pass it to spirit via the Move/Migration config.
Lifecycle: construct → AddSubscription(...)* → Start(ctx) OR StartFromPosition(ctx, pos) → Flush / BlockWait / FlushUnderTableLock as needed → Stop() → Close().
Events flow PUSH-style: when a row event matching one of the subscribed tables arrives, the source implementation looks up the Subscription whose Tables() includes that (schema, table) and calls sub.HasChanged(key, row, deleted) directly. There is no Next() / Recv() loop on this interface — the caller registers subscriptions and lets the source drive them.
The surface area is intentionally broad to match the existing binlog-backed implementation so all spirit consumers (pkg/migration, pkg/move, pkg/checksum) program against the interface. Resume-time positions are opaque strings (Position / StartFromPosition) so that alternative implementations can encode whatever they need (file+offset, GTID, VStream position, etc.) without leaking to callers.
func NewAutoClient ¶ added in v0.17.0
func NewAutoClient(ctx context.Context, db *sql.DB, host string, username, password string, appl applier.Applier, config *ClientConfig, resumePosition string) (Source, error)
NewAutoClient constructs the built-in change.Source for a server, selecting between the GTID and binlog file:offset implementations:
- Fresh runs (resumePosition == ""): the server is probed and the GTID client is used when GTIDs are enabled (GTIDEnabled), the binlog client otherwise.
- Resumed runs (resumePosition != ""): the position's own encoding decides, so the run stays in the coordinate scheme it started with. A GTID-set position requires the GTID client (and errors if the server no longer has GTIDs enabled); a file:offset position uses the binlog client even when the server could serve GTIDs — e.g. a checkpoint written by an older spirit.
The remaining arguments mirror NewBinlogClient / NewGTIDClient, which this delegates to. The chosen implementation is logged on config.Logger.
func NewBinlogClient ¶
func NewBinlogClient(db *sql.DB, host string, username, password string, appl applier.Applier, config *ClientConfig) Source
NewBinlogClient constructs the binlog-backed change.Source. The returned Source talks to MySQL via go-mysql's BinlogSyncer; future alternative sources (e.g. VStream) will live behind their own constructors. config.Applier is required.
func NewGTIDClient ¶
func NewGTIDClient(db *sql.DB, host string, username, password string, appl applier.Applier, config *ClientConfig) Source
NewGTIDClient constructs the GTID-backed change.Source. It mirrors NewBinlogClient: config.Applier (passed via appl) is required.
Most callers should use NewAutoClient instead, which selects between this and the binlog client based on the server's GTID support (fresh runs) or the checkpointed position's encoding (resumes).
type StatsReporter ¶ added in v0.17.0
type StatsReporter interface {
FeedStats() FeedStats
}
StatsReporter is implemented by change.Source implementations that can report FeedStats. It is deliberately a separate, optional interface rather than part of Source: out-of-tree sources (e.g. a VStream-backed one) should not have to grow a method to keep compiling, and a source that cannot report simply contributes nothing to the status block.
type Subscription ¶
type Subscription interface {
HasChanged(key, row []any, deleted bool)
Length() int
// Flush writes the pending changes to the target(s) via the applier.
// When underLock is true, locks carries the table locks the caller is
// holding — one per target server — and the applier executes each
// target's statements under that target's own lock.
Flush(ctx context.Context, underLock bool, locks []*dbconn.TableLock) (allChangesFlushed bool, err error)
// Tables returns the tables related to the subscription in
// currentTable, newTable order. Move-flow subscriptions have no
// destination-side TableInfo, in which case only [currentTable] is
// returned. Entries are never nil: consumers (the clients' DDL
// subscription-match loops, out-of-tree change.Source event routing)
// iterate and dereference them.
Tables() []*table.TableInfo
// ImmutableColumnOrdinal returns the position (an index into
// Tables()[0].Columns, and thus into each full binlog row image) of a
// column whose value must never change between the before and after
// image of an UPDATE, or -1 when no such column is configured.
//
// This backs the sharded applier's vindex contract (see
// applier.ShardedApplier.UpsertRows): modifications are tracked by
// PRIMARY KEY only, so an UPDATE that changed the sharding column
// would flush the new row image to its new shard while the old shard
// silently kept a stale copy. The change source is expected to treat
// such an UPDATE as a fatal error and cancel the operation — see
// checkImmutableColumn.
ImmutableColumnOrdinal() int
// SetWatermarkOptimization toggles both high and low watermark
// optimizations. For non-memory-comparable PKs toggling switches the
// subscription between map mode and queue mode; on such a transition
// it drains the outgoing store via the applier so only one store has
// pending entries at a time. Returns the drain error if any.
SetWatermarkOptimization(ctx context.Context, enabled bool) error
// Close signals that no further events will be delivered. Any HasChanged
// caller currently parked on backpressure (e.g. the bufferedMap soft
// memory limit) is unblocked so the binlog reader goroutine can exit.
// Close does NOT flush; pending changes are discarded along with the
// subscription. It is safe to call more than once.
Close()
}
func NewBufferedSubscription ¶
func NewBufferedSubscription(cfg BufferedSubscriptionConfig) (Subscription, error)
NewBufferedSubscription constructs the default bufferedMap-backed Subscription. It is the public counterpart to binlogClient's internal AddSubscription helper: out-of-tree change.Source implementations (e.g. strata's pkg/vstream) call this from their own AddSubscription to build a Subscription the runner / copier can drive.
The returned Subscription is not yet wired into a registry — the caller is responsible for storing it and routing row events to its HasChanged method. The internal sync.Cond is initialised before return (matching subscriptionRegistry.AddBuffered) so HasChanged / Flush / SetWatermarkOptimization are safe to call immediately.