Skip to content

Commit 5ed7f36

Browse files
authored
Merge pull request #1747 from VandhanaSelvaprakash-at/vaselvap-upstream-analyze-cutover
Add opt-in --analyze-ghost-table-before-cutover
2 parents 0dda3ab + 7e43186 commit 5ed7f36

10 files changed

Lines changed: 295 additions & 3 deletions

File tree

doc/command-line-flags.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ A more in-depth discussion of various `gh-ost` command line flags: implementatio
66

77
Add this flag when executing on Aliyun RDS.
88

9+
### analyze-ghost-table-before-cutover
10+
11+
Run an explicit `ANALYZE TABLE` on the ghost table immediately before cut-over — after a postponed cut-over is released, before the atomic swap takes its locks — and abort the migration if the `ANALYZE` fails, rather than swap in a table with stale InnoDB statistics. Without it, the freshly swapped table can briefly serve traffic with a near-zero row estimate, which the optimizer may cost as a free full scan on hot query paths. This is the same rationale as issue #1418 / PR #1419; this flag is a corrected variant: the `ANALYZE` runs after the postpone gate releases (so a postponed cut-over still gets fresh statistics) and a failed `ANALYZE` aborts the migration instead of being ignored. Opt-in; intended for small, non-partitioned tables that are non-empty at copy (`ANALYZE TABLE` cost grows with partition count, and its statement replicates to replicas).
12+
913
### allow-zero-in-date
1014

1115
Allows the user to make schema changes that include a zero date or zero in date (e.g. adding a `datetime default '0000-00-00 00:00:00'` column), even if global `sql_mode` on MySQL has `NO_ZERO_IN_DATE,NO_ZERO_DATE`.

