Skip to content

Commit eed0793

Browse files
Merge pull request #1718 from github/move-tables-connection-audit
Introduce a SourcePrimaryConnectionConfig for primary-required move-table ops
2 parents fe3e20e + b7a2c1c commit eed0793

11 files changed

Lines changed: 457 additions & 81 deletions

File tree

go/base/context.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,31 @@ type MigrationContext struct {
277277

278278
// move tables:
279279
MoveTables struct {
280-
TableNames []string // List of table names to be moved.
281-
TargetHost string // Target hostname for the move. This must be a primary/writable host.
282-
TargetPort int // Target MySQL port for the move.
283-
TargetUser string // Target username for the move. If not specified, it will default to the source user.
284-
TargetPass string // Target password for the move. If not specified, it will default to the source password.
285-
TargetDatabase string // Target database name for the move. If not specified, it will default to the source database name.
280+
TableNames []string // List of table names to be moved.
281+
TargetHost string // Target hostname for the move. This must be a primary/writable host.
282+
TargetPort int // Target MySQL port for the move.
283+
TargetUser string // Target username for the move. If not specified, it will default to the source user.
284+
TargetPass string // Target password for the move. If not specified, it will default to the source password.
285+
TargetDatabase string // Target database name for the move. If not specified, it will default to the source database name.
286+
287+
// AllowOnSourcePrimary opts in to running the move-tables read path (schema
288+
// inspection, the full row copy, binlog streaming) directly against the
289+
// source cluster's primary. By default gh-ost stops early when --host is the
290+
// primary, since reading the whole table copy from the primary is the load
291+
// move-tables is meant to avoid; the operator should point --host at a replica.
292+
AllowOnSourcePrimary bool
293+
286294
ConnectionConfig *mysql.ConnectionConfig
287295

296+
// SourcePrimaryConnectionConfig is the detected source-cluster primary. All
297+
// source reads (schema inspection, row copy, binlog streaming) go through the
298+
// inspector config (InspectorConnectionConfig), which may point at a read
299+
// replica to take load off the primary. The cutover RENAME + drain-GTID
300+
// capture and the source `__del` DROP must run on a writable primary, so they
301+
// use this dedicated config. When the source --host is itself the primary (no
302+
// replica topology), detection returns the inspector key and the two coincide.
303+
SourcePrimaryConnectionConfig *mysql.ConnectionConfig
304+
288305
DrainGTID mysql.BinlogCoordinates // Source @@gtid_executed captured immediately after the source RENAME TABLE; the applier drains until it reaches this coordinate (move-tables only).
289306
}
290307

go/cmd/gh-ost/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ func main() {
195195
flag.StringVar(&migrationContext.MoveTables.TargetUser, "target-user", "", "Target MySQL username for --move-tables mode. If not provided, uses the same user as the source connection")
196196
flag.StringVar(&migrationContext.MoveTables.TargetPass, "target-password", "", "Target MySQL password for --move-tables mode. If not provided, uses the same password as the source connection")
197197
flag.StringVar(&migrationContext.MoveTables.TargetDatabase, "target-database", "", "Target MySQL database name for --move-tables mode. If not provided, uses the same database name as the source connection")
198+
flag.BoolVar(&migrationContext.MoveTables.AllowOnSourcePrimary, "allow-on-source-primary", false, "allow --move-tables to read (schema, row copy, binlog) from the source cluster's primary. By default gh-ost stops if --host is the primary; prefer pointing --host at a replica to spare the primary the copy load.")
198199

199200
flag.CommandLine.SetOutput(os.Stdout)
200201
flag.Parse()

go/logic/applier.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@ func (apl *Applier) InitDBConnections() (err error) {
197197
if _, err := base.ValidateConnection(apl.moveTablesTargetDB, apl.moveTablesConnectionConfig, apl.migrationContext, apl.name); err != nil {
198198
return err
199199
}
200+
// Fail fast if the move-tables target is not a writable primary. All target
201+
// work (table create, row-copy INSERT, checkpoint writes, checkpoint DROP)
202+
// requires a writable host; catching read_only here turns a confusing
203+
// mid-run write failure into a clear startup error.
204+
if err := assertConnectionWritable(apl.moveTablesTargetDB, apl.moveTablesConnectionConfig.Key, "target"); err != nil {
205+
return err
206+
}
200207
}
201208
apl.migrationContext.Log.Infof("Applier initiated on %+v, version %+v", apl.connectionConfig.ImpliedKey, apl.migrationContext.ApplierMySQLVersion)
202209
return nil

go/logic/migrator.go

Lines changed: 236 additions & 33 deletions
Large diffs are not rendered by default.

go/logic/migrator_move_tables_cleanup_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,15 @@ func TestLogMoveTablesRollbackHint_EmitsRenameCommand(t *testing.T) {
6969
require.True(t, logger.has("-- rename table `source_db`.`_t_del` to `source_db`.`t`"),
7070
"must emit the rename command to roll the source table back")
7171
}
72+
73+
// TestMoveTablesDropSourceOldTable_NilSourcePrimaryErrors verifies the source
74+
// `__del` drop fails cleanly (rather than panicking) when the source-primary
75+
// connection was never initialized. The drop must never silently no-op.
76+
func TestMoveTablesDropSourceOldTable_NilSourcePrimaryErrors(t *testing.T) {
77+
m, _ := newCleanupTestMigrator()
78+
79+
err := m.dropSourceOldTable()
80+
81+
require.Error(t, err)
82+
require.Contains(t, err.Error(), "source primary connection not initialized")
83+
}

go/logic/migrator_move_tables_cutover_test.go

Lines changed: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import (
2424

2525
// -----------------------------------------------------------------------------
2626
// Pure unit tests - no MySQL. These exercise the orchestration branches that
27-
// run BEFORE T1's RENAME, so they do not require a real inspector.db. Per the
28-
// Option A decision in commit 3's plan, the "RENAME was not attempted" check
29-
// is a proxy assertion: m.inspector is nil, so if T1 were reached the test
30-
// would panic instead of silently passing.
27+
// run BEFORE T1's RENAME, so they do not require a real source-primary DB. The
28+
// "RENAME was not attempted" check is a proxy assertion: m.sourcePrimaryDB is
29+
// nil, so if T1 were reached the test would fail with the source-primary-not-
30+
// initialized error instead of the earlier error it asserts on.
3131
// -----------------------------------------------------------------------------
3232

3333
// TestMoveTablesCutOver_NoopShortCircuits maps to the Noop semantics decision
@@ -55,8 +55,9 @@ func TestMoveTablesCutOver_NoopShortCircuits(t *testing.T) {
5555

5656
// TestMoveTablesCutOver_OnBeforeCutOverHookAbortsBeforeRename maps to T0 in
5757
// coop_cutover.md section 1.3 ("non-zero return code aborts cutover"). The "aborts
58-
// BEFORE source DDL" assertion is enforced as a proxy: m.inspector is nil, so
59-
// if T1 RENAME executed via mgtr.inspector.db, this test would panic.
58+
// BEFORE source DDL" assertion is enforced as a proxy: m.sourcePrimaryDB is nil,
59+
// so if T1 RENAME executed it would fail with the source-primary-not-initialized
60+
// error rather than the asserted hook error.
6061
func TestMoveTablesCutOver_OnBeforeCutOverHookAbortsBeforeRename(t *testing.T) {
6162
var calls []string
6263
boom := errors.New("hook says no")
@@ -184,6 +185,67 @@ func TestResumeMoveTablesCutOverFromCheckpointAlreadyDrained(t *testing.T) {
184185
}
185186
}
186187

188+
// TestResolveSourcePrimaryConnectionConfig_AssumeMasterHostnameOverride verifies
189+
// that --assume-master-host forces the move-tables source primary to the given
190+
// host, and that --master-user/--master-password override the inherited source
191+
// credentials. No DB is required: the override branch builds the config purely
192+
// from the inspector config.
193+
func TestResolveSourcePrimaryConnectionConfig_AssumeMasterHostnameOverride(t *testing.T) {
194+
mc := base.NewMigrationContext()
195+
mc.InspectorConnectionConfig.User = "src_user"
196+
mc.InspectorConnectionConfig.Password = "src_pass"
197+
mc.AssumeMasterHostname = "10.0.0.5:3307"
198+
mc.CliMasterUser = "master_user"
199+
mc.CliMasterPassword = "master_pass"
200+
m := NewMigrator(mc, "test")
201+
202+
cfg, err := m.resolveSourcePrimaryConnectionConfig("8.0.42")
203+
require.NoError(t, err)
204+
require.Equal(t, "10.0.0.5", cfg.Key.Hostname)
205+
require.Equal(t, 3307, cfg.Key.Port)
206+
require.Equal(t, "master_user", cfg.User, "--master-user must override source credentials")
207+
require.Equal(t, "master_pass", cfg.Password, "--master-password must override source credentials")
208+
209+
// Without explicit master credentials, the forced primary inherits the source
210+
// (inspector) credentials.
211+
mc.CliMasterUser = ""
212+
mc.CliMasterPassword = ""
213+
cfg, err = m.resolveSourcePrimaryConnectionConfig("8.0.42")
214+
require.NoError(t, err)
215+
require.Equal(t, "src_user", cfg.User)
216+
require.Equal(t, "src_pass", cfg.Password)
217+
}
218+
219+
// TestValidateMoveTablesSourceReadHost verifies the read-path guard: a replica
220+
// source passes, a source that resolves to the primary is blocked with a hint to
221+
// use a replica or --allow-on-source-primary, and the opt-in flag bypasses it.
222+
func TestValidateMoveTablesSourceReadHost(t *testing.T) {
223+
newMigrator := func(srcKey, primaryKey mysql.InstanceKey, allow bool) *Migrator {
224+
mc := base.NewMigrationContext()
225+
mc.MoveTables.TableNames = []string{"t"}
226+
mc.InspectorConnectionConfig.Key = srcKey
227+
mc.MoveTables.SourcePrimaryConnectionConfig = &mysql.ConnectionConfig{Key: primaryKey}
228+
mc.MoveTables.AllowOnSourcePrimary = allow
229+
return NewMigrator(mc, "test")
230+
}
231+
replica := mysql.InstanceKey{Hostname: "replica.example.com", Port: 3306}
232+
primary := mysql.InstanceKey{Hostname: "primary.example.com", Port: 3306}
233+
234+
t.Run("source is a replica: passes", func(t *testing.T) {
235+
require.NoError(t, newMigrator(replica, primary, false).validateMoveTablesSourceReadHost())
236+
})
237+
238+
t.Run("source is the primary: blocked", func(t *testing.T) {
239+
err := newMigrator(primary, primary, false).validateMoveTablesSourceReadHost()
240+
require.Error(t, err)
241+
require.Contains(t, err.Error(), "--allow-on-source-primary")
242+
})
243+
244+
t.Run("source is the primary but opted in: passes", func(t *testing.T) {
245+
require.NoError(t, newMigrator(primary, primary, true).validateMoveTablesSourceReadHost())
246+
})
247+
}
248+
187249
// -----------------------------------------------------------------------------
188250
// Integration tests - real MySQL via testcontainers, exercise T1/T2/T3.
189251
//
@@ -251,8 +313,10 @@ func (s *MoveTablesCutOverSuite) containingDrainGTID() *mysql.GTIDBinlogCoordina
251313
}
252314

253315
// buildMigrator wires a Migrator with the test container's *sql.DB pinned to
254-
// inspector.db and a fresh Applier. initialCoords may be nil for the drain-
255-
// timeout case.
316+
// inspector.db and a fresh Applier. The cutover RENAME + drain-GTID capture run
317+
// on sourcePrimaryDB (a dedicated handle with multiStatements enabled, since
318+
// T1/T2 are issued as a single multi-statement round trip). initialCoords may be
319+
// nil for the drain-timeout case.
256320
func (s *MoveTablesCutOverSuite) buildMigrator(fakeHooks base.Hooks, initialCoords mysql.BinlogCoordinates) (*Migrator, *base.MigrationContext) {
257321
ctx := context.Background()
258322
connectionConfig, err := getTestConnectionConfig(ctx, s.mysqlContainer)
@@ -261,11 +325,17 @@ func (s *MoveTablesCutOverSuite) buildMigrator(fakeHooks base.Hooks, initialCoor
261325
mc := newTestMigrationContext()
262326
mc.ApplierConnectionConfig = connectionConfig
263327
mc.InspectorConnectionConfig = connectionConfig
328+
mc.MoveTables.SourcePrimaryConnectionConfig = connectionConfig
264329
mc.SetConnectionConfig("innodb")
265330
mc.Hooks = fakeHooks
266331

267332
m := NewMigrator(mc, "test")
268333
m.inspector = &Inspector{db: s.db, migrationContext: mc}
334+
// The source primary handle needs multiStatements enabled for the consolidated
335+
// T1+T2 (RENAME; SELECT @@global.gtid_executed) round trip.
336+
sourcePrimaryDB, _, err := mysql.GetDB(mc.Uuid, connectionConfig.GetDBUri(testMysqlDatabase)+"&multiStatements=true")
337+
s.Require().NoError(err)
338+
m.sourcePrimaryDB = sourcePrimaryDB
269339
m.applier = NewApplier(mc)
270340
if initialCoords != nil {
271341
m.applier.CurrentCoordinatesMutex.Lock()
@@ -275,6 +345,58 @@ func (s *MoveTablesCutOverSuite) buildMigrator(fakeHooks base.Hooks, initialCoor
275345
return m, mc
276346
}
277347

348+
// TestDropSourceOldTableUsesSourcePrimary verifies the source `__del` rollback
349+
// handle is dropped through the dedicated source-primary connection. In
350+
// production the inspector/streamer source connections may be a read replica, so
351+
// the drop must not route through them.
352+
func (s *MoveTablesCutOverSuite) TestDropSourceOldTableUsesSourcePrimary() {
353+
ctx := context.Background()
354+
_, err := s.db.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s (id INT PRIMARY KEY)", getTestOldTableName()))
355+
s.Require().NoError(err)
356+
357+
var calls []string
358+
fakeHooks := &recordingHooks{name: "fake", calls: &calls}
359+
m, _ := s.buildMigrator(fakeHooks, s.containingDrainGTID())
360+
361+
s.Require().NoError(m.dropSourceOldTable())
362+
363+
var name string
364+
err = s.db.QueryRow(fmt.Sprintf("SHOW TABLES IN %s LIKE '_%s_del'",
365+
testMysqlDatabase, testMysqlTableName)).Scan(&name)
366+
s.Require().ErrorIs(err, gosql.ErrNoRows, "source __del handle must be dropped via the source primary")
367+
}
368+
369+
// TestResolveSourcePrimaryFallsBackToInspectorWhenNoReplica verifies the
370+
// graceful fallback: when the source --host has no upstream primary (the
371+
// standalone test container), master detection returns the inspector connection
372+
// config, so source reads and cutover writes share the one available host.
373+
func (s *MoveTablesCutOverSuite) TestResolveSourcePrimaryFallsBackToInspectorWhenNoReplica() {
374+
var calls []string
375+
m, mc := s.buildMigrator(&recordingHooks{name: "fake", calls: &calls}, nil)
376+
377+
cfg, err := m.resolveSourcePrimaryConnectionConfig("8.0.42")
378+
s.Require().NoError(err)
379+
s.Require().Equal(mc.InspectorConnectionConfig.Key.Hostname, cfg.Key.Hostname)
380+
s.Require().Equal(mc.InspectorConnectionConfig.Key.Port, cfg.Key.Port)
381+
}
382+
383+
// TestAssertConnectionWritableRejectsReadOnly verifies the startup writability
384+
// gate: a writable primary passes, and a read_only server is rejected with a
385+
// clear error. The same helper guards both the source primary and the target.
386+
func (s *MoveTablesCutOverSuite) TestAssertConnectionWritableRejectsReadOnly() {
387+
key := mysql.InstanceKey{Hostname: "test-host", Port: 3306}
388+
s.Require().NoError(assertConnectionWritable(s.db, key, "source primary"),
389+
"a writable primary must pass the gate")
390+
391+
_, err := s.db.Exec("SET GLOBAL read_only = ON")
392+
s.Require().NoError(err)
393+
defer func() { _, _ = s.db.Exec("SET GLOBAL read_only = OFF") }()
394+
395+
err = assertConnectionWritable(s.db, key, "source primary")
396+
s.Require().Error(err)
397+
s.Require().Contains(err.Error(), "read_only")
398+
}
399+
278400
// TestHappyPath drives the full T0-T6 protocol against the test container.
279401
// Asserts hook ordering (T0 then T5), T4 flag set, and the source-side rename.
280402
// Maps to acceptance criterion #8209 "RENAME executes; drain completes;
@@ -322,7 +444,7 @@ func (s *MoveTablesCutOverSuite) TestRenameFailurePropagates() {
322444

323445
err := m.moveTablesCutOver()
324446
s.Require().Error(err)
325-
s.Require().Contains(err.Error(), "RENAME failed")
447+
s.Require().Contains(err.Error(), "source RENAME + drain GTID capture failed")
326448

327449
s.Require().Equal(int64(0), atomic.LoadInt64(&mc.CutOverCompleteFlag),
328450
"post-state: RENAME failure must leave CutOverCompleteFlag unset")

go/logic/streamer.go

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/github/gh-ost/go/base"
1616
"github.com/github/gh-ost/go/binlog"
1717
"github.com/github/gh-ost/go/mysql"
18-
"github.com/github/gh-ost/go/sql"
1918

2019
gomysql "github.com/go-mysql-org/go-mysql/mysql"
2120
"github.com/openark/golib/sqlutils"
@@ -240,32 +239,6 @@ func (es *EventsStreamer) Close() (err error) {
240239
return err
241240
}
242241

243-
// DropSourceOldTable drops the source "__del" table in move-tables mode. The
244-
// __del table is the post-cutover rollback handle on the source cluster; it is
245-
// only dropped after a successful run when --ok-to-drop-table is set.
246-
// The applier's dropTable targets the move-tables target cluster,
247-
// so the source-side drop is owned by the streamer: its `db`
248-
// handle uses InspectorConnectionConfig (the source) and stays open in both the
249-
// normal and the cutover-resume paths (Close() only closes the binlog reader,
250-
// not `db`).
251-
func (es *EventsStreamer) DropSourceOldTable() error {
252-
databaseName := es.migrationContext.DatabaseName
253-
tableName := es.migrationContext.GetOldTableName()
254-
query := fmt.Sprintf(`drop /* gh-ost */ table if exists %s.%s`,
255-
sql.EscapeName(databaseName),
256-
sql.EscapeName(tableName),
257-
)
258-
es.migrationContext.Log.Infof("Dropping source table %s.%s",
259-
sql.EscapeName(databaseName),
260-
sql.EscapeName(tableName),
261-
)
262-
if _, err := sqlutils.ExecNoPrepare(es.db, query); err != nil {
263-
return err
264-
}
265-
es.migrationContext.Log.Infof("Source table dropped")
266-
return nil
267-
}
268-
269242
func (es *EventsStreamer) Teardown() {
270243
es.db.Close()
271244
}

localtests/move-tables-test.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ build_ghost_command() {
181181
--password=opensesame \
182182
--host=$source_replica_host \
183183
--port=$source_replica_port \
184+
--assume-master-host=${source_master_host}:${source_master_port} \
184185
--database=$database \
185186
--target-user=root \
186187
--target-password=opensesame \

script/move-tables/README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,9 @@ script/build --cli
2424

2525
Run gh-ost to move tables:
2626
```bash
27-
./script/build --cli; ./bin/gh-ost --move-tables=gh_ost_test --host=localhost --port=3307 --user root --password opensesame --database=gh_ost_test_db --target-host=localhost --target-port=3309 --target-user root --target-password opensesame --target-database=gh_ost_test_db --postpone-cut-over-flag-file=/tmp/ghost-move-tables.postpone.flag --execute --verbose --checkpoint --checkpoint-seconds 10
27+
./script/build --cli; ./bin/gh-ost --move-tables=gh_ost_test --host=localhost --port=3308 --user root --password opensesame --database=gh_ost_test_db --target-host=localhost --target-port=3309 --target-user root --target-password opensesame --target-database=gh_ost_test_db --postpone-cut-over-flag-file=/tmp/ghost-move-tables.postpone.flag --execute --verbose --checkpoint --checkpoint-seconds 10 --initially-drop-socket-file
2828
```
2929

30-
Note: replicas in this local topology are configured with `read_only=ON` and
31-
`super_read_only=ON`. If you point `--host` at `mysql-source-replica` (3308),
32-
the cutover `RENAME TABLE` step will fail by design. Use source primary (3307)
33-
as the inspected host when you want cutover to rename on source.
34-
3530
Start continuous inserts against the source.
3631
```bash
3732
script/move-tables/insert-source-primary-loop

script/move-tables/reset

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/bash
2+
3+
set -euo pipefail
4+
5+
GH_OST_ROOT=$(git rev-parse --show-toplevel)
6+
SCRIPT_PATH="${GH_OST_ROOT}/script/move-tables"
7+
DATABASE_NAME="${GH_OST_TEST_DB:-gh_ost_test_db}"
8+
9+
# Reset source table state regardless of whether cutover renamed it.
10+
${SCRIPT_PATH}/mysql-source-primary -D "${DATABASE_NAME}" -e "DROP TABLE IF EXISTS _gh_ost_test_del, gh_ost_test;"
11+
12+
# Recreate and seed source table data, same fixture as setup uses.
13+
${SCRIPT_PATH}/mysql-source-primary -D "${DATABASE_NAME}" < "${GH_OST_ROOT}/localtests/move-tables/create.sql"
14+
15+
${SCRIPT_PATH}/mysql-target-primary -D "${DATABASE_NAME}" -e "DROP TABLE IF EXISTS gh_ost_test, _gh_ost_test_ghk;"
16+
17+
echo "Reset source and target tables in ${DATABASE_NAME}"

0 commit comments

Comments
 (0)