Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions doc/command-line-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ By default, `gh-ost` would like you to connect to a replica, from where it figur

If, for some reason, you do not wish `gh-ost` to connect to a replica, you may connect it directly to the master and approve this via `--allow-on-master`.

### allow-setup-metadata-lock-instruments

`--allow-setup-metadata-lock-instruments` allows gh-ost to enable the [`metadata_locks`](https://dev.mysql.com/doc/refman/8.0/en/performance-schema-metadata-locks-table.html) table in `performance_schema`, if it is not already enabled. This is used for a safety check before cut-over.
See also: [`skip-metadata-lock-check`](#skip-metadata-lock-check)

### approve-renamed-columns

When your migration issues a column rename (`change column old_name new_name ...`) `gh-ost` analyzes the statement to try and associate the old column name with new column name. Otherwise, the new structure may also look like some column was dropped and another was added.
Expand Down Expand Up @@ -247,6 +252,13 @@ Defaults to an auto-determined and advertised upon startup file. Defines Unix so

By default `gh-ost` verifies no foreign keys exist on the migrated table. On servers with large number of tables this check can take a long time. If you're absolutely certain no foreign keys exist (table does not reference other table nor is referenced by other tables) and wish to save the check time, provide with `--skip-foreign-key-checks`.

### skip-metadata-lock-check

By default `gh-ost` performs a check before the cut-over to ensure the rename session holds the exclusive metadata lock on the table. In case `performance_schema.metadata_locks` cannot be enabled on your setup, this check can be skipped with `--skip-metadata-lock-check`.
:warning: Disabling this check involves the small chance of data loss in case a session accesses the ghost table during cut-over. See https://github.com/github/gh-ost/pull/1536 for details.

See also: [`allow-setup-metadata-lock-instruments`](#allow-setup-metadata-lock-instruments)

### skip-strict-mode

By default `gh-ost` enforces STRICT_ALL_TABLES sql_mode as a safety measure. In some cases this changes the behaviour of other modes (namely ERROR_FOR_DIVISION_BY_ZERO, NO_ZERO_DATE, and NO_ZERO_IN_DATE) which may lead to errors during migration. Use `--skip-strict-mode` to explicitly tell `gh-ost` not to enforce this. **Danger** This may have some unexpected disastrous side effects.
Expand Down
3 changes: 3 additions & 0 deletions doc/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The full list of supported hooks is best found in code: [hooks.go](https://githu
- `gh-ost-on-before-cut-over`
- `gh-ost-on-success`
- `gh-ost-on-failure`
- `gh-ost-on-batch-copy-retry`

### Context

Expand Down Expand Up @@ -76,11 +77,13 @@ The following variables are available on all hooks:
- `GH_OST_HOOKS_HINT_OWNER` - copy of `--hooks-hint-owner` value
- `GH_OST_HOOKS_HINT_TOKEN` - copy of `--hooks-hint-token` value
- `GH_OST_DRY_RUN` - whether or not the `gh-ost` run is a dry run
- `GH_OST_REVERT` - whether or not `gh-ost` is running in revert mode

The following variable are available on particular hooks:

- `GH_OST_COMMAND` is only available in `gh-ost-on-interactive-command`
- `GH_OST_STATUS` is only available in `gh-ost-on-status`
- `GH_OST_LAST_BATCH_COPY_ERROR` is only available in `gh-ost-on-batch-copy-retry`

### Examples

Expand Down
1 change: 1 addition & 0 deletions doc/resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- The first `gh-ost` process was invoked with `--checkpoint`
- The first `gh-ost` process had at least one successful checkpoint
- The binlogs from the last checkpoint's binlog coordinates still exist on the replica gh-ost is inspecting (specified by `--host`)
- The checkpoint table (name ends with `_ghk`) still exists

To resume, invoke `gh-ost` again with the same arguments with the `--resume` flag.

Expand Down
56 changes: 56 additions & 0 deletions doc/revert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Reverting Migrations

`gh-ost` can attempt to revert a previously completed migration if the follow conditions are met:
- The first `gh-ost` process was invoked with `--checkpoint`
- The checkpoint table (name ends with `_ghk`) still exists
- The binlogs from the time of the migration's cut-over still exist on the replica gh-ost is inspecting (specified by `--host`)

To revert, find the name of the "old" table from the original migration e.g. `_mytable_del`. Then invoke `gh-ost` with the same arguments and the flags `--revert` and `--old-table="_mytable_del"`.
gh-ost will read the binlog coordinates of the original cut-over from the checkpoint table and bring the old table up to date. Then it performs another cut-over to complete the reversion.
Note that the checkpoint table (name ends with _ghk) will not be automatically dropped unless `--ok-to-drop-table` is provided.

> [!WARNING]
> It is recommended use `--checkpoint` with `--gtid` enabled so that checkpoint binlog coordinates store GTID sets rather than file positions. In that case, `gh-ost` can revert using a different replica than it originally attached to.
### ❗ Note ❗
Reverting is roughly equivalent to applying the "reverse" migration. _Before attempting to revert you should determine if the reverse migration is possible and does not involve any unacceptable data loss._

For example: if the original migration drops a `NOT NULL` column that has no `DEFAULT` then the reverse migration adds the column. In this case, the reverse migration is impossible if rows were added after the original cut-over and the revert will fail.
Another example: if the original migration modifies a `VARCHAR(32)` column to `VARCHAR(64)`, the reverse migration truncates the `VARCHAR(64)` column to `VARCHAR(32)`. If values were inserted with length > 32 after the cut-over then the revert will fail.


## Example
The migration starts with a `gh-ost` invocation such as:
```shell
gh-ost \
--chunk-size=100 \
--host=replica1.company.com \
--database="mydb" \
--table="mytable" \
--alter="drop key idx1"
--gtid \
--checkpoint \
--checkpoint-seconds=60 \
--execute
```

In this example `gh-ost` writes a cut-over checkpoint to `_mytable_ghk` after the cut-over is successful. The original table is renamed to `_mytable_del`.

Suppose that dropping the index causes problems, the migration can be revert with:
```shell
# revert migration
gh-ost \
--chunk-size=100 \
--host=replica1.company.com \
--database="mydb" \
--table="mytable" \
--old-table="_mytable_del"
--gtid \
--checkpoint \
--checkpoint-seconds=60 \
--revert \
--execute
```

gh-ost then reconnects at the binlog coordinates stored in the cut-over checkpoint and applies DMLs until the old table is up-to-date.
Note that the "reverse" migration is `ADD KEY idx(...)` so there is no potential data loss to consider in this case.
29 changes: 23 additions & 6 deletions go/base/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ type MigrationContext struct {
AzureMySQL bool
AttemptInstantDDL bool
Resume bool
Revert bool
OldTableName string

// SkipPortValidation allows skipping the port validation in `ValidateConnection`
// This is useful when connecting to a MySQL instance where the external port
Expand Down Expand Up @@ -254,6 +256,7 @@ type MigrationContext struct {

BinlogSyncerMaxReconnectAttempts int
AllowSetupMetadataLockInstruments bool
SkipMetadataLockCheck bool
IsOpenMetadataLockInstruments bool

Log Logger
Expand Down Expand Up @@ -348,6 +351,10 @@ func getSafeTableName(baseName string, suffix string) string {
// GetGhostTableName generates the name of ghost table, based on original table name
// or a given table name
func (this *MigrationContext) GetGhostTableName() string {
if this.Revert {
// When reverting the "ghost" table is the _del table from the original migration.
return this.OldTableName
}
if this.ForceTmpTableName != "" {
return getSafeTableName(this.ForceTmpTableName, "gho")
} else {
Expand All @@ -364,14 +371,18 @@ func (this *MigrationContext) GetOldTableName() string {
tableName = this.OriginalTableName
}

suffix := "del"
if this.Revert {
suffix = "rev_del"
}
if this.TimestampOldTable {
t := this.StartTime
timestamp := fmt.Sprintf("%d%02d%02d%02d%02d%02d",
t.Year(), t.Month(), t.Day(),
t.Hour(), t.Minute(), t.Second())
return getSafeTableName(tableName, fmt.Sprintf("%s_del", timestamp))
return getSafeTableName(tableName, fmt.Sprintf("%s_%s", timestamp, suffix))
}
return getSafeTableName(tableName, "del")
return getSafeTableName(tableName, suffix)
}

// GetChangelogTableName generates the name of changelog table, based on original table name
Expand Down Expand Up @@ -600,6 +611,13 @@ func (this *MigrationContext) GetIteration() int64 {
return atomic.LoadInt64(&this.Iteration)
}

func (this *MigrationContext) SetNextIterationRangeMinValues() {
this.MigrationIterationRangeMinValues = this.MigrationIterationRangeMaxValues
if this.MigrationIterationRangeMinValues == nil {
this.MigrationIterationRangeMinValues = this.MigrationRangeMinValues
}
}

func (this *MigrationContext) MarkPointOfInterest() int64 {
this.pointOfInterestTimeMutex.Lock()
defer this.pointOfInterestTimeMutex.Unlock()
Expand Down Expand Up @@ -959,9 +977,8 @@ func (this *MigrationContext) GetGhostTriggerName(triggerName string) string {
return triggerName + this.TriggerSuffix
}

// validateGhostTriggerLength check if the ghost trigger name length is not more than 64 characters
// ValidateGhostTriggerLengthBelowMaxLength checks if the given trigger name (already transformed
// by GetGhostTriggerName) does not exceed the maximum allowed length.
func (this *MigrationContext) ValidateGhostTriggerLengthBelowMaxLength(triggerName string) bool {
ghostTriggerName := this.GetGhostTriggerName(triggerName)

return utf8.RuneCountInString(ghostTriggerName) <= mysql.MaxTableNameLength
return utf8.RuneCountInString(triggerName) <= mysql.MaxTableNameLength
}
43 changes: 37 additions & 6 deletions go/base/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,38 +86,69 @@ func TestGetTriggerNames(t *testing.T) {
}

func TestValidateGhostTriggerLengthBelowMaxLength(t *testing.T) {
// Tests simulate the real call pattern: GetGhostTriggerName first, then validate the result.
{
// Short trigger name with suffix appended: well under 64 chars
context := NewMigrationContext()
context.TriggerSuffix = "_gho"
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength("my_trigger"))
ghostName := context.GetGhostTriggerName("my_trigger") // "my_trigger_gho" = 14 chars
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
}
{
// 64-char original + "_ghost" suffix = 70 chars → exceeds limit
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4))) // 64 characters + "_ghost"
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 4)) // 64 + 6 = 70
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
}
{
// 48-char original + "_ghost" suffix = 54 chars → valid
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 3))) // 48 characters + "_ghost"
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 3)) // 48 + 6 = 54
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
}
{
// RemoveTriggerSuffix: 64-char name ending in "_ghost" → suffix removed → 58 chars → valid
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
context.RemoveTriggerSuffix = true
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4))) // 64 characters + "_ghost" removed
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 4)) // suffix removed → 58
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
}
{
// RemoveTriggerSuffix: name doesn't end in suffix → suffix appended → 65 + 6 = 71 chars → exceeds
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
context.RemoveTriggerSuffix = true
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4)+"X")) // 65 characters + "_ghost" not removed
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 4) + "X") // no match, appended → 71
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
}
{
// RemoveTriggerSuffix: 70-char name ending in "_ghost" → suffix removed → 64 chars → exactly at limit → valid
context := NewMigrationContext()
context.TriggerSuffix = "_ghost"
context.RemoveTriggerSuffix = true
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(strings.Repeat("my_trigger_ghost", 4)+"_ghost")) // 70 characters + last "_ghost" removed
ghostName := context.GetGhostTriggerName(strings.Repeat("my_trigger_ghost", 4) + "_ghost") // suffix removed → 64
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
}
{
// Edge case: exactly 64 chars after transformation → valid (boundary test)
context := NewMigrationContext()
context.TriggerSuffix = "_ght"
originalName := strings.Repeat("x", 60) // 60 chars
ghostName := context.GetGhostTriggerName(originalName) // 60 + 4 = 64
require.Equal(t, 64, len(ghostName))
require.True(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
}
{
// Edge case: 65 chars after transformation → exceeds (boundary test)
context := NewMigrationContext()
context.TriggerSuffix = "_ght"
originalName := strings.Repeat("x", 61) // 61 chars
ghostName := context.GetGhostTriggerName(originalName) // 61 + 4 = 65
require.Equal(t, 65, len(ghostName))
require.False(t, context.ValidateGhostTriggerLengthBelowMaxLength(ghostName))
}
}

