Skip to content

Commit 865282c

Browse files
Merge pull request #1704 from github/womoruyi/move-tables-1.5-cutover
feat(move-tables): build cooperative cutover orchestration (#8209)
2 parents b538d5b + 732581d commit 865282c

8 files changed

Lines changed: 619 additions & 158 deletions

File tree

go/base/context.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,12 @@ func (mctx *MigrationContext) SetCutOverLockTimeoutSeconds(timeoutSeconds int64)
508508
if timeoutSeconds < 1 {
509509
return fmt.Errorf("minimal timeout is 1sec. Timeout remains at %d", mctx.CutOverLockTimeoutSeconds)
510510
}
511-
if timeoutSeconds > 10 {
512-
return fmt.Errorf("maximal timeout is 10sec. Timeout remains at %d", mctx.CutOverLockTimeoutSeconds)
511+
maxTimeout := int64(10)
512+
if mctx.IsMoveTablesMode() {
513+
maxTimeout = 60
514+
}
515+
if timeoutSeconds > maxTimeout {
516+
return fmt.Errorf("maximal timeout is %dsec. Timeout remains at %d", maxTimeout, mctx.CutOverLockTimeoutSeconds)
513517
}
514518
mctx.CutOverLockTimeoutSeconds = timeoutSeconds
515519
return nil

go/base/context_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,22 @@ func TestSetAbortError_ThreadSafe(t *testing.T) {
326326
t.Errorf("Stored error %v not in list of sent errors", got)
327327
}
328328
}
329+
330+
func TestSetCutOverLockTimeoutSecondsRangeByMode(t *testing.T) {
331+
{
332+
ctx := NewMigrationContext()
333+
require.NoError(t, ctx.SetCutOverLockTimeoutSeconds(10))
334+
err := ctx.SetCutOverLockTimeoutSeconds(11)
335+
require.Error(t, err)
336+
require.Contains(t, err.Error(), "maximal timeout is 10sec")
337+
}
338+
339+
{
340+
ctx := NewMigrationContext()
341+
ctx.MoveTables.TableNames = []string{"tbl"}
342+
require.NoError(t, ctx.SetCutOverLockTimeoutSeconds(60))
343+
err := ctx.SetCutOverLockTimeoutSeconds(61)
344+
require.Error(t, err)
345+
require.Contains(t, err.Error(), "maximal timeout is 60sec")
346+
}
347+
}

go/cmd/gh-ost/main.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,12 @@ func main() {
198198

199199
flag.CommandLine.SetOutput(os.Stdout)
200200
flag.Parse()
201+
cutOverLockTimeoutUserSpecified := false
202+
flag.Visit(func(f *flag.Flag) {
203+
if f.Name == "cut-over-lock-timeout-seconds" {
204+
cutOverLockTimeoutUserSpecified = true
205+
}
206+
})
201207

202208
if *checkFlag {
203209
return
@@ -364,6 +370,9 @@ func main() {
364370
if migrationContext.MoveTables.TargetHost == "" {
365371
log.Fatal("--target-host must be specified when using --move-tables")
366372
}
373+
if migrationContext.PostponeCutOverFlagFile == "" {
374+
log.Fatal("--postpone-cut-over-flag-file must be specified when using --move-tables")
375+
}
367376
migrationContext.MoveTables.TableNames = strings.Split(*moveTables, ",")
368377
for i := range migrationContext.MoveTables.TableNames {
369378
migrationContext.MoveTables.TableNames[i] = strings.TrimSpace(migrationContext.MoveTables.TableNames[i])
@@ -384,6 +393,9 @@ func main() {
384393
if migrationContext.MoveTables.TargetDatabase == "" {
385394
migrationContext.MoveTables.TargetDatabase = migrationContext.DatabaseName
386395
}
396+
if !cutOverLockTimeoutUserSpecified {
397+
*cutOverLockTimeoutSeconds = 60
398+
}
387399
migrationContext.MoveTables.ConnectionConfig = mysql.NewConnectionConfig()
388400
}
389401

go/logic/migrator.go

Lines changed: 177 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ var (
2727
ErrMigrationNotAllowedOnMaster = errors.New("it seems like this migration attempt to run directly on master. Preferably it would be executed on a replica (this reduces load from the master). To proceed please provide --allow-on-master")
2828
RetrySleepFn = time.Sleep
2929
checkpointTimeout = 2 * time.Second
30+
31+
// moveTablesCutOverDrainPollInterval is the per-iteration sleep in T3's
32+
// drain poll. 100ms per move_table_mode.md §1.5.
33+
moveTablesCutOverDrainPollInterval = 100 * time.Millisecond
3034
)
3135

3236
type ChangelogState string
@@ -838,6 +842,15 @@ func (mgtr *Migrator) MoveTables() (err error) {
838842
if err := mgtr.initiateApplier(); err != nil {
839843
return err
840844
}
845+
if err := mgtr.checkAbort(); err != nil {
846+
return err
847+
}
848+
if err := mgtr.createFlagFiles(); err != nil {
849+
return err
850+
}
851+
if err := mgtr.checkAbort(); err != nil {
852+
return err
853+
}
841854
if err := mgtr.initiateStreaming(); err != nil {
842855
return err
843856
}
@@ -899,14 +912,13 @@ func (mgtr *Migrator) MoveTables() (err error) {
899912
return err
900913
}
901914

902-
//TODO: cutover here
915+
if err := mgtr.moveTablesCutOver(); err != nil {
916+
return err
917+
}
903918

904919
if err := mgtr.finalCleanup(); err != nil {
905920
return nil
906921
}
907-
if err := mgtr.hooksExecutor.OnSuccess(false); err != nil {
908-
return err
909-
}
910922
mgtr.migrationContext.Log.Infof("Done moving tables %v from %s to %s (%s)",
911923
mgtr.migrationContext.MoveTables.TableNames, sql.EscapeName(mgtr.migrationContext.DatabaseName),
912924
sql.EscapeName(mgtr.migrationContext.GetTargetDatabaseName()), mgtr.migrationContext.MoveTables.TargetHost)
@@ -917,6 +929,167 @@ func (mgtr *Migrator) MoveTables() (err error) {
917929
return nil
918930
}
919931

932+
// moveTablesCutOver orchestrates the cooperative cutover protocol for move-tables
933+
// mode. It implements the T0-T6 transitions described in
934+
// docs/learning/design-refs/coop_cutover.md §1.3.
935+
//
936+
// NOT the standard cutOver() path: every internal call from cutOver() (throttle,
937+
// atomicCutOver, waitForEventsUpToLock, heartbeat-lag) was built on a
938+
// single-server assumption that no longer holds when the applier writes target
939+
// and the streamer reads source. Each is replaced or dropped here.
940+
//
941+
// Crash safety (persisting the drain GTID before T3) is #8210. Enriched hook
942+
// env vars (GH_OST_DRAIN_GTID, GH_OST_TARGET_*) are #8211. Target-side
943+
// throttling is #8212. None of those are wired here.
944+
func (mgtr *Migrator) moveTablesCutOver() (err error) {
945+
if mgtr.migrationContext.Noop {
946+
mgtr.migrationContext.Log.Debugf("Noop operation; not really moving tables")
947+
return nil
948+
}
949+
950+
// ----- Postpone gate (precedes T0) -----
951+
// Mirrors standard cutOver()'s sleepWhileTrue postpone structure but DROPS the
952+
// heartbeat-lag branch: move-tables mode disables _ghc heartbeat writes (#8206),
953+
// so TimeSinceLastHeartbeatOnChangelog() returns ~58 years (time.Since(zero))
954+
// and would deadlock the gate forever. KEEPS the postpone-flag-file +
955+
// unpostpone-socket gate because per coop_cutover.md §1.1 P4, operator-removes-
956+
// postpone is the trigger for the entire cutover phase.
957+
mgtr.migrationContext.Log.Debugf("checking for cut-over postpone")
958+
if err := mgtr.sleepWhileTrue(func() (bool, error) {
959+
if mgtr.migrationContext.PostponeCutOverFlagFile == "" {
960+
return false, nil
961+
}
962+
if atomic.LoadInt64(&mgtr.migrationContext.UserCommandedUnpostponeFlag) > 0 {
963+
atomic.StoreInt64(&mgtr.migrationContext.UserCommandedUnpostponeFlag, 0)
964+
return false, nil
965+
}
966+
if base.FileExists(mgtr.migrationContext.PostponeCutOverFlagFile) {
967+
if atomic.LoadInt64(&mgtr.migrationContext.IsPostponingCutOver) == 0 {
968+
if err := mgtr.hooksExecutor.OnBeginPostponed(); err != nil {
969+
return true, err
970+
}
971+
}
972+
atomic.StoreInt64(&mgtr.migrationContext.IsPostponingCutOver, 1)
973+
return true, nil
974+
}
975+
return false, nil
976+
}); err != nil {
977+
return err
978+
}
979+
atomic.StoreInt64(&mgtr.migrationContext.IsPostponingCutOver, 0)
980+
mgtr.migrationContext.Log.Debugf("checking for cut-over postpone: complete")
981+
982+
// ----- T0: on-before-cut-over hook -----
983+
// Non-zero hook exit aborts cutover BEFORE any source DDL fires.
984+
if err := mgtr.hooksExecutor.OnBeforeCutOver(); err != nil {
985+
return fmt.Errorf("on-before-cut-over hook failed: %w", err)
986+
}
987+
988+
// ----- T1 + T2: RENAME then capture @@gtid_executed on the same connection -----
989+
// Pin both operations to a single *sql.Conn so MySQL's within-session
990+
// ordering guarantee makes it impossible for T2 to observe a state that
991+
// pre-dates T1's commit. Using mgtr.inspector.db directly would let the
992+
// pool schedule T1 and T2 on different underlying TCP connections (or, with
993+
// a proxy, different servers), breaking the happens-before relationship.
994+
//
995+
// No retry on the RENAME: it is not idempotent — a partial success leaves
996+
// the table already renamed and a retry would fail. The operator re-runs
997+
// the whole hook chain on failure.
998+
pinnedConn, err := mgtr.inspector.db.Conn(context.Background())
999+
if err != nil {
1000+
return fmt.Errorf("failed to pin connection for T1/T2: %w", err)
1001+
}
1002+
defer pinnedConn.Close()
1003+
1004+
sourceDB := mgtr.migrationContext.DatabaseName
1005+
sourceTable := mgtr.migrationContext.OriginalTableName
1006+
delTable := mgtr.migrationContext.GetOldTableName()
1007+
renameQuery := fmt.Sprintf("RENAME TABLE %s.%s TO %s.%s",
1008+
sql.EscapeName(sourceDB), sql.EscapeName(sourceTable),
1009+
sql.EscapeName(sourceDB), sql.EscapeName(delTable))
1010+
mgtr.migrationContext.Log.Infof("T1: renaming source table: %s", renameQuery)
1011+
if _, err := pinnedConn.ExecContext(context.Background(), renameQuery); err != nil {
1012+
return fmt.Errorf("RENAME failed: %w", err)
1013+
}
1014+
1015+
// ----- T2: capture @@gtid_executed on the SAME connection as T1 -----
1016+
// @@GLOBAL scope is explicit so the intent is unambiguous in the SQL itself.
1017+
// Design: https://github.com/github/gh-ost-tablemove-poc/blob/9dc6df75c4c88ff473906a497836c7518f5614ec/design/coop_cutover.md#32-correctness-verification-for-p4
1018+
var drainGTIDStr string
1019+
if err := pinnedConn.QueryRowContext(context.Background(), "select @@gtid_executed").Scan(&drainGTIDStr); err != nil {
1020+
return fmt.Errorf("drain GTID capture failed: %w", err)
1021+
}
1022+
drainGTID, err := mysql.NewGTIDBinlogCoordinates(drainGTIDStr)
1023+
if err != nil {
1024+
return fmt.Errorf("drain GTID parse failed: %w", err)
1025+
}
1026+
mgtr.migrationContext.Log.Infof("T2: captured drain GTID: %s", drainGTID.DisplayString())
1027+
1028+
// ----- T3: drain poll -----
1029+
// Wait until applier.CurrentCoordinates catches up to drainGTID. The drain
1030+
// is complete when the applier's coords are not strictly smaller than the
1031+
// drain target (i.e. the applier contains every GTID in drainGTID). Reads
1032+
// of CurrentCoordinates hold the mutex per applier.go:75. Per-iteration
1033+
// logging is Debug only to avoid spamming Info on a hot loop.
1034+
drainTimeout := time.Duration(mgtr.migrationContext.CutOverLockTimeoutSeconds) * time.Second
1035+
mgtr.migrationContext.Log.Infof("T3: draining applier to drain GTID (timeout %s, poll %s)",
1036+
drainTimeout, moveTablesCutOverDrainPollInterval)
1037+
drainCtx, cancel := context.WithTimeout(context.Background(), drainTimeout)
1038+
defer cancel()
1039+
ticker := time.NewTicker(moveTablesCutOverDrainPollInterval)
1040+
defer ticker.Stop()
1041+
for {
1042+
if err := mgtr.checkAbort(); err != nil {
1043+
return err
1044+
}
1045+
mgtr.applier.CurrentCoordinatesMutex.Lock()
1046+
applierCoords := mgtr.applier.CurrentCoordinates
1047+
mgtr.applier.CurrentCoordinatesMutex.Unlock()
1048+
applyBacklog := len(mgtr.applyEventsQueue)
1049+
streamerBacklog := 0
1050+
if mgtr.eventsStreamer != nil {
1051+
streamerBacklog = len(mgtr.eventsStreamer.eventsChannel)
1052+
}
1053+
if applierCoords != nil && !applierCoords.IsEmpty() && !applierCoords.SmallerThan(drainGTID) && applyBacklog == 0 && streamerBacklog == 0 {
1054+
mgtr.migrationContext.Log.Infof("T3: drain complete; applier caught up to drain GTID")
1055+
break
1056+
}
1057+
if applierCoords != nil && !applierCoords.IsEmpty() && !applierCoords.SmallerThan(drainGTID) {
1058+
mgtr.migrationContext.Log.Debugf("T3: drain GTID reached but backlog remains (apply=%d, streamer=%d)", applyBacklog, streamerBacklog)
1059+
} else {
1060+
mgtr.migrationContext.Log.Debugf("T3: applier still behind drain GTID, polling")
1061+
}
1062+
select {
1063+
case <-drainCtx.Done():
1064+
return fmt.Errorf("drain poll timed out after %s: applier did not catch up to drain GTID", drainTimeout)
1065+
case <-ticker.C:
1066+
// next iteration
1067+
}
1068+
}
1069+
1070+
// ----- T4: set CutOverCompleteFlag -----
1071+
// MUST be set before T5 so the streamer's canStopStreaming loop (migrator.go:256)
1072+
// can wind down in parallel with the (potentially slow) on-success hook.
1073+
// Forgetting this has no visible failure at the call site — the run silently
1074+
// hangs after cutover because eventsStreamer.StreamEvents() never returns.
1075+
atomic.StoreInt64(&mgtr.migrationContext.CutOverCompleteFlag, 1)
1076+
mgtr.migrationContext.Log.Debugf("T4: CutOverCompleteFlag set")
1077+
1078+
// ----- T5: on-success hook -----
1079+
// Hook unlocks user_rw@target via db-user-management and flips the
1080+
// write_cutover? feature flag. Standard env vars only — GH_OST_DRAIN_GTID +
1081+
// GH_OST_TARGET_* are #8211 (1.7), not this PR. The pre-protocol placeholder
1082+
// OnSuccess call that used to live in MoveTables() (after finalCleanup) has
1083+
// been removed so the hook fires in the order coop_cutover.md §3.2 step 6
1084+
// requires (T5 between T4 and T6, BEFORE finalCleanup).
1085+
if err := mgtr.hooksExecutor.OnSuccess(false); err != nil {
1086+
return fmt.Errorf("on-success hook failed: %w", err)
1087+
}
1088+
1089+
// ----- T6: return nil -----
1090+
return nil
1091+
}
1092+
9201093
// ExecOnFailureHook executes the onFailure hook, and this method is provided as the only external
9211094
// hook access point
9221095
func (mgtr *Migrator) ExecOnFailureHook() (err error) {

0 commit comments

Comments
 (0)