|
| 1 | +package logic |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + gosql "database/sql" |
| 6 | + "errors" |
| 7 | + "fmt" |
| 8 | + "os" |
| 9 | + "path/filepath" |
| 10 | + "strings" |
| 11 | + "sync/atomic" |
| 12 | + "testing" |
| 13 | + "time" |
| 14 | + |
| 15 | + "github.com/stretchr/testify/require" |
| 16 | + "github.com/stretchr/testify/suite" |
| 17 | + |
| 18 | + "github.com/testcontainers/testcontainers-go" |
| 19 | + testmysql "github.com/testcontainers/testcontainers-go/modules/mysql" |
| 20 | + |
| 21 | + "github.com/github/gh-ost/go/base" |
| 22 | + "github.com/github/gh-ost/go/mysql" |
| 23 | +) |
| 24 | + |
| 25 | +// ----------------------------------------------------------------------------- |
| 26 | +// 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. |
| 31 | +// ----------------------------------------------------------------------------- |
| 32 | + |
| 33 | +// TestMoveTablesCutOver_NoopShortCircuits maps to the Noop semantics decision |
| 34 | +// in #8209-implement-protocol (Noop returns immediately, no postpone gate, no |
| 35 | +// hooks, no RENAME). |
| 36 | +func TestMoveTablesCutOver_NoopShortCircuits(t *testing.T) { |
| 37 | + var calls []string |
| 38 | + fakeHooks := &recordingHooks{name: "fake", calls: &calls} |
| 39 | + |
| 40 | + ctx := base.NewMigrationContext() |
| 41 | + ctx.Noop = true |
| 42 | + ctx.Hooks = fakeHooks |
| 43 | + |
| 44 | + m := NewMigrator(ctx, "test") |
| 45 | + |
| 46 | + require.Equal(t, int64(0), atomic.LoadInt64(&ctx.CutOverCompleteFlag), "pre-state: flag must be 0") |
| 47 | + require.Empty(t, calls, "pre-state: no hooks recorded") |
| 48 | + |
| 49 | + require.NoError(t, m.moveTablesCutOver()) |
| 50 | + |
| 51 | + require.Equal(t, int64(0), atomic.LoadInt64(&ctx.CutOverCompleteFlag), |
| 52 | + "post-state: Noop must not set CutOverCompleteFlag") |
| 53 | + require.Empty(t, calls, "post-state: Noop must not fire any hook") |
| 54 | +} |
| 55 | + |
| 56 | +// TestMoveTablesCutOver_OnBeforeCutOverHookAbortsBeforeRename maps to T0 in |
| 57 | +// 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. |
| 60 | +func TestMoveTablesCutOver_OnBeforeCutOverHookAbortsBeforeRename(t *testing.T) { |
| 61 | + var calls []string |
| 62 | + boom := errors.New("hook says no") |
| 63 | + fakeHooks := &recordingHooks{name: "fake", calls: &calls, errOn: "OnBeforeCutOver", errVal: boom} |
| 64 | + |
| 65 | + ctx := base.NewMigrationContext() |
| 66 | + ctx.Hooks = fakeHooks |
| 67 | + ctx.DatabaseName = "test" |
| 68 | + ctx.OriginalTableName = "t" |
| 69 | + |
| 70 | + m := NewMigrator(ctx, "test") |
| 71 | + |
| 72 | + require.Equal(t, int64(0), atomic.LoadInt64(&ctx.CutOverCompleteFlag), "pre-state: flag must be 0") |
| 73 | + require.Empty(t, calls, "pre-state: no hooks recorded") |
| 74 | + |
| 75 | + err := m.moveTablesCutOver() |
| 76 | + require.Error(t, err) |
| 77 | + require.ErrorIs(t, err, boom) |
| 78 | + require.Contains(t, err.Error(), "on-before-cut-over hook failed") |
| 79 | + |
| 80 | + require.Equal(t, int64(0), atomic.LoadInt64(&ctx.CutOverCompleteFlag), |
| 81 | + "post-state: T0 abort must leave CutOverCompleteFlag unset") |
| 82 | + require.Equal(t, []string{"fake:OnBeforeCutOver"}, calls, |
| 83 | + "post-state: only the failing T0 hook fires; no OnSuccess, no OnBeginPostponed") |
| 84 | +} |
| 85 | + |
| 86 | +// TestMoveTablesCutOver_PostponeGateFiresOnBeginPostponedOnce maps to the |
| 87 | +// postpone-gate decision in #8209-implement-protocol (keep OnBeginPostponed |
| 88 | +// firing logic with the same once-per-cutover semantics as standard cutOver). |
| 89 | +// Also exercises Edge Case Test Quality #5 by asserting both pre-state and |
| 90 | +// post-state of IsPostponingCutOver. |
| 91 | +func TestMoveTablesCutOver_PostponeGateFiresOnBeginPostponedOnce(t *testing.T) { |
| 92 | + flagPath := filepath.Join(t.TempDir(), "postpone.flag") |
| 93 | + require.NoError(t, os.WriteFile(flagPath, nil, 0o644)) |
| 94 | + |
| 95 | + var calls []string |
| 96 | + boom := errors.New("we got past the gate") |
| 97 | + fakeHooks := &recordingHooks{name: "fake", calls: &calls, errOn: "OnBeforeCutOver", errVal: boom} |
| 98 | + |
| 99 | + ctx := base.NewMigrationContext() |
| 100 | + ctx.PostponeCutOverFlagFile = flagPath |
| 101 | + ctx.Hooks = fakeHooks |
| 102 | + |
| 103 | + m := NewMigrator(ctx, "test") |
| 104 | + |
| 105 | + require.Equal(t, int64(0), atomic.LoadInt64(&ctx.IsPostponingCutOver), "pre-state: gate flag must be 0") |
| 106 | + require.FileExists(t, flagPath, "pre-state: postpone flag file present") |
| 107 | + require.Empty(t, calls, "pre-state: no hooks recorded") |
| 108 | + |
| 109 | + // Remove the flag file during the gate's first 1s sleep so the next |
| 110 | + // poll exits cleanly. sleepWhileTrue uses time.Sleep(1 * time.Second). |
| 111 | + go func() { |
| 112 | + time.Sleep(500 * time.Millisecond) |
| 113 | + _ = os.Remove(flagPath) |
| 114 | + }() |
| 115 | + |
| 116 | + err := m.moveTablesCutOver() |
| 117 | + require.ErrorIs(t, err, boom, "expect to bail at T0 hook after gate releases") |
| 118 | + |
| 119 | + onBegin := 0 |
| 120 | + for _, c := range calls { |
| 121 | + if c == "fake:OnBeginPostponed" { |
| 122 | + onBegin++ |
| 123 | + } |
| 124 | + } |
| 125 | + require.Equal(t, 1, onBegin, |
| 126 | + "post-state: OnBeginPostponed must fire exactly once per cutover (idempotent via IsPostponingCutOver)") |
| 127 | + require.Equal(t, int64(0), atomic.LoadInt64(&ctx.IsPostponingCutOver), |
| 128 | + "post-state: gate must reset IsPostponingCutOver to 0 after exit") |
| 129 | + require.Contains(t, calls, "fake:OnBeforeCutOver", |
| 130 | + "post-state: T0 hook must fire after gate releases") |
| 131 | + require.Equal(t, int64(0), atomic.LoadInt64(&ctx.CutOverCompleteFlag), |
| 132 | + "post-state: hook failure must leave CutOverCompleteFlag unset") |
| 133 | +} |
| 134 | + |
| 135 | +// ----------------------------------------------------------------------------- |
| 136 | +// Integration tests - real MySQL via testcontainers, exercise T1/T2/T3. |
| 137 | +// |
| 138 | +// These live under a dedicated suite (NOT MigratorTestSuite) so the parent |
| 139 | +// function name TestMoveTablesCutOver matches the `-run MoveTablesCutOver` |
| 140 | +// verifier alongside the pure unit tests above. SetupSuite duplicates the |
| 141 | +// testcontainer setup pattern used by MigratorTestSuite intentionally to |
| 142 | +// keep this commit's diff strictly additive (no edits to existing tests or |
| 143 | +// shared helpers, per the commit-3 prompt). |
| 144 | +// |
| 145 | +// Known-environmental flakes when Docker/testcontainers is unavailable mirror |
| 146 | +// the same class as #8206; they are not regressions of #8209. |
| 147 | +// ----------------------------------------------------------------------------- |
| 148 | + |
| 149 | +type MoveTablesCutOverSuite struct { |
| 150 | + suite.Suite |
| 151 | + mysqlContainer testcontainers.Container |
| 152 | + db *gosql.DB |
| 153 | +} |
| 154 | + |
| 155 | +func (s *MoveTablesCutOverSuite) SetupSuite() { |
| 156 | + ctx := context.Background() |
| 157 | + mysqlContainer, err := testmysql.Run(ctx, |
| 158 | + testMysqlContainerImage, |
| 159 | + testmysql.WithDatabase(testMysqlDatabase), |
| 160 | + testmysql.WithUsername(testMysqlUser), |
| 161 | + testmysql.WithPassword(testMysqlPass), |
| 162 | + testmysql.WithConfigFile("my.cnf.test"), |
| 163 | + ) |
| 164 | + s.Require().NoError(err) |
| 165 | + s.mysqlContainer = mysqlContainer |
| 166 | + |
| 167 | + dsn, err := mysqlContainer.ConnectionString(ctx) |
| 168 | + s.Require().NoError(err) |
| 169 | + db, err := gosql.Open("mysql", dsn) |
| 170 | + s.Require().NoError(err) |
| 171 | + s.db = db |
| 172 | +} |
| 173 | + |
| 174 | +func (s *MoveTablesCutOverSuite) TearDownSuite() { |
| 175 | + s.Assert().NoError(s.db.Close()) |
| 176 | + s.Assert().NoError(testcontainers.TerminateContainer(s.mysqlContainer)) |
| 177 | +} |
| 178 | + |
| 179 | +func (s *MoveTablesCutOverSuite) SetupTest() { |
| 180 | + _, err := s.db.ExecContext(context.Background(), "CREATE DATABASE IF NOT EXISTS "+testMysqlDatabase) |
| 181 | + s.Require().NoError(err) |
| 182 | +} |
| 183 | + |
| 184 | +func (s *MoveTablesCutOverSuite) TearDownTest() { |
| 185 | + ctx := context.Background() |
| 186 | + _, _ = s.db.ExecContext(ctx, "DROP TABLE IF EXISTS "+getTestTableName()) |
| 187 | + _, _ = s.db.ExecContext(ctx, "DROP TABLE IF EXISTS "+getTestOldTableName()) |
| 188 | +} |
| 189 | + |
| 190 | +// containingDrainGTID returns a fabricated GTID set guaranteed to contain any |
| 191 | +// GTID the test container will assign during the test. RENAME's GTID will be |
| 192 | +// a strict subset of this range, so T3's containment poll passes on iteration 1. |
| 193 | +func (s *MoveTablesCutOverSuite) containingDrainGTID() *mysql.GTIDBinlogCoordinates { |
| 194 | + var serverUUID string |
| 195 | + s.Require().NoError(s.db.QueryRow("SELECT @@server_uuid").Scan(&serverUUID)) |
| 196 | + g, err := mysql.NewGTIDBinlogCoordinates(fmt.Sprintf("%s:1-99999999", serverUUID)) |
| 197 | + s.Require().NoError(err) |
| 198 | + return g |
| 199 | +} |
| 200 | + |
| 201 | +// buildMigrator wires a Migrator with the test container's *sql.DB pinned to |
| 202 | +// inspector.db and a fresh Applier. initialCoords may be nil for the drain- |
| 203 | +// timeout case. |
| 204 | +func (s *MoveTablesCutOverSuite) buildMigrator(fakeHooks *recordingHooks, initialCoords mysql.BinlogCoordinates) (*Migrator, *base.MigrationContext) { |
| 205 | + ctx := context.Background() |
| 206 | + connectionConfig, err := getTestConnectionConfig(ctx, s.mysqlContainer) |
| 207 | + s.Require().NoError(err) |
| 208 | + |
| 209 | + mc := newTestMigrationContext() |
| 210 | + mc.ApplierConnectionConfig = connectionConfig |
| 211 | + mc.InspectorConnectionConfig = connectionConfig |
| 212 | + mc.SetConnectionConfig("innodb") |
| 213 | + mc.Hooks = fakeHooks |
| 214 | + |
| 215 | + m := NewMigrator(mc, "test") |
| 216 | + m.inspector = &Inspector{db: s.db, migrationContext: mc} |
| 217 | + m.applier = NewApplier(mc) |
| 218 | + if initialCoords != nil { |
| 219 | + m.applier.CurrentCoordinatesMutex.Lock() |
| 220 | + m.applier.CurrentCoordinates = initialCoords |
| 221 | + m.applier.CurrentCoordinatesMutex.Unlock() |
| 222 | + } |
| 223 | + return m, mc |
| 224 | +} |
| 225 | + |
| 226 | +// TestHappyPath drives the full T0-T6 protocol against the test container. |
| 227 | +// Asserts hook ordering (T0 then T5), T4 flag set, and the source-side rename. |
| 228 | +// Maps to acceptance criterion #8209 "RENAME executes; drain completes; |
| 229 | +// CutOverCompleteFlag set; on-success hook fires". |
| 230 | +func (s *MoveTablesCutOverSuite) TestHappyPath() { |
| 231 | + ctx := context.Background() |
| 232 | + _, err := s.db.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s (id INT PRIMARY KEY)", getTestTableName())) |
| 233 | + s.Require().NoError(err) |
| 234 | + |
| 235 | + var calls []string |
| 236 | + fakeHooks := &recordingHooks{name: "fake", calls: &calls} |
| 237 | + m, mc := s.buildMigrator(fakeHooks, s.containingDrainGTID()) |
| 238 | + |
| 239 | + s.Require().Equal(int64(0), atomic.LoadInt64(&mc.CutOverCompleteFlag), "pre-state: flag must be 0") |
| 240 | + s.Require().Empty(calls, "pre-state: no hooks recorded") |
| 241 | + |
| 242 | + s.Require().NoError(m.moveTablesCutOver()) |
| 243 | + |
| 244 | + s.Require().Equal(int64(1), atomic.LoadInt64(&mc.CutOverCompleteFlag), |
| 245 | + "post-state: T4 must set CutOverCompleteFlag before T5/T6") |
| 246 | + s.Require().Equal([]string{"fake:OnBeforeCutOver", "fake:OnSuccess"}, calls, |
| 247 | + "post-state: T0 hook precedes T5 hook") |
| 248 | + |
| 249 | + // Source-side post-state (Edge Case Test Quality #5: assert both sides). |
| 250 | + var renamed string |
| 251 | + s.Require().NoError(s.db.QueryRow(fmt.Sprintf("SHOW TABLES IN %s LIKE '_%s_del'", |
| 252 | + testMysqlDatabase, testMysqlTableName)).Scan(&renamed)) |
| 253 | + s.Require().Equal("_"+testMysqlTableName+"_del", renamed) |
| 254 | + |
| 255 | + err = s.db.QueryRow(fmt.Sprintf("SHOW TABLES IN %s LIKE '%s'", |
| 256 | + testMysqlDatabase, testMysqlTableName)).Scan(&renamed) |
| 257 | + s.Require().ErrorIs(err, gosql.ErrNoRows, "original table must no longer exist under its old name") |
| 258 | +} |
| 259 | + |
| 260 | +// TestRenameFailurePropagates maps to T1 failure handling: no source table -> |
| 261 | +// RENAME errors. Verify the wrapped error, no CutOverCompleteFlag set, no |
| 262 | +// OnSuccess fired. |
| 263 | +func (s *MoveTablesCutOverSuite) TestRenameFailurePropagates() { |
| 264 | + // Deliberately no CREATE TABLE. |
| 265 | + var calls []string |
| 266 | + fakeHooks := &recordingHooks{name: "fake", calls: &calls} |
| 267 | + m, mc := s.buildMigrator(fakeHooks, s.containingDrainGTID()) |
| 268 | + |
| 269 | + s.Require().Equal(int64(0), atomic.LoadInt64(&mc.CutOverCompleteFlag), "pre-state: flag must be 0") |
| 270 | + |
| 271 | + err := m.moveTablesCutOver() |
| 272 | + s.Require().Error(err) |
| 273 | + s.Require().Contains(err.Error(), "RENAME failed") |
| 274 | + |
| 275 | + s.Require().Equal(int64(0), atomic.LoadInt64(&mc.CutOverCompleteFlag), |
| 276 | + "post-state: RENAME failure must leave CutOverCompleteFlag unset") |
| 277 | + for _, c := range calls { |
| 278 | + s.Require().NotEqual("fake:OnSuccess", c, "OnSuccess must not fire when RENAME fails") |
| 279 | + } |
| 280 | + s.Require().Equal([]string{"fake:OnBeforeCutOver"}, calls, |
| 281 | + "post-state: only T0 fires before the failed RENAME") |
| 282 | +} |
| 283 | + |
| 284 | +// TestDrainTimeoutPropagates maps to T3 timeout handling: applier coordinates |
| 285 | +// never reach the drain GTID -> drain poll bounded by moveTablesCutOverDrainTimeout |
| 286 | +// returns a wrapped error and the flag is not set. |
| 287 | +func (s *MoveTablesCutOverSuite) TestDrainTimeoutPropagates() { |
| 288 | + ctx := context.Background() |
| 289 | + _, err := s.db.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s (id INT PRIMARY KEY)", getTestTableName())) |
| 290 | + s.Require().NoError(err) |
| 291 | + |
| 292 | + // Patch the package-level timeout/poll just for this test. |
| 293 | + origTimeout, origPoll := moveTablesCutOverDrainTimeout, moveTablesCutOverDrainPollInterval |
| 294 | + moveTablesCutOverDrainTimeout = 200 * time.Millisecond |
| 295 | + moveTablesCutOverDrainPollInterval = 50 * time.Millisecond |
| 296 | + s.T().Cleanup(func() { |
| 297 | + moveTablesCutOverDrainTimeout = origTimeout |
| 298 | + moveTablesCutOverDrainPollInterval = origPoll |
| 299 | + }) |
| 300 | + |
| 301 | + var calls []string |
| 302 | + fakeHooks := &recordingHooks{name: "fake", calls: &calls} |
| 303 | + // initialCoords nil - drain comparison never satisfies. |
| 304 | + m, mc := s.buildMigrator(fakeHooks, nil) |
| 305 | + |
| 306 | + s.Require().Equal(int64(0), atomic.LoadInt64(&mc.CutOverCompleteFlag), "pre-state: flag must be 0") |
| 307 | + |
| 308 | + err = m.moveTablesCutOver() |
| 309 | + s.Require().Error(err) |
| 310 | + s.Require().Contains(err.Error(), "drain poll timed out") |
| 311 | + s.Require().True(strings.HasPrefix(err.Error(), "drain poll timed out"), |
| 312 | + "drain timeout error must name the drain") |
| 313 | + |
| 314 | + s.Require().Equal(int64(0), atomic.LoadInt64(&mc.CutOverCompleteFlag), |
| 315 | + "post-state: drain timeout must abort before T4 flag set") |
| 316 | + for _, c := range calls { |
| 317 | + s.Require().NotEqual("fake:OnSuccess", c, "OnSuccess must not fire on drain timeout") |
| 318 | + } |
| 319 | + s.Require().Equal([]string{"fake:OnBeforeCutOver"}, calls, |
| 320 | + "post-state: only T0 fires before the drain loop") |
| 321 | +} |
| 322 | + |
| 323 | +func TestMoveTablesCutOver(t *testing.T) { |
| 324 | + if testing.Short() { |
| 325 | + t.Skip("skipping integration suite in short mode") |
| 326 | + } |
| 327 | + suite.Run(t, new(MoveTablesCutOverSuite)) |
| 328 | +} |
0 commit comments