@@ -34,6 +34,8 @@ import (
3434 "github.com/stretchr/testify/assert"
3535 "github.com/stretchr/testify/require"
3636 "go.uber.org/mock/gomock"
37+ "google.golang.org/grpc/codes"
38+ "google.golang.org/grpc/status"
3739
3840 "vitess.io/vitess/go/mysql/replication"
3941 "vitess.io/vitess/go/vt/external/golib/sqlutils"
@@ -1411,7 +1413,117 @@ func TestReconcileStaleTopoPrimaryTopoTimeout(t *testing.T) {
14111413 })
14121414}
14131415
1414- // TestRestartDirectReplicasTimeout verifies that restartDirectReplicas does not block forever if an RPC hangs.
1416+ func TestRestartReplicationFallback (t * testing.T ) {
1417+ oldTMC := tmc
1418+ t .Cleanup (func () {
1419+ tmc = oldTMC
1420+ })
1421+
1422+ oldRemoteOpTimeout := topo .RemoteOperationTimeout
1423+ topo .RemoteOperationTimeout = 100 * time .Millisecond
1424+ t .Cleanup (func () {
1425+ topo .RemoteOperationTimeout = oldRemoteOpTimeout
1426+ })
1427+
1428+ tablet := & topodatapb.Tablet {Alias : & topodatapb.TabletAlias {Cell : "zone1" , Uid : 101 }}
1429+ logger := log .NewPrefixedLogger ("test-restart-replication-fallback" )
1430+
1431+ setup := func (t * testing.T ) * tmcmock.MockTabletManagerClient {
1432+ mockController := gomock .NewController (t )
1433+ mockTMC := tmcmock .NewMockTabletManagerClient (mockController )
1434+ tmc = mockTMC
1435+ return mockTMC
1436+ }
1437+
1438+ // The server may have run STOP REPLICA before timing out, so the fallback
1439+ // must run while the recovery context is still alive.
1440+ t .Run ("fallback starts replication after RPC timeout" , func (t * testing.T ) {
1441+ mockTMC := setup (t )
1442+ ctx , cancel := context .WithCancel (t .Context ())
1443+ t .Cleanup (cancel )
1444+
1445+ gomock .InOrder (
1446+ mockTMC .EXPECT ().
1447+ RestartReplication (gomock .Any (), tablet , true ).
1448+ DoAndReturn (func (restartCtx context.Context , _ * topodatapb.Tablet , _ bool ) error {
1449+ <- restartCtx .Done ()
1450+ return restartCtx .Err ()
1451+ }),
1452+ mockTMC .EXPECT ().
1453+ StartReplication (gomock .Any (), tablet , true ).
1454+ DoAndReturn (func (ctx context.Context , _ * topodatapb.Tablet , _ bool ) error {
1455+ require .NoError (t , ctx .Err ())
1456+ _ , ok := ctx .Deadline ()
1457+ require .True (t , ok , "cleanup context must have a deadline" )
1458+ return nil
1459+ }),
1460+ )
1461+
1462+ err := restartReplication (ctx , tablet , true , logger )
1463+ require .ErrorIs (t , err , context .DeadlineExceeded )
1464+ })
1465+
1466+ // When the fallback also fails, the returned error must still unwrap to
1467+ // the original RestartReplication error so callers can match on it.
1468+ t .Run ("fallback failure preserves original error" , func (t * testing.T ) {
1469+ mockTMC := setup (t )
1470+
1471+ gomock .InOrder (
1472+ mockTMC .EXPECT ().
1473+ RestartReplication (gomock .Any (), tablet , true ).
1474+ DoAndReturn (func (restartCtx context.Context , _ * topodatapb.Tablet , _ bool ) error {
1475+ <- restartCtx .Done ()
1476+ return restartCtx .Err ()
1477+ }),
1478+ mockTMC .EXPECT ().
1479+ StartReplication (gomock .Any (), tablet , true ).
1480+ Return (errors .New ("start failed" )),
1481+ )
1482+
1483+ err := restartReplication (t .Context (), tablet , true , logger )
1484+ require .ErrorIs (t , err , context .DeadlineExceeded )
1485+ require .ErrorContains (t , err , "failed to ensure replication was started" )
1486+ })
1487+
1488+ // A canceled recovery context means the recovery is being torn down; the
1489+ // detached fallback would only delay eg.Wait() and the next recovery.
1490+ t .Run ("fallback skipped when recovery context is canceled" , func (t * testing.T ) {
1491+ mockTMC := setup (t )
1492+ ctx , cancel := context .WithCancel (t .Context ())
1493+ t .Cleanup (cancel )
1494+
1495+ mockTMC .EXPECT ().
1496+ RestartReplication (gomock .Any (), tablet , true ).
1497+ DoAndReturn (func (context.Context , * topodatapb.Tablet , bool ) error {
1498+ cancel ()
1499+ return context .Canceled
1500+ })
1501+ mockTMC .EXPECT ().
1502+ StartReplication (gomock .Any (), gomock .Any (), gomock .Any ()).
1503+ Times (0 )
1504+
1505+ err := restartReplication (ctx , tablet , true , logger )
1506+ require .ErrorIs (t , err , context .Canceled )
1507+ })
1508+
1509+ // On transport errors the RPC never reached the tablet, so STOP cannot
1510+ // have run and the fallback would waste a full RemoteOperationTimeout.
1511+ t .Run ("fallback skipped when tablet is unreachable" , func (t * testing.T ) {
1512+ mockTMC := setup (t )
1513+
1514+ mockTMC .EXPECT ().
1515+ RestartReplication (gomock .Any (), tablet , true ).
1516+ Return (status .Error (codes .Unavailable , "connection refused" ))
1517+ mockTMC .EXPECT ().
1518+ StartReplication (gomock .Any (), gomock .Any (), gomock .Any ()).
1519+ Times (0 )
1520+
1521+ err := restartReplication (t .Context (), tablet , true , logger )
1522+ require .Equal (t , codes .Unavailable , status .Code (err ))
1523+ })
1524+ }
1525+
1526+ // TestRestartDirectReplicasTimeout verifies that restartDirectReplicas does not block forever if replication RPCs hang.
14151527func TestRestartDirectReplicasTimeout (t * testing.T ) {
14161528 orcDB , fromCache , err := db .OpenVTOrcWithCache ()
14171529 require .NoError (t , err )
@@ -1499,8 +1611,15 @@ func TestRestartDirectReplicasTimeout(t *testing.T) {
14991611 // when the passed context is canceled.
15001612 mockTMC := tmcmock .NewMockTabletManagerClient (mockController )
15011613 mockTMC .EXPECT ().
1502- StopReplication (gomock .Any (), gomock .Any ()).
1503- DoAndReturn (func (ctx context.Context , _ * topodatapb.Tablet ) error {
1614+ RestartReplication (gomock .Any (), gomock .Any (), gomock .Any ()).
1615+ DoAndReturn (func (ctx context.Context , _ * topodatapb.Tablet , _ bool ) error {
1616+ <- ctx .Done ()
1617+ return ctx .Err ()
1618+ }).
1619+ Times (1 )
1620+ mockTMC .EXPECT ().
1621+ StartReplication (gomock .Any (), gomock .Any (), gomock .Any ()).
1622+ DoAndReturn (func (ctx context.Context , _ * topodatapb.Tablet , _ bool ) error {
15041623 <- ctx .Done ()
15051624 return ctx .Err ()
15061625 }).
@@ -1541,10 +1660,13 @@ func TestRestartDirectReplicasTimeout(t *testing.T) {
15411660 }()
15421661
15431662 // Let the recovery goroutine reach a blocked state before advancing fake time (in this case,
1544- // hanging on the StopReplication RPC).
1663+ // hanging on the RestartReplication RPC).
1664+ synctest .Wait ()
1665+
1666+ // Move fake time beyond both RPC timeout boundaries.
1667+ time .Sleep (topo .RemoteOperationTimeout + time .Nanosecond )
15451668 synctest .Wait ()
15461669
1547- // Move fake time just beyond the expected RPC timeout boundary.
15481670 time .Sleep (topo .RemoteOperationTimeout + time .Nanosecond )
15491671 synctest .Wait ()
15501672
@@ -1555,7 +1677,7 @@ func TestRestartDirectReplicasTimeout(t *testing.T) {
15551677 require .NotNil (t , result .topologyRecovery , "topology recovery record must be returned" )
15561678 require .ErrorIs (t , result .err , context .DeadlineExceeded , "restartDirectReplicas must timeout and return when a replication RPC hangs indefinitely" )
15571679 default :
1558- require .FailNowf (t , "restartDirectReplicas did not return" , "expected timeout after %s when a replication RPC hangs indefinitely" , topo .RemoteOperationTimeout )
1680+ require .FailNowf (t , "restartDirectReplicas did not return" , "expected timeout after %s when replication RPCs hang indefinitely" , 2 * topo .RemoteOperationTimeout )
15591681 }
15601682
15611683 activeRecoveries , err := ReadActiveClusterRecoveries (keyspace , shard )
0 commit comments