Skip to content

Commit 3420ea8

Browse files
Peter BollenCopilot
andcommitted
Fix instant-DDL deadlock on GhostTableMigrated changelog signal
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>
1 parent c231272 commit 3420ea8

3 files changed

Lines changed: 144 additions & 0 deletions

File tree

go/logic/migrator.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,28 @@ func (mgtr *Migrator) checkAbort() error {
471471
return nil
472472
}
473473

474+
// drainGhostTableMigrated consumes the "GhostTableMigrated" changelog signal that
475+
// initiateApplier emits once the ghost table is in place. It must be called on the
476+
// instant-DDL success path before finalCleanup runs.
477+
//
478+
// The streamer's changelog listener callback (onChangelogStateEvent) publishes this
479+
// signal synchronously while holding the streamer's listenersMutex. On the normal
480+
// migration path there is a dedicated receiver (see below in Migrate), but the
481+
// instant-DDL path returns early and would otherwise never receive. The blocked send
482+
// keeps listenersMutex held forever, so when finalCleanup closes the binlog reader
483+
// its rows-event decode callback (shouldDecodeRowsEvent) deadlocks trying to acquire
484+
// the same mutex. Draining the signal here releases the listener and avoids the
485+
// deadlock.
486+
//
487+
// Resume migrations never emit the signal (initiateApplier only writes it when
488+
// !Revert && !Resume), so there is nothing to drain in that case.
489+
func (mgtr *Migrator) drainGhostTableMigrated() {
490+
if mgtr.migrationContext.Resume {
491+
return
492+
}
493+
<-mgtr.ghostTableMigrated
494+
}
495+
474496
// Migrate executes the complete migration logic. This is *the* major gh-ost function.
475497
func (mgtr *Migrator) Migrate() (err error) {
476498
mgtr.migrationContext.Log.Infof("Migrating %s.%s", sql.EscapeName(mgtr.migrationContext.DatabaseName), sql.EscapeName(mgtr.migrationContext.OriginalTableName))
@@ -535,6 +557,7 @@ func (mgtr *Migrator) Migrate() (err error) {
535557
} else {
536558
mgtr.migrationContext.Log.Infof("Attempting to execute alter with ALGORITHM=INSTANT")
537559
if err := mgtr.applier.AttemptInstantDDL(); err == nil {
560+
mgtr.drainGhostTableMigrated()
538561
if err := mgtr.finalCleanup(); err != nil {
539562
return nil
540563
}

go/logic/migrator_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,59 @@ import (
3535
"github.com/testcontainers/testcontainers-go"
3636
)
3737

38+
func TestMigratorDrainGhostTableMigrated(t *testing.T) {
39+
t.Run("receives the GhostTableMigrated signal", func(t *testing.T) {
40+
migrationContext := base.NewMigrationContext()
41+
migrator := NewMigrator(migrationContext, "test")
42+
43+
// Emulate the streamer's changelog listener: onChangelogStateEvent publishes
44+
// the signal synchronously and only returns once a receiver consumes it.
45+
sendErr := make(chan error, 1)
46+
go func() {
47+
sendErr <- base.SendWithContext(migrationContext.GetContext(), migrator.ghostTableMigrated, true)
48+
}()
49+
50+
done := make(chan struct{})
51+
go func() {
52+
migrator.drainGhostTableMigrated()
53+
close(done)
54+
}()
55+
56+
select {
57+
case <-done:
58+
case <-time.After(2 * time.Second):
59+
t.Fatal("drainGhostTableMigrated blocked; the signal was not consumed")
60+
}
61+
62+
select {
63+
case err := <-sendErr:
64+
require.NoError(t, err, "publisher should have been unblocked by the drain")
65+
case <-time.After(2 * time.Second):
66+
t.Fatal("publisher still blocked after drain")
67+
}
68+
})
69+
70+
t.Run("skips draining for resume migrations", func(t *testing.T) {
71+
migrationContext := base.NewMigrationContext()
72+
migrationContext.Resume = true
73+
migrator := NewMigrator(migrationContext, "test")
74+
75+
// A resume migration never emits the signal, so the drain must return
76+
// immediately instead of blocking forever on the empty channel.
77+
done := make(chan struct{})
78+
go func() {
79+
migrator.drainGhostTableMigrated()
80+
close(done)
81+
}()
82+
83+
select {
84+
case <-done:
85+
case <-time.After(2 * time.Second):
86+
t.Fatal("drainGhostTableMigrated blocked for a resume migration")
87+
}
88+
})
89+
}
90+
3891
func TestMigratorOnChangelogEvent(t *testing.T) {
3992
migrationContext := base.NewMigrationContext()
4093
migrator := NewMigrator(migrationContext, "1.2.3")

go/logic/streamer_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"testing"
88
"time"
99

10+
"github.com/github/gh-ost/go/base"
1011
"github.com/github/gh-ost/go/binlog"
12+
"github.com/stretchr/testify/require"
1113
"github.com/stretchr/testify/suite"
1214
"github.com/testcontainers/testcontainers-go"
1315
"github.com/testcontainers/testcontainers-go/modules/mysql"
@@ -287,6 +289,72 @@ func TestEventsStreamerShouldDecodeRowsEvent(t *testing.T) {
287289
}
288290
}
289291

292+
func TestEventsStreamerInstantDDLDeadlockIsResolvedByDraining(t *testing.T) {
293+
// Regression test for the instant-DDL deadlock. It reproduces the exact
294+
// mechanism and proves that draining the GhostTableMigrated signal (what
295+
// Migrator.drainGhostTableMigrated does on the instant-DDL success path)
296+
// resolves it.
297+
//
298+
// notifyListeners invokes the changelog listener synchronously while holding
299+
// listenersMutex. The listener publishes on an unbuffered channel (mirroring
300+
// onChangelogStateEvent -> SendWithContext) and blocks until the signal is
301+
// received. Meanwhile the binlog reader's shouldDecodeRowsEvent needs the same
302+
// mutex and blocks as well. Without a receiver both stay blocked forever.
303+
migrationContext := newTestMigrationContext()
304+
streamer := NewEventsStreamer(migrationContext)
305+
306+
ghostTableMigrated := make(chan bool) // unbuffered, mirrors Migrator.ghostTableMigrated
307+
308+
err := streamer.AddListener(false, testMysqlDatabase, testMysqlTableName, func(event *binlog.BinlogEntry) error {
309+
return base.SendWithContext(migrationContext.GetContext(), ghostTableMigrated, true)
310+
})
311+
require.NoError(t, err)
312+
313+
entry := &binlog.BinlogEntry{
314+
DmlEvent: binlog.NewBinlogDMLEvent(testMysqlDatabase, testMysqlTableName, binlog.InsertDML),
315+
}
316+
317+
notifyReturned := make(chan struct{})
318+
go func() {
319+
streamer.notifyListeners(entry) // holds listenersMutex, blocks on the listener's send
320+
close(notifyReturned)
321+
}()
322+
323+
decodeReturned := make(chan bool, 1)
324+
go func() {
325+
decodeReturned <- streamer.shouldDecodeRowsEvent(testMysqlDatabase, testMysqlTableName)
326+
}()
327+
328+
// Both goroutines are blocked and cannot progress until the signal is drained:
329+
// notifyListeners on the send, shouldDecodeRowsEvent on the mutex.
330+
select {
331+
case <-notifyReturned:
332+
t.Fatal("notifyListeners returned before draining; the test no longer reproduces the deadlock")
333+
case <-time.After(200 * time.Millisecond):
334+
}
335+
336+
// The fix: the instant-DDL path drains the signal before finalCleanup.
337+
select {
338+
case <-ghostTableMigrated:
339+
case <-time.After(2 * time.Second):
340+
t.Fatal("GhostTableMigrated signal was never published")
341+
}
342+
343+
// Draining releases the listener, so notifyListeners returns and frees the mutex,
344+
// which unblocks the decode path.
345+
select {
346+
case <-notifyReturned:
347+
case <-time.After(2 * time.Second):
348+
t.Fatal("notifyListeners still blocked after drain: deadlock not resolved")
349+
}
350+
select {
351+
case decoded := <-decodeReturned:
352+
require.True(t, decoded, "registered table should be decoded")
353+
case <-time.After(2 * time.Second):
354+
t.Fatal("shouldDecodeRowsEvent still blocked after drain: mutex was not released")
355+
}
356+
}
357+
290358
func TestEventsStreamer(t *testing.T) {
291359
if testing.Short() {
292360
t.Skip("skipping events streamer test suite in short mode")

0 commit comments

Comments
 (0)