EXPLORATION: refactor BinlogStreamer onto go-mysql canal (findings)#450
Closed
driv3r wants to merge 1 commit into
Closed
EXPLORATION: refactor BinlogStreamer onto go-mysql canal (findings)#450driv3r wants to merge 1 commit into
driv3r wants to merge 1 commit into
Conversation
Spike to evaluate replacing Ghostferry's hand-rolled binlog event loop with
go-mysql/canal, keeping the DMLEvent output boundary and GTID coordinate
semantics unchanged.
What changed:
- binlog_streamer.go now drives canal instead of a raw replication.BinlogSyncer:
the manual Run() GetEvent loop, the big event-type switch (defaultEventHandler),
rotate/format-description/position bookkeeping, and handleRowsEvent decoding are
replaced by a single canal.EventHandler adapter (OnRotate/OnRowsQueryEvent/OnRow/
OnGTID/OnXID/OnDDL/OnPosSynced).
- dml_events.go: row constructors take [][]interface{} (shared), and a new
NewBinlogDMLEventsFromCanal builds events from canal.RowsEvent (Action-based,
no replication event-type switch).
- AddBinlogEventHandler/BinlogEventState (raw replication.BinlogEvent hooks) are
removed in favor of a typed DDLEventHandler; ddl_ghostferry + tests migrated.
Simplification measured:
- binlog_streamer.go 802 -> 753 lines, but the removed portion is the gnarly
low-level event loop; the net is modest because the DMLEvent/coordinate/stop
plumbing all remains.
- pulls in extra transitive deps (BurntSushi/toml, pingcap/failpoint, tidb parser).
BLOCKER (why this stays an exploration): canal's stop/teardown model deadlocks
against Ghostferry's aggressive cutover. canal owns the loop and is stopped via
canal.Close(), which waits on the syncer goroutine; that goroutine is frequently
parked in a blocking socket read (especially the low-traffic target-verifier
streamer after the source goes read-only at cutover). canal.Close()'s 100ms read
deadline does not reliably preempt the in-progress netpoll read, so Close() (and
thus Ferry shutdown via StopTargetVerifier) hangs. Reproduced consistently at
~50% failure across ReadTimeout/HeartbeatPeriod/async-close variations. Unit
tests and happy-path copy (both file_position and gtid) pass; only the
stop-at-cutover path is affected.
Conclusion: canal is a genuine maintainability win for the event-decode path but
its cooperative-shutdown model is incompatible with Ghostferry's stop-at-
coordinate cutover without upstream changes (e.g. a context-cancellable
GetEvent or a non-blocking Close). Not mergeable as-is.
driv3r
marked this pull request as ready for review
July 23, 2026 01:42
Contributor
Author
|
@Shopify/db-mobility @milanatshopify @pawandubey - just as an FYI for the future |
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.
Warning
This is an exploration spike, not a merge candidate. It compiles and is functionally correct on the happy path, but has a hard blocker at cutover (see below). Opening as draft to preserve the work and share findings.
Goal
Evaluate replacing Ghostferry's hand-rolled binlog event loop with
go-mysql/canal, keeping theDMLEventoutput boundary and GTID coordinate semantics unchanged. The hypothesis (from prior discussion) was that canal would improve maintainability by removing low-level code paths we currently own.Base branch:
gtid-stage5-replica-progress(canal pairs naturally with the GTID work).What changed
binlog_streamer.gonow drivescanalinstead of a rawreplication.BinlogSyncer. Replaced by a singlecanal.EventHandleradapter (OnRotate/OnRowsQueryEvent/OnRow/OnGTID/OnXID/OnDDL/OnPosSynced):Run()GetEventloopswitch(defaultEventHandler)handleRowsEventraw decodingdml_events.go: row constructors take[][]interface{}(shared), and newNewBinlogDMLEventsFromCanalbuilds events fromcanal.RowsEvent(Action-based, noreplicationevent-type switch).AddBinlogEventHandler/BinlogEventStatehooks (only real use was intercepting DDLQueryEvents) in favor of a typedDDLEventHandler func(schema, table string, query []byte) error.ddl_ghostferryharness + streamer tests migrated accordingly.Simplification measured
Modest.
binlog_streamer.goBurntSushi/toml,pingcap/failpoint, tidb parserThe removed code is the gnarly low-level loop, but the DMLEvent construction, coordinate stamping, filtering, stop/cutover, lag metrics, and server-id logic all remain (canal doesn't own those). Net win is real but smaller than hoped, and it adds dependency surface.
🚧 Blocker: canal stop-path deadlock at cutover
Functionally the refactor is correct — unit tests pass, and happy-path copy passes in both
file_positionandgtidmodes. But there is a hard stop-path deadlock at cutover, reproduced consistently at ~50% failure onTestCopyDataWith*:canal.Close().canal.Close()waits on the syncer goroutine (wg.Wait()).read_onlyat cutover.netpollread, soClose()(and thusFerryshutdown viaStopTargetVerifier) hangs → test timeout → cascade (leftoverread_only = ONthen fails the next run's replica precheck).Representative goroutine dump at the hang:
Mitigations attempted (all still flaky):
cfg.ReadTimeoutat 1s and 5scfg.HeartbeatPeriod = 1ssync.Once-guardedClose()to avoid double-closeClose()from the monitor + defer — made it worse (Run returns mid-teardown, reconnect races)Conclusion / recommendation
Canal is a genuine maintainability improvement for the event-decode path, but its cooperative-shutdown model is incompatible with Ghostferry's stop-at-coordinate cutover without upstream changes — e.g. a context-cancellable
GetEvent, or a non-blocking / force-closeClose(). Not mergeable as-is.This matches the earlier assessment: canal helps maintainability, but the hard parts (here, deterministic stop/cutover) still need Ghostferry-level orchestration — and in this case canal actively fights it.
Possible follow-ups (if we want to pursue canal)
GetEvent/ non-blockingClose()togo-mysql, orcanal.Close()by holding the syncer reference and force-closing the underlyingnet.Conn, orBinlogSyncer(current approach) and only adopt canal'sRowsEvent/Actionnormalization for the decode layer.Test status
go test .) — both modesTestBinlogStreamerTestSuite(incl. new DDL-handler test) — file_positionTestCopyDataWithInsertLoad) — file_position and gtidTestCopyDataWith*under load — ~50% flaky due to the cutover deadlock above