Fix instant-DDL deadlock on GhostTableMigrated changelog signal - #1736
Fix instant-DDL deadlock on GhostTableMigrated changelog signal#1736peterbollen wants to merge 1 commit into
Conversation
When --attempt-instant-ddl succeeds, Migrate() returns early after finalCleanup() without ever receiving from the ghostTableMigrated channel. initiateApplier writes a GhostTableMigrated changelog row, and the streamer's listener callback (onChangelogStateEvent) publishes that signal synchronously via SendWithContext while holding EventsStreamer.listenersMutex. On the normal path a receiver drains it, but the instant-DDL path skips that receive, so the send blocks forever holding the mutex. finalCleanup() then closes the binlog reader, whose rows-event decode callback (shouldDecodeRowsEvent) needs the same mutex, and Close() waits for that goroutine to exit -> permanent deadlock. Fix: drain the GhostTableMigrated signal on the instant-DDL success path before finalCleanup, mirroring the existing receive on the normal path. Resume migrations never emit the signal, so draining is guarded by !Resume. Adds regression tests: a migrator-level test for drainGhostTableMigrated (drains the signal and unblocks the publisher; skips for resume migrations) and a streamer-level test that reproduces the exact deadlock and proves draining resolves it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Independent confirmation of the diagnosis in #1735, plus one issue with the fix as written. Confirming the bug. We hit this in CI while bumping gh-ost from a 2026-05-14 build to v1.1.11. Our instant-DDL integration tests hung indefinitely — the migration stayed in The concern with the fix. func (mgtr *Migrator) drainGhostTableMigrated() {
if mgtr.migrationContext.Resume {
return
}
<-mgtr.ghostTableMigrated
}The sender publishes via Concretely: Suggestion — mirror #1677 here: func (mgtr *Migrator) drainGhostTableMigrated() error {
if mgtr.migrationContext.Resume {
return nil
}
select {
case <-mgtr.ghostTableMigrated:
return nil
case <-mgtr.migrationContext.GetContext().Done():
return mgtr.checkAbort()
}
}For what it's worth, I've opened #1758 applying the same treatment to the other remaining bare receive on this channel — the |
Related issue: #1735
Description
Fixes a deadlock that hangs gh-ost during cleanup when
--attempt-instant-ddlis used and the instantALTERsucceeds.initiateApplierwrites aGhostTableMigratedchangelog row. The streamer's changelog listener callback (onChangelogStateEvent) publishes that signal synchronously viabase.SendWithContext(ctx, ghostTableMigrated, true)while holdingEventsStreamer.listenersMutex(the send runs insidenotifyListeners, which holds the mutex for the whole callback).On the normal path there is a matching
<-mgtr.ghostTableMigratedreceiver. The instant-DDL success path, however, returns early right afterfinalCleanup()and never receives, so the send blocks forever holdinglistenersMutex.finalCleanup()then closes the binlog reader, whose rows-event decode callback (shouldDecodeRowsEvent) needs the same mutex, andBinlogSyncer.Close()waits for that goroutine to exit → permanent deadlock.Fix: drain the
GhostTableMigratedsignal on the instant-DDL success path beforefinalCleanup(), mirroring the receive on the normal path. The drain is extracted intoMigrator.drainGhostTableMigrated()and guarded by!Resume, since resume migrations never emit the signal (initiateApplieronly writes it when!Revert && !Resume).Tests
TestMigratorDrainGhostTableMigrated— the drain consumes the signal and unblocks the publisher; it is skipped for resume migrations.TestEventsStreamerInstantDDLDeadlockIsResolvedByDraining— reproduces the exact deadlock (listener callback blocked on the send while holdinglistenersMutex;shouldDecodeRowsEventblocked on the same mutex) and proves that draining the signal resolves it.Both pass with
-race.script/cibuildreturns with no formatting errors, build errors or unit test errors.