Skip to content

Add opt-in master failover recovery with Ferry-wide source swap (GTID stage 6)#451

Open
driv3r wants to merge 3 commits into
gtid-stage5-replica-progressfrom
gtid-stage6-master-failover
Open

Add opt-in master failover recovery with Ferry-wide source swap (GTID stage 6)#451
driv3r wants to merge 3 commits into
gtid-stage5-replica-progressfrom
gtid-stage6-master-failover

Conversation

@driv3r

@driv3r driv3r commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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 GetEvent error went straight to ErrorHandler.Fatal, and go-mysql's BinlogSyncer only 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. MasterFailoverRecoveryConfig carries it plus retry bounds (MaxAttempts, RetryWait).
  • On a non-timeout GetEvent error, the Run loop attempts recovery before fataling. Each attempt: resolve a candidate → validate it → build a fresh syncer against it → StartSyncGTID from the last resumable set (replaying the interrupted transaction).
  • validateFailoverTarget fails 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 from GTIDEvent/XIDEvent). A candidate missing any applied transaction is rejected to prevent silent target divergence.
  • Concurrency: 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 source handle is never closed there.

2. OnFailover notification hook

  • MasterFailoverRecoveryConfig.OnFailover is invoked after a successful swap with a MasterFailoverEvent (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

  • swapSource opens 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.Source
    • DataIterator.DB + CursorConfig.DB
    • inline verifier SourceDB and its prepared-statement cache (reset, since it's keyed by query text and would otherwise stay bound to the old DB)
    • the configured verifier (iterative: both SourceDB and its CursorConfig; checksum: SourceDB), with typed-nil guards
  • Previous source handles are retained, not closed at swap time (in-flight cursors capture their DB by value and cached statements may still reference it); closePreviousSourceDBs releases them at teardown.
  • Serialized by Ferry.sourceSwapMu; CurrentSourceDB() is the lock-safe accessor.
  • failoverRecoveryConfigForSource shallow-copies the user's config and composes the Ferry swap ahead of any user callback, without mutating the user's config.

Safety notes & scope

  • Fully backward compatible and opt-in: nil config keeps the previous behavior (lost connection stays fatal).
  • File/position mode is rejected at config validation.
  • 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. Runtime readers that need strict consistency should go through CurrentSourceDB().

Testing

  • New unit tests: recovery gating, resolver adapter, GTID union/clone helpers, in-flight GTID tracking, config validation, OnFailover composition, 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.
  • Full test/go integration suite passes in both file_position and gtid modes.
  • go build + gofmt clean.

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

driv3r added 3 commits July 22, 2026 22:44
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.
@driv3r driv3r changed the title Add opt-in master failover recovery (GTID stage 6) Add opt-in master failover recovery with Ferry-wide source swap (GTID stage 6) Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant