Skip to content

Commit 54652ab

Browse files
committed
test(move-tables): add unit and integration tests for moveTablesCutOver (#8209)
Adds 3 pure-unit + 3 testcontainers-backed tests covering moveTablesCutOver protocol mechanics: 1. NoopShortCircuits - Noop returns immediately; no hooks, no flag 2. OnBeforeCutOverHookAborts* - T0 hook failure aborts BEFORE T1 RENAME (proxy: nil inspector.db would panic) 3. PostponeGateFiresOnce - OnBeginPostponed fires exactly once; IsPostponingCutOver resets to 0 4. TestHappyPath - full T0-T6: hook order, T4 flag set, source-side rename verified both sides 5. TestRenameFailurePropagates - T1 RENAME error wrapped; flag stays 0; no OnSuccess 6. TestDrainTimeoutPropagates - T3 timeout wrapped via patched package vars (t.Cleanup restored); flag stays 0 End-to-end acceptance criteria (DML drain under load, pt-table-checksum parity) are validated against the multi-cluster test bed separately. Ref: database-infrastructure#8209
1 parent 60acf2c commit 54652ab

1 file changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
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

Comments
 (0)