Add opt-in master failover recovery with Ferry-wide source swap (GTID stage 6)#451
Open
driv3r wants to merge 3 commits into
Open
Add opt-in master failover recovery with Ferry-wide source swap (GTID stage 6)#451driv3r wants to merge 3 commits into
driv3r wants to merge 3 commits into
Conversation
When the source master fails over, the binlog streamer previously died: a non-timeout GetEvent error went straight to ErrorHandler.Fatal, and go-mysql's syncer only reconnects to the same configured host. This adds opt-in recovery that reconnects to the promoted writer and resumes streaming, so a topology failover no longer aborts the run. Recovery is GTID-only. GTID sets are server-independent, so a resume set valid on the old master is meaningful on the new one; file/position coordinates are per-host and cannot be carried across a failover, so the feature refuses to enable in file/position mode. Design: - MasterWriterResolver (supplied by the embedding app) discovers the new writer; MasterFailoverRecoveryConfig carries it plus retry bounds. - On a non-timeout GetEvent error, the Run loop attempts recovery before fataling. Each attempt resolves a candidate, validates it, rebuilds the syncer against it, and restarts StartSyncGTID from the last resumable set (replaying the interrupted transaction). - validateFailoverTarget fails closed: the candidate must be a writer (not read_only), have gtid_mode=ON, and its executed set must contain everything already applied downstream. The applied set is the committed streamed set folded together with the in-flight transaction's GTID (tracked from GTIDEvent/XIDEvent), since in-flight rows may already have been emitted to listeners before the commit advances the streamed set. A candidate missing any applied transaction is rejected to prevent silent target divergence. - DB/DBConfig swaps happen under a connMu lock; FlushAndStop reads the DB through currentDB() so cutover records the stop coordinate from the new writer. Only failover-owned DB handles are closed; the shared SourceDB is never closed here. Config.MasterFailoverRecovery is validated (requires gtid mode + a resolver) and wired onto the source streamer only; the target verifier streams from the stable target and does not fail over.
The streamer previously swapped only its own DB/DBConfig on failover, so other source consumers (data iterator, verifiers, SHOW CREATE queries) kept talking to the demoted master. Add an optional OnFailover callback on MasterFailoverRecoveryConfig, invoked after a successful swap with a MasterFailoverEvent describing the new writer (config + live DB handle) and the previous config. This is the seam Ferry will use to repoint the whole run at the promoted writer. The callback runs on the Run goroutine, must not close the provided DB (owned by the streamer), and a callback error is logged but does not abort the already-recovered stream.
Previously only the binlog streamer followed a source failover; the rest of the Ferry (SourceDB, Config.Source, data iterator, verifiers, ad-hoc SHOW CREATE queries) kept talking to the demoted master. This wires the streamer's OnFailover callback to a Ferry-wide swap so the entire run follows the promoted writer. swapSource (ferry_failover.go): - Opens a Ferry-OWNED connection to the new master rather than adopting the streamer's handle, so the streamer closing its handle on exit/next failover never pulls the rug out from a post-Run consumer. - Repoints SourceDB, Config.Source, DataIterator.DB + CursorConfig.DB, inline verifier SourceDB, and the configured verifier (iterative: both SourceDB and its CursorConfig; checksum: SourceDB), with typed-nil guards. - Resets the inline verifier's prepared-statement cache, which is keyed by query text and would otherwise keep statements bound to the old DB. - Retains previous source handles (not closed at swap time, since in-flight cursors and cached statements may still reference them) for closePreviousSourceDBs to release during teardown. - Serialized by Ferry.sourceSwapMu; CurrentSourceDB() is the lock-safe accessor for callers that may run concurrently with a failover. failoverRecoveryConfigForSource shallow-copies the user's recovery config and composes an OnFailover that runs the Ferry swap first, then any user-supplied callback, without mutating the user's config. Note: cursors and prepared statements created before a swap keep their old DB reference (CursorConfig is copied by value into each Cursor), so a failover mid-row-copy is only fully picked up by work started after the swap. Failover recovery matters most at/after row-copy completion (binlog catchup and cutover), where this is sufficient.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds opt-in master failover recovery for Ghostferry's source. When the source MySQL master fails over, Ghostferry reconnects to the promoted writer and the entire run follows it — not just the binlog stream, but also the data iterator, verifiers, and ad-hoc source queries.
Stage 6 of the GTID coordinate stack (builds on #449 /
gtid-stage5-replica-progress).Why
Previously a source failover killed Ghostferry: a non-timeout
GetEventerror went straight toErrorHandler.Fatal, and go-mysql'sBinlogSynceronly reconnects to the same configured host. There was no path to follow the writer to a new host.GTID mode makes safe failover possible: GTID sets are server-independent, so a resume set valid on the old master is meaningful on the new one. File/position coordinates are per-host and cannot be carried across a failover, so the feature refuses to enable in file/position mode.
How
Implemented in three commits:
1. Streamer-level recovery
MasterWriterResolver(supplied by the embedding application) discovers the new writer.MasterFailoverRecoveryConfigcarries it plus retry bounds (MaxAttempts,RetryWait).GetEventerror, theRunloop attempts recovery before fataling. Each attempt: resolve a candidate → validate it → build a fresh syncer against it →StartSyncGTIDfrom the last resumable set (replaying the interrupted transaction).validateFailoverTargetfails closed. A candidate is accepted only if it is a writer (not@@read_only), has@@GLOBAL.gtid_mode = ON, and its executed GTID set contains everything already applied downstream — the committed streamed set folded together with the in-flight transaction's GTID (tracked fromGTIDEvent/XIDEvent). A candidate missing any applied transaction is rejected to prevent silent target divergence.connMulock;FlushAndStopreads the DB throughcurrentDB()so cutover records the stop coordinate from the new writer. Only failover-owned DB handles are closed; the shared source handle is never closed there.2.
OnFailovernotification hookMasterFailoverRecoveryConfig.OnFailoveris invoked after a successful swap with aMasterFailoverEvent(new writer config + live DB handle, previous config). This is the seam the Ferry uses to repoint the whole run. Callback errors are logged but do not abort the already-recovered stream.3. Ferry-wide source swap
swapSourceopens a Ferry-owned connection to the promoted master (rather than adopting the streamer's handle, so lifecycles don't entangle) and repoints:Ferry.SourceDB,Config.SourceDataIterator.DB+CursorConfig.DBSourceDBand its prepared-statement cache (reset, since it's keyed by query text and would otherwise stay bound to the old DB)SourceDBand itsCursorConfig; checksum:SourceDB), with typed-nil guardsclosePreviousSourceDBsreleases them at teardown.Ferry.sourceSwapMu;CurrentSourceDB()is the lock-safe accessor.failoverRecoveryConfigForSourceshallow-copies the user's config and composes the Ferry swap ahead of any user callback, without mutating the user's config.Safety notes & scope
CursorConfigis copied by value into eachCursor), so a failover mid-row-copy is only fully picked up by work started after the swap. Failover recovery matters most at/after row-copy completion (binlog catchup and cutover), where this is sufficient. Runtime readers that need strict consistency should go throughCurrentSourceDB().Testing
OnFailovercomposition, and the Ferry-wide swap (all consumers repointed, stmt-cache reset, previous-handle retention, nil-safety, teardown drain).go test ./...unit suite passes with-race.test/gointegration suite passes in bothfile_positionandgtidmodes.go build+gofmtclean.Reviewer
Reviewed by automated code review across all three commits; data-safety findings addressed (applied-set + in-flight GTID containment, DB-swap race, shared-handle close semantics, verifier
CursorConfig/stmt-cache repointing, DB ownership across failover, nil-safety).