Skip to content

Commit da4e714

Browse files
reparentutil: order reparent candidates by GTID dominance for a consistent sort (#20728)
Signed-off-by: Tim Vaillancourt <tim@timvaillancourt.com>
1 parent 8055e69 commit da4e714

6 files changed

Lines changed: 301 additions & 35 deletions

File tree

changelog/25.0/25.0.0/summary.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
- [Stricter PROXY protocol v1 header validation](#vtgate-proxy-protocol-v1-strictness)
3131
- **[Reparent](#minor-changes-reparent)**
3232
- [`EmergencyReparentShard` no longer waits on replicas that cannot win the election](#ers-lagging-relay-log-wait)
33+
- [Reparent candidate ordering now respects partially ordered GTID histories](#reparent-gtid-candidate-ordering)
3334
- **[VTTablet](#minor-changes-vttablet)**
3435
- [Consolidator Reject on Waiter Cap](#vttablet-consolidator-reject-on-cap)
3536
- [Query timeout for state-changing statements on the streaming path](#vttablet-stream-query-timeout)
@@ -279,6 +280,14 @@ This mirrors a tradeoff `orchestrator` made before Vitess: it never gated dead-p
279280

280281
See [#18529](https://github.com/vitessio/vitess/issues/18529).
281282

283+
#### <a id="reparent-gtid-candidate-ordering"/>Reparent candidate ordering now respects partially ordered GTID histories</a>
284+
285+
GTID containment is pairwise, so a candidate set can mix comparable and divergent histories: candidate A at `p:1-100,a:1-10` is strictly ahead of B at `p:1-100,a:1-5`, while C at `p:1-100,c:1-3` is incomparable with both. The reparent sorter that both `EmergencyReparentShard` and `PlannedReparentShard` use compared such candidates non-transitively, so ordering could depend on map iteration or RPC completion order, and `PlannedReparentShard` could select B even though A was known to be more advanced.
286+
287+
Candidates are now ordered by GTID dominance before the existing promotion-rule, buffer-pool, and tablet-alias tiebreakers, so a dominated candidate can never rank ahead of its dominator regardless of input order. `EmergencyReparentShard` still rejects incomparable candidates as split brain, and `PlannedReparentShard` still chooses among incomparable maximal candidates. Positions that contain each other without being equal (possible with MariaDB GTIDs, where containment ignores the origin server) are now also rejected by `EmergencyReparentShard` as split brain, wherever the pair sits among the candidates; previously a leading pair failed with an internal sorting error, while a pair behind a more advanced candidate was not detected at all.
288+
289+
See [#20579](https://github.com/vitessio/vitess/issues/20579).
290+
282291
### <a id="minor-changes-vttablet"/>VTTablet</a>
283292

284293
#### <a id="vttablet-consolidator-reject-on-cap"/>Consolidator Reject on Waiter Cap</a>

go/vt/vtctl/reparentutil/emergency_reparenter.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -789,16 +789,23 @@ func (erp *EmergencyReparenter) findMostAdvanced(
789789
// We have already removed the tablets with errant GTIDs before calling this function. At this point our winning position must be a
790790
// superset of all the other valid positions. If any position is incomparable with it, then we have a split brain scenario, and we
791791
// should cancel the ERS. Split brain is about divergent received history, so we only compare the Combined positions; the Executed
792-
// positions can be transiently incomparable at an equal Combined position (multi-threaded apply gaps) without any divergence
792+
// positions can be transiently incomparable at an equal Combined position (multi-threaded apply gaps) without any divergence.
793+
// Reciprocally contained but unequal positions are divergent too, containment just can't order them (MariaDB GTID containment
794+
// ignores the origin server), so they must also fail closed. The divergent pair can sit behind a candidate that dominates both
795+
// of them, so reciprocal containment is checked between every pair of candidates, not just against the winning position
793796
for i, position := range tabletPositions {
794797
if haveIncomparablePositions(winningPosition.Combined, position.Combined) {
795798
return nil, nil, vterrors.Errorf(vtrpc.Code_FAILED_PRECONDITION, "split brain detected between servers - %s and %s", topoproto.TabletAliasString(winningPrimaryTablet.Alias), topoproto.TabletAliasString(validTablets[i].Alias))
796799
}
797-
// The sort can't guarantee a maximum at index 0 when some positions are incomparable, so also reject a winner that
798-
// another candidate dominates. This is an invariant check that should never fire, not an expected path
799-
if hasDominantPosition(position.Combined, winningPosition.Combined) {
800+
// Keep the sort's maximum-at-index-zero guarantee as a defense-in-depth invariant.
801+
if hasDominantReparentPosition(position, winningPosition) {
800802
return nil, nil, vterrors.Errorf(vtrpc.Code_INTERNAL, "candidate sorting error: %s has a more advanced position than the chosen candidate %s", topoproto.TabletAliasString(validTablets[i].Alias), topoproto.TabletAliasString(winningPrimaryTablet.Alias))
801803
}
804+
for j := i + 1; j < len(tabletPositions); j++ {
805+
if haveReciprocallyContainedPositions(position.Combined, tabletPositions[j].Combined) {
806+
return nil, nil, vterrors.Errorf(vtrpc.Code_FAILED_PRECONDITION, "split brain detected between servers - %s and %s", topoproto.TabletAliasString(validTablets[i].Alias), topoproto.TabletAliasString(validTablets[j].Alias))
807+
}
808+
}
802809
}
803810

804811
// If we were requested to elect a particular primary, verify it's a valid

go/vt/vtctl/reparentutil/emergency_reparenter_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6627,6 +6627,21 @@ func TestEmergencyReparenter_findMostAdvanced(t *testing.T) {
66276627
Executed: replication.Position{GTIDSet: replication.Mysql56GTIDSet{}},
66286628
}
66296629

6630+
// MariaDB GTID containment ignores the origin server, so these two positions contain
6631+
// each other while holding a different write for sequence 10
6632+
positionMariadbServer1 := &RelayLogPositions{
6633+
Combined: replication.MustParsePosition(replication.MariadbFlavorID, "0-1-10"),
6634+
Executed: replication.MustParsePosition(replication.MariadbFlavorID, "0-1-10"),
6635+
}
6636+
positionMariadbServer2 := &RelayLogPositions{
6637+
Combined: replication.MustParsePosition(replication.MariadbFlavorID, "0-2-10"),
6638+
Executed: replication.MustParsePosition(replication.MariadbFlavorID, "0-2-10"),
6639+
}
6640+
positionMariadbServer1Seq11 := &RelayLogPositions{
6641+
Combined: replication.MustParsePosition(replication.MariadbFlavorID, "0-1-11"),
6642+
Executed: replication.MustParsePosition(replication.MariadbFlavorID, "0-1-11"),
6643+
}
6644+
66306645
tests := []struct {
66316646
name string
66326647
validCandidates map[string]*RelayLogPositions
@@ -6901,6 +6916,72 @@ func TestEmergencyReparenter_findMostAdvanced(t *testing.T) {
69016916
},
69026917
},
69036918
err: "split brain detected between servers",
6919+
}, {
6920+
// reciprocally contained but unequal positions (MariaDB GTIDs with the same
6921+
// domain and sequence from different origin servers) are divergent histories
6922+
// that containment can't order, so ERS must fail closed instead of picking
6923+
// a side of the divergence by tiebreak
6924+
name: "split brain detection on reciprocal but unequal mariadb positions",
6925+
validCandidates: map[string]*RelayLogPositions{
6926+
"zone1-0000000100": positionMariadbServer1,
6927+
"zone1-0000000101": positionMariadbServer2,
6928+
},
6929+
tabletMap: map[string]*topo.TabletInfo{
6930+
"zone1-0000000100": {
6931+
Tablet: &topodatapb.Tablet{
6932+
Alias: &topodatapb.TabletAlias{
6933+
Cell: "zone1",
6934+
Uid: 100,
6935+
},
6936+
},
6937+
},
6938+
"zone1-0000000101": {
6939+
Tablet: &topodatapb.Tablet{
6940+
Alias: &topodatapb.TabletAlias{
6941+
Cell: "zone1",
6942+
Uid: 101,
6943+
},
6944+
},
6945+
},
6946+
},
6947+
err: "split brain detected between servers",
6948+
}, {
6949+
// the divergent pair can sit behind a candidate that dominates both of them,
6950+
// so reciprocal containment must be checked between every pair of candidates,
6951+
// not just against the winning position
6952+
name: "split brain detection on reciprocal mariadb positions behind the winner",
6953+
validCandidates: map[string]*RelayLogPositions{
6954+
"zone1-0000000100": positionMariadbServer1Seq11,
6955+
"zone1-0000000101": positionMariadbServer1,
6956+
"zone1-0000000102": positionMariadbServer2,
6957+
},
6958+
tabletMap: map[string]*topo.TabletInfo{
6959+
"zone1-0000000100": {
6960+
Tablet: &topodatapb.Tablet{
6961+
Alias: &topodatapb.TabletAlias{
6962+
Cell: "zone1",
6963+
Uid: 100,
6964+
},
6965+
},
6966+
},
6967+
"zone1-0000000101": {
6968+
Tablet: &topodatapb.Tablet{
6969+
Alias: &topodatapb.TabletAlias{
6970+
Cell: "zone1",
6971+
Uid: 101,
6972+
},
6973+
},
6974+
},
6975+
"zone1-0000000102": {
6976+
Tablet: &topodatapb.Tablet{
6977+
Alias: &topodatapb.TabletAlias{
6978+
Cell: "zone1",
6979+
Uid: 102,
6980+
},
6981+
},
6982+
},
6983+
},
6984+
err: "split brain detected between servers",
69046985
},
69056986
}
69066987

go/vt/vtctl/reparentutil/reparent_sorter.go

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,26 @@ import (
2929
// reparentSorter sorts tablets by GTID positions and Promotion rules aimed at finding the best
3030
// candidate for intermediate promotion in emergency reparent shard, and the new primary in planned reparent shard
3131
type reparentSorter struct {
32-
tablets []*topodatapb.Tablet
33-
positions []*RelayLogPositions
34-
innodbBufferPool []int
35-
durability policy.Durabler
32+
tablets []*topodatapb.Tablet
33+
positions []*RelayLogPositions
34+
combinedDominatedCount []int
35+
executedDominatedCount []int
36+
innodbBufferPool []int
37+
durability policy.Durabler
3638
}
3739

3840
// newReparentSorter creates a new reparentSorter
3941
func newReparentSorter(tablets []*topodatapb.Tablet, positions []*RelayLogPositions, innodbBufferPool []int, durability policy.Durabler) *reparentSorter {
4042
return &reparentSorter{
41-
tablets: tablets,
42-
positions: positions,
43+
tablets: tablets,
44+
positions: positions,
45+
combinedDominatedCount: dominatedCountsForSort(tablets, positions, func(moreAdvanced, lessAdvanced *RelayLogPositions) bool {
46+
return hasDominantPosition(moreAdvanced.Combined, lessAdvanced.Combined)
47+
}),
48+
executedDominatedCount: dominatedCountsForSort(tablets, positions, func(moreAdvanced, lessAdvanced *RelayLogPositions) bool {
49+
return moreAdvanced.Combined.Equal(lessAdvanced.Combined) &&
50+
hasDominantPosition(moreAdvanced.Executed, lessAdvanced.Executed)
51+
}),
4352
durability: durability,
4453
innodbBufferPool: innodbBufferPool,
4554
}
@@ -52,6 +61,8 @@ func (rs *reparentSorter) Len() int { return len(rs.tablets) }
5261
func (rs *reparentSorter) Swap(i, j int) {
5362
rs.tablets[i], rs.tablets[j] = rs.tablets[j], rs.tablets[i]
5463
rs.positions[i], rs.positions[j] = rs.positions[j], rs.positions[i]
64+
rs.combinedDominatedCount[i], rs.combinedDominatedCount[j] = rs.combinedDominatedCount[j], rs.combinedDominatedCount[i]
65+
rs.executedDominatedCount[i], rs.executedDominatedCount[j] = rs.executedDominatedCount[j], rs.executedDominatedCount[i]
5566
if len(rs.innodbBufferPool) != 0 {
5667
rs.innodbBufferPool[i], rs.innodbBufferPool[j] = rs.innodbBufferPool[j], rs.innodbBufferPool[i]
5768
}
@@ -71,33 +82,14 @@ func (rs *reparentSorter) Less(i, j int) bool {
7182
return true
7283
}
7384

74-
jPositions := rs.positions[j]
75-
iPositions := rs.positions[i]
76-
77-
// sort by dominance of the combined positions first. GTID positions are partially
78-
// ordered, so a pair can also be incomparable (disjoint UUIDs); those fall through to
79-
// the tiebreakers below to keep the sort deterministic. this can't make the sort a
80-
// total order, so findMostAdvanced re-checks the winner after sorting.
81-
if hasDominantPosition(iPositions.Combined, jPositions.Combined) {
82-
return true
83-
}
84-
if hasDominantPosition(jPositions.Combined, iPositions.Combined) {
85-
return false
85+
if rs.combinedDominatedCount[i] != rs.combinedDominatedCount[j] {
86+
return rs.combinedDominatedCount[i] < rs.combinedDominatedCount[j]
8687
}
8788

88-
// if the combined positions are equal, sort by the executed GTID positions. this
89-
// prefers tablets with less SQL delay, which would otherwise slow down the reparent.
90-
if iPositions.Combined.Equal(jPositions.Combined) {
91-
if hasDominantPosition(iPositions.Executed, jPositions.Executed) {
92-
return true
93-
}
94-
if hasDominantPosition(jPositions.Executed, iPositions.Executed) {
95-
return false
96-
}
89+
if rs.executedDominatedCount[i] != rs.executedDominatedCount[j] {
90+
return rs.executedDominatedCount[i] < rs.executedDominatedCount[j]
9791
}
9892

99-
// at this point, neither tablet is ahead of the other
100-
// so we check their promotion rules
10193
jPromotionRule := policy.PromotionRule(rs.durability, rs.tablets[j])
10294
iPromotionRule := policy.PromotionRule(rs.durability, rs.tablets[i])
10395

@@ -123,6 +115,52 @@ func (rs *reparentSorter) Less(i, j int) bool {
123115
return rs.tablets[i].Alias.Uid < rs.tablets[j].Alias.Uid
124116
}
125117

118+
// dominatedCountsForSort returns, for each candidate, how many other candidates
119+
// strictly dominate it under the dominates predicate. The result is only meaningful
120+
// as a sort key: a lower count means more advanced, and the maximal candidates that
121+
// nothing dominates all have count 0.
122+
//
123+
// This count is what lets Less sort safely. dominates is only a partial order, so
124+
// comparing two candidates head-to-head is not transitive — with an incomparable
125+
// third candidate in play, sort.Sort can otherwise seat a dominated candidate above
126+
// the one that dominates it. Counting dominators sidesteps that, because dominates
127+
// itself is transitive: whatever dominates a candidate also dominates everything below
128+
// it, so a dominated candidate always has a strictly higher count than its dominator.
129+
// Ordering by ascending count therefore never places a candidate ahead of one that
130+
// dominates it.
131+
//
132+
// Counts are not unique. Incomparable candidates (e.g. divergent GTID histories) can
133+
// share a count; the caller breaks those ties with its remaining preferences.
134+
func dominatedCountsForSort(tablets []*topodatapb.Tablet, positions []*RelayLogPositions, dominates func(*RelayLogPositions, *RelayLogPositions) bool) []int {
135+
dominatedCounts := make([]int, len(positions))
136+
for i := range positions {
137+
if tablets[i] == nil {
138+
continue
139+
}
140+
for j := range positions {
141+
if i == j || tablets[j] == nil {
142+
continue
143+
}
144+
if dominates(positions[j], positions[i]) {
145+
dominatedCounts[i]++ // one more candidate strictly dominates i
146+
}
147+
}
148+
}
149+
return dominatedCounts
150+
}
151+
152+
// hasDominantReparentPosition reports whether moreAdvanced is strictly ahead of
153+
// lessAdvanced under the same two-level order the sorter uses: a strictly greater
154+
// received (Combined) history, or an equal received history with strictly more of it
155+
// applied (Executed). findMostAdvanced uses it as a defense-in-depth check that the
156+
// sort really did place the maximum at index 0 — it should never find a candidate that
157+
// dominates the chosen winner.
158+
func hasDominantReparentPosition(moreAdvanced, lessAdvanced *RelayLogPositions) bool {
159+
return hasDominantPosition(moreAdvanced.Combined, lessAdvanced.Combined) ||
160+
(moreAdvanced.Combined.Equal(lessAdvanced.Combined) &&
161+
hasDominantPosition(moreAdvanced.Executed, lessAdvanced.Executed))
162+
}
163+
126164
// sortTabletsForReparent sorts the tablets, given their positions for emergency reparent shard and planned reparent shard.
127165
// Tablets are sorted first by their replication positions, with ties broken by the promotion rules.
128166
func sortTabletsForReparent(tablets []*topodatapb.Tablet, positions []*RelayLogPositions, innodbBufferPool []int, durability policy.Durabler) error {

0 commit comments

Comments
 (0)