Skip to content

Commit b5353ac

Browse files
committed
Attempt instant DDL early and add --force-instant-ddl flag
Move the --attempt-instant-ddl check to run before ghost table and binlog streaming setup. If instant DDL succeeds, the migration completes immediately without creating ghost tables, changelog tables, or starting binlog streaming. Add --force-instant-ddl flag that aborts the migration if ALGORITHM=INSTANT is not supported, preventing accidental multi-hour row-copy migrations when the intent was an instant metadata change.
1 parent 67cc636 commit b5353ac

File tree

4 files changed

+146
-21
lines changed

4 files changed

+146
-21
lines changed

go/base/context.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ type MigrationContext struct {
104104
GoogleCloudPlatform bool
105105
AzureMySQL bool
106106
AttemptInstantDDL bool
107+
ForceInstantDDL bool
107108
Resume bool
108109
Revert bool
109110
OldTableName string

go/cmd/gh-ost/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func main() {
7070
flag.StringVar(&migrationContext.OriginalTableName, "table", "", "table name (mandatory)")
7171
flag.StringVar(&migrationContext.AlterStatement, "alter", "", "alter statement (mandatory)")
7272
flag.BoolVar(&migrationContext.AttemptInstantDDL, "attempt-instant-ddl", false, "Attempt to use instant DDL for this migration first")
73+
flag.BoolVar(&migrationContext.ForceInstantDDL, "force-instant-ddl", false, "Require instant DDL; abort if the operation cannot be completed instantly (do not fall back to regular migration)")
7374
storageEngine := flag.String("storage-engine", "innodb", "Specify table storage engine (default: 'innodb'). When 'rocksdb': the session transaction isolation level is changed from REPEATABLE_READ to READ_COMMITTED.")
7475

7576
flag.BoolVar(&migrationContext.CountTableRows, "exact-rowcount", false, "actually count table rows as opposed to estimate them (results in more accurate progress estimation)")
@@ -230,6 +231,9 @@ func main() {
230231
if migrationContext.AttemptInstantDDL {
231232
log.Warning("--attempt-instant-ddl was provided with --revert, it will be ignored")
232233
}
234+
if migrationContext.ForceInstantDDL {
235+
log.Warning("--force-instant-ddl was provided with --revert, it will be ignored")
236+
}
233237
if migrationContext.IncludeTriggers {
234238
log.Warning("--include-triggers was provided with --revert, it will be ignored")
235239
}
@@ -270,6 +274,9 @@ func main() {
270274
if migrationContext.SwitchToRowBinlogFormat && migrationContext.AssumeRBR {
271275
migrationContext.Log.Fatal("--switch-to-rbr and --assume-rbr are mutually exclusive")
272276
}
277+
if migrationContext.ForceInstantDDL {
278+
migrationContext.AttemptInstantDDL = true
279+
}
273280
if migrationContext.TestOnReplicaSkipReplicaStop {
274281
if !migrationContext.TestOnReplica {
275282
migrationContext.Log.Fatal("--test-on-replica-skip-replica-stop requires --test-on-replica to be enabled")

go/logic/migrator.go

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,25 @@ func (this *Migrator) Migrate() (err error) {
430430
if err := this.checkAbort(); err != nil {
431431
return err
432432
}
433+
434+
// In MySQL 8.0 (and possibly earlier) some DDL statements can be applied instantly.
435+
// Attempt this EARLY, before creating ghost tables or starting binlog streaming,
436+
// to avoid unnecessary overhead for large tables when instant DDL is possible.
437+
// Skip during resume (the DDL may have already been applied) and noop mode.
438+
if this.migrationContext.AttemptInstantDDL && !this.migrationContext.Resume {
439+
if this.migrationContext.Noop {
440+
this.migrationContext.Log.Debugf("Noop operation; not really attempting instant DDL")
441+
} else {
442+
if err := this.attemptInstantDDLEarly(); err == nil {
443+
return nil
444+
} else if this.migrationContext.ForceInstantDDL {
445+
return fmt.Errorf("--force-instant-ddl enabled but ALGORITHM=INSTANT is not supported for this operation: %s", err)
446+
} else {
447+
this.migrationContext.Log.Infof("ALGORITHM=INSTANT not supported for this operation, proceeding with original algorithm")
448+
}
449+
}
450+
}
451+
433452
// If we are resuming, we will initiateStreaming later when we know
434453
// the binlog coordinates to resume streaming from.
435454
// If not resuming, the streamer must be initiated before the applier,
@@ -451,27 +470,6 @@ func (this *Migrator) Migrate() (err error) {
451470
if err := this.createFlagFiles(); err != nil {
452471
return err
453472
}
454-
// In MySQL 8.0 (and possibly earlier) some DDL statements can be applied instantly.
455-
// Attempt to do this if AttemptInstantDDL is set.
456-
if this.migrationContext.AttemptInstantDDL {
457-
if this.migrationContext.Noop {
458-
this.migrationContext.Log.Debugf("Noop operation; not really attempting instant DDL")
459-
} else {
460-
this.migrationContext.Log.Infof("Attempting to execute alter with ALGORITHM=INSTANT")
461-
if err := this.applier.AttemptInstantDDL(); err == nil {
462-
if err := this.finalCleanup(); err != nil {
463-
return nil
464-
}
465-
if err := this.hooksExecutor.onSuccess(); err != nil {
466-
return err
467-
}
468-
this.migrationContext.Log.Infof("Success! table %s.%s migrated instantly", sql.EscapeName(this.migrationContext.DatabaseName), sql.EscapeName(this.migrationContext.OriginalTableName))
469-
return nil
470-
} else {
471-
this.migrationContext.Log.Infof("ALGORITHM=INSTANT not supported for this operation, proceeding with original algorithm: %s", err)
472-
}
473-
}
474-
}
475473

476474
initialLag, _ := this.inspector.getReplicationLag()
477475
if !this.migrationContext.Resume {
@@ -1030,6 +1028,55 @@ func (this *Migrator) initiateServer() (err error) {
10301028
return nil
10311029
}
10321030

1031+
// attemptInstantDDLEarly attempts to execute the ALTER with ALGORITHM=INSTANT
1032+
// before any ghost table or binlog streaming setup. This avoids the overhead of
1033+
// creating ghost tables, changelog tables, and streaming binlog events for
1034+
// operations that MySQL 8.0+ can execute as instant metadata-only changes.
1035+
// If instant DDL succeeds, the migration is complete. If it fails, the caller
1036+
// should proceed with the normal migration flow.
1037+
func (this *Migrator) attemptInstantDDLEarly() error {
1038+
this.migrationContext.Log.Infof("Attempting to execute alter with ALGORITHM=INSTANT before full migration setup")
1039+
1040+
// Open a temporary connection to the master for the instant DDL attempt.
1041+
// This avoids initializing the full Applier (ghost table, changelog, etc.).
1042+
connConfig := this.migrationContext.ApplierConnectionConfig
1043+
uri := connConfig.GetDBUri(this.migrationContext.DatabaseName)
1044+
db, _, err := mysql.GetDB(this.migrationContext.Uuid, uri)
1045+
if err != nil {
1046+
this.migrationContext.Log.Infof("Could not open connection for instant DDL attempt: %s", err)
1047+
return err
1048+
}
1049+
1050+
tableLockTimeoutSeconds := this.migrationContext.CutOverLockTimeoutSeconds * 2
1051+
this.migrationContext.Log.Infof("Setting LOCK timeout as %d seconds for instant DDL attempt", tableLockTimeoutSeconds)
1052+
lockTimeoutQuery := fmt.Sprintf(`set /* gh-ost */ session lock_wait_timeout:=%d`, tableLockTimeoutSeconds)
1053+
if _, err := db.Exec(lockTimeoutQuery); err != nil {
1054+
this.migrationContext.Log.Infof("Could not set lock timeout for instant DDL: %s", err)
1055+
return err
1056+
}
1057+
1058+
query := fmt.Sprintf(`ALTER /* gh-ost */ TABLE %s.%s %s, ALGORITHM=INSTANT`,
1059+
sql.EscapeName(this.migrationContext.DatabaseName),
1060+
sql.EscapeName(this.migrationContext.OriginalTableName),
1061+
this.migrationContext.AlterStatementOptions,
1062+
)
1063+
this.migrationContext.Log.Infof("INSTANT DDL query: %s", query)
1064+
1065+
if _, err := db.Exec(query); err != nil {
1066+
this.migrationContext.Log.Infof("ALGORITHM=INSTANT is not supported for this operation, proceeding with regular algorithm: %s", err)
1067+
return err
1068+
}
1069+
1070+
if err := this.hooksExecutor.onSuccess(); err != nil {
1071+
return err
1072+
}
1073+
this.migrationContext.Log.Infof("Successfully executed instant DDL on %s.%s (no ghost table was needed)",
1074+
sql.EscapeName(this.migrationContext.DatabaseName),
1075+
sql.EscapeName(this.migrationContext.OriginalTableName),
1076+
)
1077+
return nil
1078+
}
1079+
10331080
// initiateInspector connects, validates and inspects the "inspector" server.
10341081
// The "inspector" server is typically a replica; it is where we issue some
10351082
// queries such as:

go/logic/migrator_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,76 @@ func (suite *MigratorTestSuite) TestMigrateEmpty() {
386386
suite.Require().Equal("_testing_del", tableName)
387387
}
388388

389+
func (suite *MigratorTestSuite) TestMigrateInstantDDLEarly() {
390+
ctx := context.Background()
391+
392+
_, err := suite.db.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s (id INT PRIMARY KEY, name VARCHAR(64))", getTestTableName()))
393+
suite.Require().NoError(err)
394+
395+
connectionConfig, err := getTestConnectionConfig(ctx, suite.mysqlContainer)
396+
suite.Require().NoError(err)
397+
398+
migrationContext := newTestMigrationContext()
399+
migrationContext.ApplierConnectionConfig = connectionConfig
400+
migrationContext.InspectorConnectionConfig = connectionConfig
401+
migrationContext.SetConnectionConfig("innodb")
402+
migrationContext.AttemptInstantDDL = true
403+
404+
// Adding a column is an instant DDL operation in MySQL 8.0+
405+
migrationContext.AlterStatementOptions = "ADD COLUMN instant_col VARCHAR(255)"
406+
407+
migrator := NewMigrator(migrationContext, "0.0.0")
408+
409+
err = migrator.Migrate()
410+
suite.Require().NoError(err)
411+
412+
// Verify the new column was added via instant DDL
413+
var tableName, createTableSQL string
414+
//nolint:execinquery
415+
err = suite.db.QueryRow("SHOW CREATE TABLE "+getTestTableName()).Scan(&tableName, &createTableSQL)
416+
suite.Require().NoError(err)
417+
418+
suite.Require().Contains(createTableSQL, "instant_col")
419+
420+
// Verify that NO ghost table was created (instant DDL should skip ghost table creation)
421+
//nolint:execinquery
422+
err = suite.db.QueryRow("SHOW TABLES IN test LIKE '_testing_gho'").Scan(&tableName)
423+
suite.Require().Error(err, "ghost table should not exist after instant DDL")
424+
suite.Require().Equal(gosql.ErrNoRows, err)
425+
426+
// Verify that NO changelog table was created
427+
//nolint:execinquery
428+
err = suite.db.QueryRow("SHOW TABLES IN test LIKE '_testing_ghc'").Scan(&tableName)
429+
suite.Require().Error(err, "changelog table should not exist after instant DDL")
430+
suite.Require().Equal(gosql.ErrNoRows, err)
431+
}
432+
433+
func (suite *MigratorTestSuite) TestForceInstantDDLFailsForNonInstantOp() {
434+
ctx := context.Background()
435+
436+
_, err := suite.db.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s (id INT PRIMARY KEY, name VARCHAR(64))", getTestTableName()))
437+
suite.Require().NoError(err)
438+
439+
connectionConfig, err := getTestConnectionConfig(ctx, suite.mysqlContainer)
440+
suite.Require().NoError(err)
441+
442+
migrationContext := newTestMigrationContext()
443+
migrationContext.ApplierConnectionConfig = connectionConfig
444+
migrationContext.InspectorConnectionConfig = connectionConfig
445+
migrationContext.SetConnectionConfig("innodb")
446+
migrationContext.AttemptInstantDDL = true
447+
migrationContext.ForceInstantDDL = true
448+
449+
// Changing a column type from VARCHAR to INT is NOT an instant DDL operation
450+
migrationContext.AlterStatementOptions = "MODIFY COLUMN name INT"
451+
452+
migrator := NewMigrator(migrationContext, "0.0.0")
453+
454+
err = migrator.Migrate()
455+
suite.Require().Error(err)
456+
suite.Require().Contains(err.Error(), "--force-instant-ddl")
457+
}
458+
389459
func (suite *MigratorTestSuite) TestRetryBatchCopyWithHooks() {
390460
ctx := context.Background()
391461

0 commit comments

Comments
 (0)