Skip to content
Merged
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
1 change: 1 addition & 0 deletions doc/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ 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:

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.
14 changes: 12 additions & 2 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 @@ -348,6 +350,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 +370,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
36 changes: 34 additions & 2 deletions go/cmd/gh-ost/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,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 +208,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 +372,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
11 changes: 3 additions & 8 deletions go/logic/applier.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,25 +437,20 @@ func (this *Applier) CreateCheckpointTable() error {
"`gh_ost_chk_iteration` bigint",
"`gh_ost_rows_copied` bigint",
"`gh_ost_dml_applied` bigint",
"`gh_ost_is_cutover` tinyint(1) DEFAULT '0'",
}
for _, col := range this.migrationContext.UniqueKey.Columns.Columns() {
if col.MySQLType == "" {
return fmt.Errorf("CreateCheckpoinTable: column %s has no type information. applyColumnTypes must be called", sql.EscapeName(col.Name))
}
minColName := sql.TruncateColumnName(col.Name, sql.MaxColumnNameLength-4) + "_min"
colDef := fmt.Sprintf("%s %s", sql.EscapeName(minColName), col.MySQLType)
if !col.Nullable {
colDef += " NOT NULL"
}
colDefs = append(colDefs, colDef)
}

for _, col := range this.migrationContext.UniqueKey.Columns.Columns() {
maxColName := sql.TruncateColumnName(col.Name, sql.MaxColumnNameLength-4) + "_max"
colDef := fmt.Sprintf("%s %s", sql.EscapeName(maxColName), col.MySQLType)
if !col.Nullable {
colDef += " NOT NULL"
}
colDefs = append(colDefs, colDef)
}

Expand Down Expand Up @@ -627,7 +622,7 @@ func (this *Applier) WriteCheckpoint(chk *Checkpoint) (int64, error) {
if err != nil {
return insertId, err
}
args := sqlutils.Args(chk.LastTrxCoords.String(), chk.Iteration, chk.RowsCopied, chk.DMLApplied)
args := sqlutils.Args(chk.LastTrxCoords.String(), chk.Iteration, chk.RowsCopied, chk.DMLApplied, chk.IsCutover)
args = append(args, uniqueKeyArgs...)
res, err := this.db.Exec(query, args...)
if err != nil {
Expand All @@ -645,7 +640,7 @@ func (this *Applier) ReadLastCheckpoint() (*Checkpoint, error) {

var coordStr string
var timestamp int64
ptrs := []interface{}{&chk.Id, &timestamp, &coordStr, &chk.Iteration, &chk.RowsCopied, &chk.DMLApplied}
ptrs := []interface{}{&chk.Id, &timestamp, &coordStr, &chk.Iteration, &chk.RowsCopied, &chk.DMLApplied, &chk.IsCutover}
ptrs = append(ptrs, chk.IterationRangeMin.ValuesPointers...)
ptrs = append(ptrs, chk.IterationRangeMax.ValuesPointers...)
err := row.Scan(ptrs...)
Expand Down
5 changes: 4 additions & 1 deletion go/logic/applier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ func (suite *ApplierTestSuite) SetupSuite() {
testmysql.WithUsername(testMysqlUser),
testmysql.WithPassword(testMysqlPass),
testcontainers.WithWaitStrategy(wait.ForExposedPort()),
testmysql.WithConfigFile("my.cnf.test"),
)
suite.Require().NoError(err)

Expand Down Expand Up @@ -272,7 +273,7 @@ func (suite *ApplierTestSuite) TestInitDBConnections() {
mysqlVersion, _ := strings.CutPrefix(testMysqlContainerImage, "mysql:")
suite.Require().Equal(mysqlVersion, migrationContext.ApplierMySQLVersion)
suite.Require().Equal(int64(28800), migrationContext.ApplierWaitTimeout)
suite.Require().Equal("SYSTEM", migrationContext.ApplierTimeZone)
suite.Require().Equal("+00:00", migrationContext.ApplierTimeZone)

suite.Require().Equal(sql.NewColumnList([]string{"id", "item_id"}), migrationContext.OriginalTableColumnsOnApplier)
}
Expand Down Expand Up @@ -702,6 +703,7 @@ func (suite *ApplierTestSuite) TestWriteCheckpoint() {
Iteration: 2,
RowsCopied: 100000,
DMLApplied: 200000,
IsCutover: true,
}
id, err := applier.WriteCheckpoint(chk)
suite.Require().NoError(err)
Expand All @@ -716,6 +718,7 @@ func (suite *ApplierTestSuite) TestWriteCheckpoint() {
suite.Require().Equal(chk.IterationRangeMax.String(), gotChk.IterationRangeMax.String())
suite.Require().Equal(chk.RowsCopied, gotChk.RowsCopied)
suite.Require().Equal(chk.DMLApplied, gotChk.DMLApplied)
suite.Require().Equal(chk.IsCutover, gotChk.IsCutover)
}

func TestApplier(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions go/logic/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ type Checkpoint struct {
Iteration int64
RowsCopied int64
DMLApplied int64
IsCutover bool
}
1 change: 1 addition & 0 deletions go/logic/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func (this *HooksExecutor) applyEnvironmentVariables(extraVariables ...string) [
env = append(env, fmt.Sprintf("GH_OST_HOOKS_HINT_OWNER=%s", this.migrationContext.HooksHintOwner))
env = append(env, fmt.Sprintf("GH_OST_HOOKS_HINT_TOKEN=%s", this.migrationContext.HooksHintToken))
env = append(env, fmt.Sprintf("GH_OST_DRY_RUN=%t", this.migrationContext.Noop))
env = append(env, fmt.Sprintf("GH_OST_REVERT=%t", this.migrationContext.Revert))

env = append(env, extraVariables...)
return env
Expand Down
Loading
Loading