go/base/context.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,12 @@ type MigrationContext struct {
267267
TriggerSuffix string
268268
Triggers []mysql.Trigger
269269

270+
// AnalyzeGhostTableBeforeCutOver makes cutOver() run ANALYZE TABLE on the ghost table
271+
// immediately before the atomic swap, and abort the migration if the ANALYZE errors,
272+
// rather than swap in a table with stale statistics. Opt-in: the operator enables it
273+
// only for eligible tables — small, non-partitioned, non-empty at copy.
274+
AnalyzeGhostTableBeforeCutOver bool
275+
270276
recentBinlogCoordinates mysql.BinlogCoordinates
271277

272278
BinlogSyncerMaxReconnectAttempts int

go/cmd/gh-ost/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ func main() {
167167
flag.BoolVar(&migrationContext.Resume, "resume", false, "Attempt to resume migration from checkpoint")
168168
flag.BoolVar(&migrationContext.Revert, "revert", false, "Attempt to revert completed migration")
169169
flag.StringVar(&migrationContext.OldTableName, "old-table", "", "The name of the old table when using --revert, e.g. '_mytable_del'")
170+
flag.BoolVar(&migrationContext.AnalyzeGhostTableBeforeCutOver, "analyze-ghost-table-before-cutover", false, "Run ANALYZE TABLE on the ghost table immediately before cut-over; abort the migration (fatal) if the ANALYZE fails, rather than swapping in a table with stale statistics. Opt-in; intended for small, non-partitioned tables that are non-empty at copy. Default false")
170171

171172
maxLoad := flag.String("max-load", "", "Comma delimited status-name=threshold. e.g: 'Threads_running=100,Threads_connected=500'. When status exceeds threshold, app throttles writes")
172173
criticalLoad := flag.String("critical-load", "", "Comma delimited status-name=threshold, same format as --max-load. When status exceeds threshold, app panics and quits")

go/logic/applier.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,78 @@ func (apl *Applier) CreateGhostTable() error {
526526
return err
527527
}
528528

529+
// analyzeTableResultRow is the subset of an `ANALYZE TABLE` result-set row that gh-ost inspects
530+
// to decide whether the analyze succeeded.
531+
type analyzeTableResultRow struct {
532+
msgType string
533+
msgText string
534+
}
535+
536+
// classifyAnalyzeTableResult decides whether an `ANALYZE TABLE` succeeded from its result rows.
537+
// ANALYZE TABLE reports table-level failures (missing table, storage-engine errors) as
538+
// Msg_type "Error" rows while still succeeding at the protocol level, so the statement error
539+
// alone cannot be trusted — the rows must be inspected. Cut-over is refused unless the result
540+
// carries a status-OK row and no error row (fail-closed: an empty or status-less result also
541+
// refuses). tableName is used only to build the error message.
542+
func classifyAnalyzeTableResult(tableName string, rows []analyzeTableResultRow) error {
543+
sawStatusOk := false
544+
var resultErrors []string
545+
for _, row := range rows {
546+
msgType := strings.ToLower(row.msgType)
547+
if msgType == "error" {
548+
resultErrors = append(resultErrors, row.msgText)
549+
}
550+
if msgType == "status" && strings.EqualFold(row.msgText, "OK") {
551+
sawStatusOk = true
552+
}
553+
}
554+
if len(resultErrors) > 0 || !sawStatusOk {
555+
return fmt.Errorf("ANALYZE TABLE on ghost %s did not report status OK; refusing cut-over: %s", sql.EscapeName(tableName), strings.Join(resultErrors, "; "))
556+
}
557+
return nil
558+
}
559+
560+
// AnalyzeGhostTable runs an explicit ANALYZE TABLE on the ghost table, forcing a
561+
// synchronous InnoDB persistent-statistics recompute before cut-over. Without it the
562+
// freshly swapped table can serve traffic with a near-zero row estimate, which the
563+
// optimizer costs as a free full scan — the failure mode motivating upstream #1419.
564+
// No row-count assertion follows the ANALYZE: on a freshly built, compact ghost a
565+
// successful ANALYZE yields correct statistics by construction, and a row count
566+
// cannot prove plan safety — plan checks belong to the orchestrating layer, which
567+
// knows the table's context. The caller must treat a returned error as fatal,
568+
// not retriable.
569+
func (apl *Applier) AnalyzeGhostTable() error {
570+
query := fmt.Sprintf(`analyze /* gh-ost */ table %s.%s`,
571+
sql.EscapeName(apl.migrationContext.DatabaseName),
572+
sql.EscapeName(apl.migrationContext.GetGhostTableName()),
573+
)
574+
apl.migrationContext.Log.Infof("Running ANALYZE TABLE on ghost table %s.%s before cut-over",
575+
sql.EscapeName(apl.migrationContext.DatabaseName),
576+
sql.EscapeName(apl.migrationContext.GetGhostTableName()),
577+
)
578+
analyzeStartTime := time.Now()
579+
var rows []analyzeTableResultRow
580+
err := sqlutils.QueryRowsMap(apl.db, query, func(rowMap sqlutils.RowMap) error {
581+
rows = append(rows, analyzeTableResultRow{
582+
msgType: rowMap.GetString("Msg_type"),
583+
msgText: rowMap.GetString("Msg_text"),
584+
})
585+
return nil
586+
})
587+
if err != nil {
588+
return fmt.Errorf("ANALYZE TABLE on ghost %s failed; refusing cut-over: %w", sql.EscapeName(apl.migrationContext.GetGhostTableName()), err)
589+
}
590+
if err := classifyAnalyzeTableResult(apl.migrationContext.GetGhostTableName(), rows); err != nil {
591+
return err
592+
}
593+
apl.migrationContext.Log.Infof("ANALYZE TABLE on ghost table %s.%s completed in %dms",
594+
sql.EscapeName(apl.migrationContext.DatabaseName),
595+
sql.EscapeName(apl.migrationContext.GetGhostTableName()),
596+
time.Since(analyzeStartTime).Milliseconds(),
597+
)
598+
return nil
599+
}
600+
529601
// AlterGhost applies `alter` statement on ghost table
530602
func (apl *Applier) AlterGhost() error {
531603
query := fmt.Sprintf(`alter /* gh-ost */ table %s.%s %s`,

go/logic/applier_test.go

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,73 @@ func TestRetryOnLockWaitTimeout(t *testing.T) {
266266
})
267267
}
268268

269+
func TestClassifyAnalyzeTableResult(t *testing.T) {
270+
tests := []struct {
271+
name string
272+
rows []analyzeTableResultRow
273+
// errContains is the substring the refusal error must carry; empty means expect success.
274+
// Asserting the substring proves which branch refused and that the underlying cause
275+
// propagates to the operator, rather than accepting any error.
276+
errContains string
277+
}{
278+
{
279+
name: "status OK passes",
280+
rows: []analyzeTableResultRow{{msgType: "status", msgText: "OK"}},
281+
},
282+
{
283+
// gh-ost lowercases Msg_type and folds Msg_text, so a differently-cased OK still passes.
284+
name: "status OK is matched case-insensitively",
285+
rows: []analyzeTableResultRow{{msgType: "Status", msgText: "ok"}},
286+
},
287+
{
288+
// The fail-open the PR fixes: MySQL reports a table-level failure as an Error row while
289+
// the statement succeeds at the protocol level. An error row must refuse cut-over.
290+
name: "error row refuses cut-over",
291+
rows: []analyzeTableResultRow{{msgType: "Error", msgText: "Table 'test._testing_gho' doesn't exist"}},
292+
errContains: "doesn't exist",
293+
},
294+
{
295+
// An error row must refuse even when a status-OK row is also present — this is the case
296+
// that exercises the error-row clause independently of the missing-status-OK clause.
297+
name: "error row refuses even alongside status OK",
298+
rows: []analyzeTableResultRow{
299+
{msgType: "Error", msgText: "Incorrect key file for table"},
300+
{msgType: "status", msgText: "OK"},
301+
},
302+
errContains: "Incorrect key file",
303+
},
304+
{
305+
// All rows are scanned: a status-OK row must not short-circuit a later error row.
306+
name: "status OK before a later error row still refuses",
307+
rows: []analyzeTableResultRow{
308+
{msgType: "status", msgText: "OK"},
309+
{msgType: "Error", msgText: "late corruption error"},
310+
},
311+
errContains: "late corruption error",
312+
},
313+
{
314+
name: "status row that is not OK refuses cut-over",
315+
rows: []analyzeTableResultRow{{msgType: "status", msgText: "Operation failed"}},
316+
errContains: "did not report status OK",
317+
},
318+
{
319+
name: "empty result refuses cut-over (fail-closed)",
320+
rows: nil,
321+
errContains: "did not report status OK",
322+
},
323+
}
324+
for _, tc := range tests {
325+
t.Run(tc.name, func(t *testing.T) {
326+
err := classifyAnalyzeTableResult("_testing_gho", tc.rows)
327+
if tc.errContains == "" {
328+
require.NoError(t, err)
329+
} else {
330+
require.ErrorContains(t, err, tc.errContains)
331+
}
332+
})
333+
}
334+
}
335+
269336
type ApplierTestSuite struct {
270337
suite.Suite
271338

@@ -295,7 +362,7 @@ func (suite *ApplierTestSuite) SetupSuite() {
295362
suite.db = db
296363
}
297364

298-
func (suite *ApplierTestSuite) TeardownSuite() {
365+
func (suite *ApplierTestSuite) TearDownSuite() {
299366
suite.Assert().NoError(suite.db.Close())
300367
suite.Assert().NoError(testcontainers.TerminateContainer(suite.mysqlContainer))
301368
}
@@ -627,6 +694,48 @@ func (suite *ApplierTestSuite) TestCreateGhostTable() {
627694
suite.Require().Equal("CREATE TABLE `_testing_gho` (\n `id` int DEFAULT NULL,\n `item_id` int DEFAULT NULL\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", createDDL)
628695
}
629696

697+
func (suite *ApplierTestSuite) TestAnalyzeGhostTable() {
698+
ctx := context.Background()
699+
700+
_, err := suite.db.ExecContext(ctx, "CREATE TABLE test.testing (id INT, item_id INT);")
701+
suite.Require().NoError(err)
702+
703+
connectionConfig, err := getTestConnectionConfig(ctx, suite.mysqlContainer)
704+
suite.Require().NoError(err)
705+
706+
migrationContext := base.NewMigrationContext()
707+
migrationContext.ApplierConnectionConfig = connectionConfig
708+
migrationContext.DatabaseName = "test"
709+
migrationContext.SkipPortValidation = true
710+
migrationContext.OriginalTableName = "testing"
711+
migrationContext.SetConnectionConfig("innodb")
712+
migrationContext.InitiallyDropGhostTable = true
713+
714+
applier := NewApplier(migrationContext)
715+
defer applier.Teardown()
716+
717+
suite.Require().NoError(applier.InitDBConnections())
718+
suite.Require().NoError(applier.CreateGhostTable())
719+
720+
// Happy path: ANALYZE on the freshly-created ghost table succeeds.
721+
suite.Require().NoError(applier.AnalyzeGhostTable())
722+
723+
// Fail-closed regression: if the ghost table is gone at cut-over time, MySQL reports the missing
724+
// table as a Msg_type=Error result row while the statement itself succeeds at the protocol
725+
// level. A naive statement-error check would fail open and swap in a broken table; the
726+
// row-inspection guard must refuse instead. ErrorContains pins the refusal to that guard rather
727+
// than to any incidental error.
728+
_, err = suite.db.ExecContext(ctx, "DROP TABLE test._testing_gho")
729+
suite.Require().NoError(err)
730+
suite.Require().ErrorContains(applier.AnalyzeGhostTable(), "did not report status OK")
731+
732+
// Statement-error path: a failure at the protocol level (here, a closed connection) rather than
733+
// a result row is refused through the distinct statement-error branch. This closes the applier's
734+
// connections, so the deferred Teardown above becomes a harmless second close.
735+
applier.Teardown()
736+
suite.Require().ErrorContains(applier.AnalyzeGhostTable(), "failed; refusing cut-over")
737+
}
738+
630739
func (suite *ApplierTestSuite) TestPanicOnWarningsInApplyIterationInsertQuerySucceedsWithUniqueKeyWarningInsertedByDMLEvent() {
631740
ctx := context.Background()
632741

go/logic/migrator.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,20 @@ func (mgtr *Migrator) handleCutOverResult(cutOverError error) (err error) {
854854
return nil
855855
}
856856

857+
// analyzeGhostTableBeforeCutOver runs the opt-in pre-cut-over ANALYZE TABLE via the
858+
// injected analyze operation, gated on --analyze-ghost-table-before-cutover. It
859+
// returns nil (proceed) when the flag is off, and otherwise returns whatever analyze
860+
// returns; the caller must treat a non-nil error as fatal and abort cut-over before
861+
// any source lock, replica stop, or cut-over retry. analyze is a parameter so the
862+
// gating and fail-closed contract is testable without a live applier (whose ANALYZE
863+
// requires a real MySQL) or the process-exiting Log.Fatale path.
864+
func (mgtr *Migrator) analyzeGhostTableBeforeCutOver(analyze func() error) error {
865+
if !mgtr.migrationContext.AnalyzeGhostTableBeforeCutOver {
866+
return nil
867+
}
868+
return analyze()
869+
}
870+
857871
// cutOver performs the final step of migration, based on migration
858872
// type (on replica? atomic? safe?)
859873
func (mgtr *Migrator) cutOver() (err error) {
@@ -904,6 +918,18 @@ func (mgtr *Migrator) cutOver() (err error) {
904918
mgtr.migrationContext.MarkPointOfInterest()
905919
mgtr.migrationContext.Log.Debugf("checking for cut-over postpone: complete")
906920

921+
// Force a synchronous ANALYZE on the ghost table here — after the postpone gate
922+
// releases, before atomicCutOver() takes the source write lock, and before
923+
// --test-on-replica stops replication (so a failure cannot strand a stopped
924+
// replica). A failure must be fatal, not retried: a plain `return err` re-runs
925+
// cutOver() — and the ANALYZE — up to --default-retries, and a PanicAbort send
926+
// races the retrier. Log.Fatale exits synchronously without ever locking the
927+
// source. Gating and the injectable analyze op live in
928+
// analyzeGhostTableBeforeCutOver so this ordering contract is unit-testable.
929+
if err := mgtr.analyzeGhostTableBeforeCutOver(mgtr.applier.AnalyzeGhostTable); err != nil {
930+
return mgtr.migrationContext.Log.Fatale(err)
931+
}
932+
907933
if mgtr.migrationContext.TestOnReplica {
908934
// With `--test-on-replica` we stop replication thread, and then proceed to use
909935
// the same cut-over phase as the master would use. That means we take locks

go/logic/migrator_test.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,57 @@ func TestCutOverOperationWithMetricsAbort(t *testing.T) {
496496
assert.Equal(t, [][]string{{"outcome:" + metrics.CutOverOutcomeAbort}}, spy.histogramTags)
497497
}
498498

499+
// TestAnalyzeGhostTableBeforeCutOver covers the cut-over orchestration contract for
500+
// the opt-in pre-cut-over ANALYZE: the --analyze-ghost-table-before-cutover flag
501+
// gates the call, and a failed ANALYZE surfaces as an error so cutOver() aborts
502+
// (via Log.Fatale) before it reaches replica-stop or the cut-over locking/retry
503+
// switch. The applier's ANALYZE execution and result parsing are covered separately
504+
// by ApplierTestSuite.TestAnalyzeGhostTable against a real MySQL.
505+
func TestAnalyzeGhostTableBeforeCutOver(t *testing.T) {
506+
t.Run("flag off: ANALYZE is not invoked, cut-over proceeds", func(t *testing.T) {
507+
migrator := NewMigrator(base.NewMigrationContext(), "test")
508+
migrator.migrationContext.AnalyzeGhostTableBeforeCutOver = false
509+
510+
invoked := false
511+
err := migrator.analyzeGhostTableBeforeCutOver(func() error {
512+
invoked = true
513+
return nil
514+
})
515+
516+
require.NoError(t, err)
517+
assert.False(t, invoked, "ANALYZE must not run when the flag is off")
518+
})
519+
520+
t.Run("flag on, ANALYZE succeeds: invoked once, cut-over proceeds", func(t *testing.T) {
521+
migrator := NewMigrator(base.NewMigrationContext(), "test")
522+
migrator.migrationContext.AnalyzeGhostTableBeforeCutOver = true
523+
524+
calls := 0
525+
err := migrator.analyzeGhostTableBeforeCutOver(func() error {
526+
calls++
527+
return nil
528+
})
529+
530+
require.NoError(t, err)
531+
assert.Equal(t, 1, calls)
532+
})
533+
534+
t.Run("flag on, ANALYZE fails: error propagates so cut-over aborts fail-closed", func(t *testing.T) {
535+
migrator := NewMigrator(base.NewMigrationContext(), "test")
536+
migrator.migrationContext.AnalyzeGhostTableBeforeCutOver = true
537+
538+
analyzeErr := errors.New("ANALYZE TABLE on ghost failed; refusing cut-over")
539+
calls := 0
540+
err := migrator.analyzeGhostTableBeforeCutOver(func() error {
541+
calls++
542+
return analyzeErr
543+
})
544+
545+
require.ErrorIs(t, err, analyzeErr)
546+
assert.Equal(t, 1, calls, "a failed ANALYZE must not be retried inside the seam")
547+
})
548+
}
549+
499550
func TestReportStatusEmitsProgressGaugesEveryTick(t *testing.T) {
500551
spy := &progressGaugeSpy{}
501552
ctx := base.NewMigrationContext()
@@ -647,7 +698,7 @@ func (suite *MigratorTestSuite) SetupSuite() {
647698
suite.db = db
648699
}
649700

650-
func (suite *MigratorTestSuite) TeardownSuite() {
701+
func (suite *MigratorTestSuite) TearDownSuite() {
651702
suite.Assert().NoError(suite.db.Close())
652703
suite.Assert().NoError(testcontainers.TerminateContainer(suite.mysqlContainer))
653704
}

go/logic/streamer_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func (suite *EventsStreamerTestSuite) SetupSuite() {
4242
suite.db = db
4343
}
4444

45-
func (suite *EventsStreamerTestSuite) TeardownSuite() {
45+
func (suite *EventsStreamerTestSuite) TearDownSuite() {
4646
suite.Assert().NoError(suite.db.Close())
4747
suite.Assert().NoError(testcontainers.TerminateContainer(suite.mysqlContainer))
4848
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
drop table if exists gh_ost_test;
2+
create table gh_ost_test (
3+
id int auto_increment,
4+
i int not null,
5+
color varchar(32),
6+
primary key(id)
7+
) auto_increment=1;
8+
9+
drop event if exists gh_ost_test;
10+
delimiter ;;
11+
create event gh_ost_test
12+
on schedule every 1 second
13+
starts current_timestamp
14+
ends current_timestamp + interval 60 second
15+
on completion not preserve
16+
enable
17+
do
18+
begin
19+
insert into gh_ost_test values (null, 11, 'red');
20+
insert into gh_ost_test values (null, 13, 'green');
21+
update gh_ost_test set color = 'gray' where i = 11;
22+
end ;;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--analyze-ghost-table-before-cutover

0 commit comments

Comments
 (0)