Skip to content

Commit e5255c9

Browse files
EmergencyReparentShard: skip zero-position candidates in errant GTID detection (#20831)
Signed-off-by: Tim Vaillancourt <tim@timvaillancourt.com>
1 parent f92fcc1 commit e5255c9

4 files changed

Lines changed: 512 additions & 37 deletions

File tree

doc/design-docs/EmergencyReparentShard.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ The goals, rules and limitations of ERS. Changes to `go/vt/vtctl/reparentutil` m
2222
- Automated ERS callers such as VTOrc must never allow a split-brain promotion; resolving a split brain requires an operator to choose the history to preserve
2323
- The most-advanced tablet becomes the intermediate source, which is a replication source and not an automatic winner: the final primary must come from the filtered valid candidates, and catch up to the intermediate source before the switch
2424
- `PopulateReparentJournal` on the promoted primary is the system of record for promotions: errant GTID detection counts journal rows to decide which tablets can serve as evidence, so every promotion must write it
25+
- A tablet with an empty GTID position cannot corroborate evidence or be promoted over tablets with real history; its surviving reparent journal rows still count as proof of promotion history. When every tablet holding the deepest journal history has an empty position, including when all positions are empty on a shard whose journal shows history, ERS must fail closed, because the remaining candidates provably missed a promotion whose content can no longer be proven. Only a shard with empty positions and empty journals everywhere, and whose topology has never recorded a primary, is treated as uninitialized, where every candidate is an equally valid first primary
2526
- The reparent sorter (via `ElectNewPrimary`) and durability helpers like `canEstablishForTablet` are shared with `PlannedReparentShard`: changes to candidate ordering or semi-sync accounting affect PRS too
2627
- Any new pipeline step that stops replication on a tablet must add that tablet to `replicasToRestart`, so the deferred cleanup can recover it if ERS aborts. The code can't enforce this — review carefully

go/vt/vtctl/grpcvtctldserver/testutil/test_tmclient.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ type TabletManagerClient struct {
301301
PopulateReparentJournalResults map[string]error
302302
// keyed by tablet alias
303303
ReadReparentJournalInfoResults map[string]int32
304+
// keyed by tablet alias; takes precedence over ReadReparentJournalInfoResults
305+
ReadReparentJournalInfoErrors map[string]error
304306
// keyed by tablet alias. once a WaitForPosition call for the tablet has
305307
// succeeded, this value is returned instead of ReadReparentJournalInfoResults,
306308
// mirroring how the reparent journal count only advances once relay logs are
@@ -1004,6 +1006,9 @@ func (fake *TabletManagerClient) ReadReparentJournalInfo(ctx context.Context, ta
10041006
}
10051007
}
10061008

1009+
if err, ok := fake.ReadReparentJournalInfoErrors[key]; ok {
1010+
return 0, err
1011+
}
10071012
if fake.ReadReparentJournalInfoResults == nil {
10081013
return 1, nil
10091014
}

go/vt/vtctl/reparentutil/emergency_reparenter.go

Lines changed: 83 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import (
2222
"fmt"
2323
"maps"
2424
"slices"
25+
"strings"
2526
"sync"
2627
"time"
2728

2829
"vitess.io/vitess/go/event"
2930
"vitess.io/vitess/go/mysql/replication"
31+
"vitess.io/vitess/go/mysql/sqlerror"
3032
"vitess.io/vitess/go/sets"
3133
"vitess.io/vitess/go/stats"
3234
"vitess.io/vitess/go/vt/concurrency"
@@ -384,6 +386,11 @@ func (erp *EmergencyReparenter) reparentShardLocked(ctx context.Context, ev *eve
384386

385387
// For GTID based replication, we will run errant GTID detection.
386388
if isGTIDBased && !splitBrainOverrideActive {
389+
// Errant GTID detection may only treat all-empty candidates as a brand-new
390+
// shard when the topology agrees it was never initialized: a shard that has
391+
// recorded a primary has history to protect, even if every reachable tablet
392+
// lost it
393+
shardNeverInitialized := !ev.ShardInfo.HasPrimary() && ev.ShardInfo.PrimaryTermStartTime == nil
387394
// Failed waiters are only ever removed from a uniform leading group (a
388395
// requireAll wait aborts on failure instead of removing anyone), so a failed
389396
// tablet received exactly what the surviving leaders received, including every
@@ -395,7 +402,7 @@ func (erp *EmergencyReparenter) reparentShardLocked(ctx context.Context, ev *eve
395402
}
396403
}
397404
var starved []string
398-
validCandidates, starved, err = erp.findErrantGTIDs(ctx, validCandidates, stoppedReplicationSnapshot.statusMap, tabletMap, opts.WaitReplicasTimeout, failedEvidence)
405+
validCandidates, starved, err = erp.findErrantGTIDs(ctx, validCandidates, stoppedReplicationSnapshot.statusMap, tabletMap, opts.WaitReplicasTimeout, failedEvidence, shardNeverInitialized)
399406
if err != nil {
400407
return err
401408
}
@@ -451,7 +458,7 @@ func (erp *EmergencyReparenter) reparentShardLocked(ctx context.Context, ev *eve
451458
// truthful, the other candidates genuinely lack journal entries and
452459
// there is nothing more to compare against, same as before this
453460
// optimization: accept it
454-
validCandidates, _, err = erp.findErrantGTIDs(ctx, validCandidates, stoppedReplicationSnapshot.statusMap, tabletMap, opts.WaitReplicasTimeout, failedEvidence)
461+
validCandidates, _, err = erp.findErrantGTIDs(ctx, validCandidates, stoppedReplicationSnapshot.statusMap, tabletMap, opts.WaitReplicasTimeout, failedEvidence, shardNeverInitialized)
455462
if err != nil {
456463
return err
457464
}
@@ -1335,6 +1342,7 @@ func (erp *EmergencyReparenter) findErrantGTIDs(
13351342
tabletMap map[string]*topo.TabletInfo,
13361343
waitReplicasTimeout time.Duration,
13371344
extraEvidence []replication.Position,
1345+
shardNeverInitialized bool,
13381346
) (map[string]*RelayLogPositions, []string, error) {
13391347
allPositionsZero := len(validCandidates) > 0
13401348
for _, positions := range validCandidates {
@@ -1343,14 +1351,16 @@ func (erp *EmergencyReparenter) findErrantGTIDs(
13431351
break
13441352
}
13451353
}
1346-
if allPositionsZero {
1347-
return maps.Clone(validCandidates), nil, nil
1348-
}
13491354

13501355
// First we need to collect the reparent journal length for all the candidates.
13511356
// This will tell us, which of the tablets are severly lagged, and haven't even seen all the primary promotions.
13521357
// Such severely lagging tablets cannot be used to find errant GTIDs in other tablets, seeing that they themselves don't have enough information.
1353-
reparentJournalLen, err := erp.gatherReparenJournalInfo(ctx, validCandidates, tabletMap, waitReplicasTimeout)
1358+
// Zero-position candidates are included: their journal rows survive a GTID wipe and
1359+
// prove the shard has promotion history even when no GTID state is left to compare.
1360+
// A missing journal table is only tolerated when the topology says never initialized
1361+
// and no candidate has any GTIDs; a nonzero position anywhere proves history, so an
1362+
// unreadable journal depth must fail the gather
1363+
reparentJournalLen, err := erp.gatherReparentJournalInfo(ctx, validCandidates, tabletMap, waitReplicasTimeout, shardNeverInitialized && allPositionsZero)
13541364
if err != nil {
13551365
return nil, nil, err
13561366
}
@@ -1361,25 +1371,66 @@ func (erp *EmergencyReparenter) findErrantGTIDs(
13611371
maxLen = max(maxLen, length)
13621372
}
13631373

1364-
// Find the candidates with the maximum length of the reparent journal.
1374+
// A shard where every candidate has an empty GTID position and an empty reparent
1375+
// journal has never seen a promotion: it is being initialized, and every candidate
1376+
// is an equally valid first primary. The topology must agree the shard was never
1377+
// initialized, though: a shard that has recorded a primary has history to protect
1378+
// even when every reachable tablet lost both its GTIDs and its sidecar tables.
1379+
// Empty positions alongside journal history mean the GTID state was wiped instead,
1380+
// which fails closed below.
1381+
if allPositionsZero && maxLen == 0 {
1382+
if !shardNeverInitialized {
1383+
return nil, nil, vterrors.Errorf(vtrpc.Code_FAILED_PRECONDITION, "every candidate reports an empty GTID position and an empty reparent journal, but the shard topology records a previous primary: refusing to re-initialize a shard that has history to protect; restore a tablet with the shard's data before retrying")
1384+
}
1385+
return maps.Clone(validCandidates), nil, nil
1386+
}
1387+
1388+
// A tablet with nil or zero positions has no GTIDs to corroborate anyone and can't be
1389+
// promoted over tablets with real history, so it is dropped from candidacy up front.
1390+
nonZeroCandidates := make(map[string]*RelayLogPositions, len(validCandidates))
1391+
for alias, positions := range validCandidates {
1392+
if positions == nil || positions.IsZero() {
1393+
erp.logger.Warningf("skipping candidate %s during errant GTID detection: nil or zero positions", alias)
1394+
continue
1395+
}
1396+
nonZeroCandidates[alias] = positions
1397+
}
1398+
1399+
// Find the candidates with the maximum length of the reparent journal. A dropped
1400+
// zero-position tablet can't be part of the evidence tier: it has no GTIDs to
1401+
// compare anyone against.
13651402
var maxLenCandidates []string
13661403
for alias, length := range reparentJournalLen {
1367-
if length == maxLen {
1368-
maxLenCandidates = append(maxLenCandidates, alias)
1404+
if length != maxLen {
1405+
continue
13691406
}
1407+
if _, ok := nonZeroCandidates[alias]; !ok {
1408+
continue
1409+
}
1410+
maxLenCandidates = append(maxLenCandidates, alias)
1411+
}
1412+
1413+
// If every tablet holding the latest reparent journal history had its GTID state
1414+
// wiped, the surviving candidates provably missed a promotion and no evidence is
1415+
// left to prove what it contained. Promoting one of them could silently discard
1416+
// the missed history, so fail closed and leave the decision to an operator.
1417+
if len(maxLenCandidates) == 0 && len(reparentJournalLen) > 0 {
1418+
var wipedLeaders []string
1419+
for alias, length := range reparentJournalLen {
1420+
if length == maxLen {
1421+
wipedLeaders = append(wipedLeaders, alias)
1422+
}
1423+
}
1424+
slices.Sort(wipedLeaders)
1425+
return nil, nil, vterrors.Errorf(vtrpc.Code_FAILED_PRECONDITION, "errant GTID detection has no usable evidence: the candidates with the latest reparent journal history (%s, %d entries) have empty GTID positions, so the remaining candidates cannot be proven to have seen the latest promotion; restore the GTID state or data of a wiped tablet before retrying; removing the wiped tablets from the shard instead would discard the missed promotion's transactions", strings.Join(wipedLeaders, ", "), maxLen)
13701426
}
13711427

13721428
// We use all the candidates with the maximum length of the reparent journal to find the errant GTIDs amongst them.
13731429
var maxLenPositions []replication.Position
13741430
var starvedCandidates []string
13751431
updatedValidCandidates := make(map[string]*RelayLogPositions)
13761432
for _, candidate := range maxLenCandidates {
1377-
candidatePositions := validCandidates[candidate]
1378-
if candidatePositions == nil {
1379-
erp.logger.Warningf("skipping candidate %s during errant GTID detection: nil or zero positions", candidate)
1380-
continue
1381-
}
1382-
1433+
candidatePositions := nonZeroCandidates[candidate]
13831434
status, ok := statusMap[candidate]
13841435
if !ok {
13851436
// If the tablet is not in the status map, and has the maximum length of the reparent journal,
@@ -1393,11 +1444,7 @@ func (erp *EmergencyReparenter) findErrantGTIDs(
13931444
// Even in this case, the best we can do is not run errant GTID detection on either, and let the split brain detection code
13941445
// deal with it, if A in fact has errant GTIDs.
13951446
maxLenPositions = append(maxLenPositions, candidatePositions.Combined)
1396-
updatedValidCandidates[candidate] = validCandidates[candidate]
1397-
continue
1398-
}
1399-
if candidatePositions.IsZero() {
1400-
erp.logger.Warningf("skipping candidate %s during errant GTID detection: nil or zero positions", candidate)
1447+
updatedValidCandidates[candidate] = candidatePositions
14011448
continue
14021449
}
14031450
// Store all the other candidate's positions so that we can run errant GTID detection using them.
@@ -1406,10 +1453,7 @@ func (erp *EmergencyReparenter) findErrantGTIDs(
14061453
if otherCandidate == candidate {
14071454
continue
14081455
}
1409-
otherPosition := validCandidates[otherCandidate]
1410-
if otherPosition != nil && !otherPosition.IsZero() {
1411-
otherPositions = append(otherPositions, otherPosition.Combined)
1412-
}
1456+
otherPositions = append(otherPositions, nonZeroCandidates[otherCandidate].Combined)
14131457
}
14141458
otherPositions = append(otherPositions, extraEvidence...)
14151459
// FindErrantGTIDs accepts a candidate's GTID set as-is when there is nothing to
@@ -1429,7 +1473,7 @@ func (erp *EmergencyReparenter) findErrantGTIDs(
14291473
continue
14301474
}
14311475
maxLenPositions = append(maxLenPositions, candidatePositions.Combined)
1432-
updatedValidCandidates[candidate] = validCandidates[candidate]
1476+
updatedValidCandidates[candidate] = candidatePositions
14331477
}
14341478

14351479
// The extra evidence positions also corroborate the lagged tablets below.
@@ -1457,8 +1501,8 @@ func (erp *EmergencyReparenter) findErrantGTIDs(
14571501
// This exact scenario outlined above, can be found in the test for this function, subtest `Case 5a`.
14581502
// The idea is that if the tablet is lagged, then even the server UUID that it is replicating from
14591503
// should not be considered a valid source of writes that no other tablet has.
1460-
candidatePositions := validCandidates[alias]
1461-
if candidatePositions == nil || candidatePositions.IsZero() {
1504+
candidatePositions, ok := nonZeroCandidates[alias]
1505+
if !ok {
14621506
continue
14631507
}
14641508
errantGTIDs, err := replication.FindErrantGTIDs(candidatePositions.Combined, replication.SID{}, maxLenPositions)
@@ -1475,12 +1519,13 @@ func (erp *EmergencyReparenter) findErrantGTIDs(
14751519
return updatedValidCandidates, starvedCandidates, nil
14761520
}
14771521

1478-
// gatherReparenJournalInfo reads the reparent journal information from all the tablets in the valid candidates list.
1479-
func (erp *EmergencyReparenter) gatherReparenJournalInfo(
1522+
// gatherReparentJournalInfo reads the reparent journal information from all the tablets in the valid candidates list.
1523+
func (erp *EmergencyReparenter) gatherReparentJournalInfo(
14801524
ctx context.Context,
14811525
validCandidates map[string]*RelayLogPositions,
14821526
tabletMap map[string]*topo.TabletInfo,
14831527
waitReplicasTimeout time.Duration,
1528+
tolerateMissingJournal bool,
14841529
) (map[string]int32, error) {
14851530
reparentJournalLen := make(map[string]int32)
14861531
var mu sync.Mutex
@@ -1502,6 +1547,15 @@ func (erp *EmergencyReparenter) gatherReparenJournalInfo(
15021547
}
15031548
}()
15041549
length, err = erp.tmc.ReadReparentJournalInfo(groupCtx, tabletMap[alias].Tablet)
1550+
if err != nil && tolerateMissingJournal {
1551+
// A brand-new shard has no sidecar tables yet: treat a missing journal
1552+
// table as zero entries so ERS can still initialize it
1553+
if sqlErr, ok := sqlerror.NewSQLErrorFromError(err).(*sqlerror.SQLError); ok &&
1554+
(sqlErr.Number() == sqlerror.ERNoSuchTable || sqlErr.Number() == sqlerror.ERBadDb) {
1555+
erp.logger.Warningf("treating missing reparent journal table on %s as zero entries during errant GTID detection: %v", alias, err)
1556+
length, err = 0, nil
1557+
}
1558+
}
15051559
mu.Lock()
15061560
defer mu.Unlock()
15071561
reparentJournalLen[alias] = length

0 commit comments

Comments
 (0)