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
3236type 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
9221095func (mgtr * Migrator ) ExecOnFailureHook () (err error ) {
0 commit comments