Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
55 changes: 55 additions & 0 deletions doc/revert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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.

> [!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