Expand Down
39 changes: 36 additions & 3 deletions go/cmd/gh-ost/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ func main() {
flag.Int64Var(&migrationContext.HooksStatusIntervalSec, "hooks-status-interval", 60, "how many seconds to wait between calling onStatus hook")

flag.UintVar(&migrationContext.ReplicaServerId, "replica-server-id", 99999, "server id used by gh-ost process. Default: 99999")
flag.BoolVar(&migrationContext.AllowSetupMetadataLockInstruments, "allow-setup-metadata-lock-instruments", false, "validate rename session hold the MDL of original table before unlock tables in cut-over phase")
flag.BoolVar(&migrationContext.AllowSetupMetadataLockInstruments, "allow-setup-metadata-lock-instruments", false, "Validate rename session hold the MDL of original table before unlock tables in cut-over phase")
flag.BoolVar(&migrationContext.SkipMetadataLockCheck, "skip-metadata-lock-check", false, "Skip metadata lock check at cut-over time. The checks require performance_schema.metadata_lock to be enabled")
flag.IntVar(&migrationContext.BinlogSyncerMaxReconnectAttempts, "binlogsyncer-max-reconnect-attempts", 0, "when master node fails, the maximum number of binlog synchronization attempts to reconnect. 0 is unlimited")

flag.BoolVar(&migrationContext.IncludeTriggers, "include-triggers", false, "When true, the triggers (if exist) will be created on the new table")
Expand All @@ -148,6 +149,8 @@ func main() {
flag.BoolVar(&migrationContext.Checkpoint, "checkpoint", false, "Enable migration checkpoints")
flag.Int64Var(&migrationContext.CheckpointIntervalSeconds, "checkpoint-seconds", 300, "The number of seconds between checkpoints")
flag.BoolVar(&migrationContext.Resume, "resume", false, "Attempt to resume migration from checkpoint")
flag.BoolVar(&migrationContext.Revert, "revert", false, "Attempt to revert completed migration")
flag.StringVar(&migrationContext.OldTableName, "old-table", "", "The name of the old table when using --revert, e.g. '_mytable_del'")

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")
criticalLoad := flag.String("critical-load", "", "Comma delimited status-name=threshold, same format as --max-load. When status exceeds threshold, app panics and quits")
Expand Down Expand Up @@ -206,12 +209,35 @@ func main() {

migrationContext.SetConnectionCharset(*charset)

if migrationContext.AlterStatement == "" {
if migrationContext.AlterStatement == "" && !migrationContext.Revert {
log.Fatal("--alter must be provided and statement must not be empty")
}
parser := sql.NewParserFromAlterStatement(migrationContext.AlterStatement)
migrationContext.AlterStatementOptions = parser.GetAlterStatementOptions()

if migrationContext.Revert {
if migrationContext.Resume {
log.Fatal("--revert cannot be used with --resume")
}
if migrationContext.OldTableName == "" {
migrationContext.Log.Fatalf("--revert must be called with --old-table")
}

// options irrelevant to revert mode
if migrationContext.AlterStatement != "" {
log.Warning("--alter was provided with --revert, it will be ignored")
}
if migrationContext.AttemptInstantDDL {
log.Warning("--attempt-instant-ddl was provided with --revert, it will be ignored")
}
if migrationContext.IncludeTriggers {
log.Warning("--include-triggers was provided with --revert, it will be ignored")
}
if migrationContext.DiscardForeignKeys {
log.Warning("--discard-foreign-keys was provided with --revert, it will be ignored")
}
}

if migrationContext.DatabaseName == "" {
if parser.HasExplicitSchema() {
migrationContext.DatabaseName = parser.GetExplicitSchema()
Expand Down Expand Up @@ -347,7 +373,14 @@ func main() {
acceptSignals(migrationContext)

migrator := logic.NewMigrator(migrationContext, AppVersion)
if err := migrator.Migrate(); err != nil {
var err error
if migrationContext.Revert {
err = migrator.Revert()
} else {
err = migrator.Migrate()
}

if err != nil {
migrator.ExecOnFailureHook()
migrationContext.Log.Fatale(err)
}
Expand Down
Loading