Skip to content

Commit 4faa1e0

Browse files
vtorc: use RestartReplication RPC in restartDirectReplicas (#20719)
Signed-off-by: Tim Vaillancourt <tim@timvaillancourt.com>
1 parent b1cbeb5 commit 4faa1e0

4 files changed

Lines changed: 290 additions & 32 deletions

File tree

go/vt/vtorc/logic/topology_recovery.go

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import (
3131

3232
"github.com/patrickmn/go-cache"
3333
"golang.org/x/sync/errgroup"
34+
"google.golang.org/grpc/codes"
35+
"google.golang.org/grpc/status"
3436

3537
"vitess.io/vitess/go/stats"
3638
"vitess.io/vitess/go/vt/log"
@@ -571,16 +573,11 @@ func restartDirectReplicas(ctx context.Context, analysisEntry *inst.DetectionAna
571573
logger.Info("Restarting replication on direct replica " + tabletAliasString)
572574
_ = AuditTopologyRecovery(topologyRecovery, "Restarting replication on direct replica "+tabletAliasString)
573575

574-
if err := stopReplication(ctx, tablet); err != nil {
575-
logger.Error(fmt.Sprintf("Failed to stop replication on %s: %v", tabletAliasString, err))
576-
return err
577-
}
578-
579576
// Determine if this replica should use semi-sync based on the durability policy
580577
semiSync := policy.IsReplicaSemiSync(durabilityPolicy, primaryTablet, tablet)
581578

582-
if err := startReplication(ctx, tablet, semiSync); err != nil {
583-
logger.Error(fmt.Sprintf("Failed to start replication on %s: %v", tabletAliasString, err))
579+
if err := restartReplication(ctx, tablet, semiSync, logger); err != nil {
580+
logger.Error(fmt.Sprintf("Failed to restart replication on %s: %v", tabletAliasString, err))
584581
return err
585582
}
586583
logger.Info("Successfully restarted replication on " + tabletAliasString)
@@ -608,20 +605,39 @@ func getShardTablets(ctx context.Context, keyspace, shard string) ([]*topo.Table
608605
return ts.GetTabletsByShard(ctx, keyspace, shard)
609606
}
610607

611-
// stopReplication calls StopReplication RPC for the given tablet with a timeout.
612-
func stopReplication(ctx context.Context, tablet *topodatapb.Tablet) error {
613-
ctx, cancel := context.WithTimeout(ctx, topo.RemoteOperationTimeout)
614-
defer cancel()
608+
// restartReplication calls RestartReplication RPC for the given tablet with a timeout.
609+
func restartReplication(ctx context.Context, tablet *topodatapb.Tablet, semiSync bool, logger *log.PrefixedLogger) error {
610+
restartCtx, restartCancel := context.WithTimeout(ctx, topo.RemoteOperationTimeout)
611+
err := tmc.RestartReplication(restartCtx, tablet, semiSync)
612+
restartCancel()
613+
if err == nil {
614+
return nil
615+
}
615616

616-
return tmc.StopReplication(ctx, tablet)
617-
}
617+
// Skip the fallback when the recovery context is done, since the detached
618+
// fallback cannot be canceled and would only delay recovery teardown, or
619+
// when the RPC never reached the tablet, in which case STOP cannot have run.
620+
if ctx.Err() != nil || status.Code(err) == codes.Unavailable {
621+
return err
622+
}
618623

619-
// startReplication calls StartReplication RPC for the given tablet with a timeout.
620-
func startReplication(ctx context.Context, tablet *topodatapb.Tablet, semiSync bool) error {
621-
ctx, cancel := context.WithTimeout(ctx, topo.RemoteOperationTimeout)
622-
defer cancel()
624+
// TODO: Remove this StartReplication fallback in v26, when all supported
625+
// vttablets include detached post-STOP cleanup in RestartReplication.
626+
tabletAlias := topoproto.TabletAliasString(tablet.Alias)
627+
logger.Warn("RestartReplication failed; attempting to ensure replication is started",
628+
slog.String("tablet", tabletAlias),
629+
slog.Any("error", err),
630+
)
631+
632+
startCtx, startCancel := context.WithTimeout(context.WithoutCancel(ctx), topo.RemoteOperationTimeout)
633+
defer startCancel()
634+
if startErr := tmc.StartReplication(startCtx, tablet, semiSync); startErr != nil {
635+
// Wrap with %w (not vterrors.Wrapf, which has no Unwrap) so callers can
636+
// still match on the original RestartReplication error.
637+
return fmt.Errorf("failed to ensure replication was started on tablet %s after RestartReplication error (%v): %w", tabletAlias, startErr, err)
638+
}
623639

624-
return tmc.StartReplication(ctx, tablet, semiSync)
640+
return err
625641
}
626642

627643
// isERSEnabled returns true if ERS can be used globally or for the given keyspace.

go/vt/vtorc/logic/topology_recovery_test.go

Lines changed: 128 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
14151527
func 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)

go/vt/vttablet/tabletmanager/rpc_replication.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,17 +340,28 @@ func (tm *TabletManager) RestartReplication(ctx context.Context, semiSync bool)
340340
return err
341341
}
342342

343-
semiSyncAction, err := tm.convertBoolToSemiSyncAction(ctx, semiSync)
343+
// Once STOP succeeds, run the post-STOP work with cancellation detached so
344+
// caller cancellation cannot leave replication stopped. The detached work is
345+
// bounded by the caller's remaining budget, capped at RemoteOperationTimeout,
346+
// so the action lock cannot be held well beyond the caller's deadline.
347+
postStopTimeout := topo.RemoteOperationTimeout
348+
if deadline, ok := ctx.Deadline(); ok {
349+
postStopTimeout = min(time.Until(deadline), postStopTimeout)
350+
}
351+
postStopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), postStopTimeout)
352+
defer cancel()
353+
354+
semiSyncAction, err := tm.convertBoolToSemiSyncAction(postStopCtx, semiSync)
344355
if err != nil {
345356
return err
346357
}
347358

348-
if err := tm.fixSemiSync(ctx, tm.Tablet().Type, semiSyncAction); err != nil {
359+
if err := tm.fixSemiSync(postStopCtx, tm.Tablet().Type, semiSyncAction); err != nil {
349360
return err
350361
}
351362

352363
// Start replication
353-
return tm.startReplicationRecoverable(ctx)
364+
return tm.startReplicationRecoverable(postStopCtx)
354365
}
355366

356367
// StartReplicationUntilAfter will start the replication and let it catch up

0 commit comments

Comments
 (0)