Skip to content

mcs, tso: fix expected-primary transient marker races - #11123

Open
bufferflies wants to merge 5 commits into
tikv:masterfrom
bufferflies:fix-expected-primary-marker-races
Open

mcs, tso: fix expected-primary transient marker races#11123
bufferflies wants to merge 5 commits into
tikv:masterfrom
bufferflies:fix-expected-primary-marker-races

Conversation

@bufferflies

@bufferflies bufferflies commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: Close #11122

The expected-primary transient marker mechanism (introduced in aa5a988) has four correctness gaps that let {service}/primary/transfer silently no-op or be bypassed under leadership churn. See #11122 for the full analysis and reproduction reasoning behind each one.

What is changed and how does it work?

The expected-primary transient marker mechanism has four correctness gaps
that let `{service}/primary/transfer` silently no-op or be bypassed:

- markExpectedPrimaryFlag wrote the marker unconditionally; a caller that lost
  leadership between its IsServing() check and the write could still publish
  a marker that the real, already-serving primary never reacts to.
- DeleteExpectedPrimaryFlag could not tell "marker gone" from "marker
  overwritten by a newer transfer", so a newer transfer's target was never
  promoted when the write raced a winning campaign.
- ExpectedPrimaryCmp returned no guard for the empty-marker case, letting a
  campaigner that observed no transfer win anyway if a transfer installed a
  marker and released the leader key in the meantime.
- Guarding the marker write on the leader key's Value is not enough to fence
  a specific election term, since MemberValue() never changes for a
  participant's lifetime; guard on CreateRevision instead, captured right
  after the IsServing() check rather than right before the write.

markExpectedPrimaryFlag now takes extra etcd comparisons folded into the same
transaction as the Put. DeleteExpectedPrimaryFlag takes the campaigning
participant and returns whether it was superseded by a newer transfer, so its
three callers (scheduling, resource manager, TSO) step down instead of
silently keeping serving. ExpectedPrimaryCmp always returns a real
comparison, asserting the marker is absent (CreateRevision == 0) when no
transfer was observed. TransferPrimary now reads the leader key right after
its IsServing() check and fences the marker write on that CreateRevision, and
revokes the newly granted lease on any failure path.

Update: dropped post-transfer verification, capped marker TTL at 1 lease

An earlier revision of this PR added waitForPrimaryTransfer: after marking
and resigning, TransferPrimary polled the leader key and only reported
success once the target was observed stably holding it. Review discussion
(see the thread on #11122 / the linked pd-cse PR) established that this
verification only narrowed, never eliminated, the gap between the leader
key being written and the target actually finishing initialization (TSO's
syncTimestamp in particular does real I/O and can fail well after the key
looks stable) - it could not be made airtight without querying the target's
own local serving state instead of polling etcd, which is out of scope here.
Meanwhile it made the API call block for up to the marker's TTL and could
report a false failure to a caller with a short-lived context even while the
transfer was proceeding normally in the background.

Given the verification could not be made complete anyway, this PR now drops
it: TransferPrimary reports success as soon as the marker is written and
the current primary has resigned, without waiting for the target to actually
win or initialize. The other correctness fixes above (mark-before-resign
ordering, the atomic leader-key guard, DeleteExpectedPrimaryFlag clearing
the marker as soon as any member wins a campaign) are unaffected.

Without verification, the worst-case unavailability a transfer can leave
behind - when the target never wins a single campaign at all (down,
unreachable, or stuck) - is bounded by the marker's TTL
(TransferPrimaryLeaseMultiplier * leaderLease), since every other candidate
backs off in its favor for as long as the marker is valid. This PR also
shrinks that multiplier from 3 to 1: a target that does win clears the marker
immediately regardless of whether it goes on to initialize successfully, so
the multiplier's only effect is on the "never wins even once" case, and there
is no reason to let that cost more than one leader lease - the same duration
the cluster already tolerates a primary being unreachable everywhere else.

Check List

Tests

  • Unit test
  • Integration test

Side effects

  • Possible performance regression
  • Increased code complexity

Release note

Fix races in the primary-transfer marker mechanism that could let `{service}/primary/transfer` silently no-op or be bypassed under leadership churn.

Summary by CodeRabbit

  • Improvements
    • Improved primary transfers with stronger safeguards against stale or superseded transfer requests.
    • Primary members now step down promptly when a transfer target changes, helping prevent conflicting leadership.
    • Transfer operations are more reliably protected during leadership changes and recovery scenarios.
    • Reduced the transfer lease window from three lease durations to one, enabling faster recovery when transfers do not complete.

The expected-primary transient marker mechanism introduced in aa5a988 has
four correctness gaps that let `{service}/primary/transfer` silently no-op or
be bypassed:

- markExpectedPrimaryFlag wrote the marker unconditionally; a caller that lost
  leadership between its IsServing() check and the write could still publish
  a marker that the real, already-serving primary never reacts to.
- DeleteExpectedPrimaryFlag could not tell "marker gone" from "marker
  overwritten by a newer transfer", so a newer transfer's target was never
  promoted when the write raced a winning campaign.
- ExpectedPrimaryCmp returned no guard for the empty-marker case, letting a
  campaigner that observed no transfer win anyway if a transfer installed a
  marker and released the leader key in the meantime.
- Guarding the marker write on the leader key's Value is not enough to fence
  a specific election term, since MemberValue() never changes for a
  participant's lifetime; guard on CreateRevision instead, captured right
  after the IsServing() check rather than right before the write.

markExpectedPrimaryFlag now takes extra etcd comparisons folded into the same
transaction as the Put. DeleteExpectedPrimaryFlag takes the campaigning
participant and returns whether it was superseded by a newer transfer, so its
three callers (scheduling, resource manager, TSO) step down instead of
silently keeping serving. ExpectedPrimaryCmp always returns a real
comparison, asserting the marker is absent (CreateRevision == 0) when no
transfer was observed. TransferPrimary now reads the leader key right after
its IsServing() check and fences the marker write on that CreateRevision, and
revokes the newly granted lease on any failure path.

See tikv#11122 for the analysis and reproduction reasoning behind each issue.

Signed-off-by: bufferflies <1045931706@qq.com>
@ti-chi-bot ti-chi-bot Bot added do-not-merge/needs-triage-completed release-note Denotes a PR that will be considered when it comes time to generate release notes. dco-signoff: yes Indicates the PR's author has signed the dco. labels Aug 6, 2026
@ti-chi-bot

ti-chi-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign qiuyesuifeng for approval. For more information see the Code Review Process.
Please ensure that each of them provides their approval before proceeding.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@ti-chi-bot ti-chi-bot Bot added the size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. label Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change fences expected-primary marker writes to the observed leadership term, reconciles marker replacement during elections, and makes campaigners step down when superseded. Tests cover marker races, lease cleanup, and election-term fencing. Transfer recovery comments now describe the one-lease window.

Changes

Expected-primary marker reconciliation

Layer / File(s) Summary
Marker comparison and reconciliation
pkg/mcs/utils/expected_primary.go, pkg/mcs/utils/expected_primary_test.go
ExpectedPrimaryCmp now guards both marker absence and value equality. DeleteExpectedPrimaryFlag reports superseded targets, removes applicable markers, and revokes leases. Tests cover reconciliation and transaction races.

Transfer write term fencing

Layer / File(s) Summary
Transfer write term fencing
pkg/mcs/utils/expected_primary.go, pkg/mcs/utils/expected_primary_test.go, pkg/mcs/utils/constant/constant.go, tests/integrations/mcs/members/member_test.go
TransferPrimary captures the leader-key creation revision and guards marker publication with it. Failed writes revoke leases. The transfer lease multiplier is reduced to one, and integration comments describe immediate resignation and later recovery.

Campaign cleanup and step-down

Layer / File(s) Summary
Campaign cleanup and step-down
pkg/mcs/resourcemanager/server/server.go, pkg/mcs/scheduling/server/server.go, pkg/tso/allocator.go
Campaign transactions always include the expected-primary comparison. Campaigners stop initialization and step down when cleanup detects a newer transfer target.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TransferPrimary
  participant etcd
  participant Campaign
  participant DeleteExpectedPrimaryFlag
  TransferPrimary->>etcd: capture leader-key create revision
  TransferPrimary->>etcd: publish guarded expected-primary marker
  TransferPrimary->>TransferPrimary: resign
  Campaign->>etcd: campaign with expected-primary comparison
  Campaign->>DeleteExpectedPrimaryFlag: reconcile marker
  DeleteExpectedPrimaryFlag-->>Campaign: report target status
  Campaign->>Campaign: step down if superseded
Loading

Possibly related PRs

  • tikv/pd#10146: Introduced the transfer-primary and expected-primary handling extended by this change.
  • tikv/pd#10952: Introduced the transient expected-primary marker logic modified here.
  • tikv/pd#10970: Also changes expected-primary marker behavior in pkg/mcs/utils/expected_primary.go.

Suggested reviewers: lhy1024, rleungx

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address all four race conditions in issue #11122 with leadership fencing, marker reconciliation, campaign guards, and lease cleanup.
Out of Scope Changes check ✅ Passed The code, tests, marker TTL change, and documentation comments remain related to expected-primary transfer correctness and recovery.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly identifies the affected packages and summarizes the primary fix for expected-primary marker races.
Description check ✅ Passed The description covers the issue, implementation, tests, side effects, and release note; omitted checklist sections are non-critical.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
pkg/mcs/utils/expected_primary.go (1)

103-107: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Distinguish a failed read from an absent marker.

deleteMarkerIfEquals returns ("", false) both when the marker does not exist and when the transaction fails. Line 103 collapses the two cases into superseded = false. If the failure is transient and a newer transfer had already retargeted the marker to another member, this member keeps serving and the transfer does nothing until the marker TTL expires. That is the silent no-op class this PR removes elsewhere.

Return the transaction error from the helper and retry once, or treat an unknown outcome conservatively.

♻️ Proposed change to separate the failure case
-func deleteMarkerIfEquals(client *clientv3.Client, path, want string) (current string, deleted bool) {
+func deleteMarkerIfEquals(client *clientv3.Client, path, want string) (current string, deleted bool, err error) {
 	resp, err := kv.NewSlowLogTxn(client).
 		If(clientv3.Compare(clientv3.Value(path), "=", want)).
 		Then(clientv3.OpGet(path), clientv3.OpDelete(path)).
 		Else(clientv3.OpGet(path)).
 		Commit()
 	if err != nil {
 		log.Warn("failed to delete expected primary flag", zap.String("primary-path", path), errs.ZapError(err))
-		return "", false
+		return "", false, err
 	}

Then retry once in DeleteExpectedPrimaryFlag before assuming the marker is gone.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/mcs/utils/expected_primary.go` around lines 103 - 107, Update
deleteMarkerIfEquals to return the transaction error separately from the
empty-marker result, then have DeleteExpectedPrimaryFlag retry once when the
read fails before treating the marker as absent. Preserve the existing
false/superseded behavior only after a successful read confirms no marker
exists, and handle the retry’s remaining error conservatively.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/mcs/resourcemanager/server/server.go`:
- Around line 245-252: The step-down branch in campaignLeader currently returns
false without the delay used by the earlier expected-primary mismatch path. Add
the same bounded 200ms sleep before returning, so primaryElectionLoop does not
immediately re-campaign after DeleteExpectedPrimaryFlag reports a newer transfer
target.

In `@pkg/mcs/utils/expected_primary.go`:
- Around line 208-216: Update the leader-key read in TransferPrimary around
client.Get to use a derived context with etcdutil.DefaultRequestTimeout,
matching the existing timeout pattern used near lines 154 and 307. Pass that
bounded context to client.Get while preserving the existing error annotation and
leadership validation.

---

Nitpick comments:
In `@pkg/mcs/utils/expected_primary.go`:
- Around line 103-107: Update deleteMarkerIfEquals to return the transaction
error separately from the empty-marker result, then have
DeleteExpectedPrimaryFlag retry once when the read fails before treating the
marker as absent. Preserve the existing false/superseded behavior only after a
successful read confirms no marker exists, and handle the retry’s remaining
error conservatively.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 85665042-8d0a-4c4a-aeb0-dcf18e837881

📥 Commits

Reviewing files that changed from the base of the PR and between afa4311 and 01a7af9.

📒 Files selected for processing (5)
  • pkg/mcs/resourcemanager/server/server.go
  • pkg/mcs/scheduling/server/server.go
  • pkg/mcs/utils/expected_primary.go
  • pkg/mcs/utils/expected_primary_test.go
  • pkg/tso/allocator.go

Comment on lines +245 to +252
// failure re-elects immediately instead of waiting for the flag's TTL. If a newer
// transfer rewrote the flag to another member while we were winning, step down so
// the re-election routes leadership to that target.
if utils.DeleteExpectedPrimaryFlag(s.GetClient(), &s.participant.MsParam, expectedPrimary, s.participant) {
log.Info("the expected primary has been changed to another member, stepping down",
zap.String("server-name", s.Name()))
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the resource manager election loop and every DeleteExpectedPrimaryFlag call site.
set -euo pipefail

ast-grep outline pkg/mcs/resourcemanager/server/server.go --items all

rg -nP -C 12 '\bcampaignLeader\s*\(' pkg/mcs/resourcemanager/server/server.go

rg -nP -C 4 '\bDeleteExpectedPrimaryFlag\s*\(' --type=go

Repository: tikv/pd

Length of output: 5760


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## server.go primaryElectionLoop and campaignLeader"
sed -n '145,265p' pkg/mcs/resourcemanager/server/server.go | cat -n

echo "## All DeleteExpectedPrimaryFlag references"
rg -n -C 5 'DeleteExpectedPrimaryFlag' --type=go

echo "## campaignLeader return call sites"
rg -n -C 6 'campaignLeader\b|backoff|time.Sleep|Sleep' pkg/mcs/resourcemanager/server/server.go --type=go

echo "## Compare similar loops in scheduler/tso if present"
for f in $(git ls-files | rg 'pkg/.*/.*_server\.go$|pkg/.*/.*server\.go$' | rg -E 'scheduling|tso|mcs/|resources'); do
  [ -f "$f" ] || continue
  if rg -q 'primaryElectionLoop|campaignLeader|ExpectedPrimaryCmp|DeleteExpectedPrimaryFlag' "$f"; then
    echo "### $f"
    rg -n -C 8 'primaryElectionLoop|campaignLeader|ExpectedPrimaryCmp|DeleteExpectedPrimaryFlag' "$f"
  fi
done

Repository: tikv/pd

Length of output: 6958


Add a short sleep before re-campaigning on the step-down path.

campaignLeader returns false here, but primaryElectionLoop immediately returns to the top and starts the next CampaignWithCmps without sleeping. The earlier expected-primary mismatch path sleeps 200ms before re-checking. Since a newer transfer can rewrite the flag while this server campaigned, add the same bounded sleep after this false return to avoid tight-loop re-election attempts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/mcs/resourcemanager/server/server.go` around lines 245 - 252, The
step-down branch in campaignLeader currently returns false without the delay
used by the earlier expected-primary mismatch path. Add the same bounded 200ms
sleep before returning, so primaryElectionLoop does not immediately re-campaign
after DeleteExpectedPrimaryFlag reports a newer transfer target.

Comment on lines +208 to +216
leaderKeyPath := p.GetLeadership().GetLeaderKey()
leaderResp, err := client.Get(client.Ctx(), leaderKeyPath)
if err != nil {
return errors.Annotate(err, "failed to read leader key for transfer guard")
}
if len(leaderResp.Kvs) == 0 || string(leaderResp.Kvs[0].Value) != p.MemberValue() {
return errors.New("current member is not serving as primary, please check leadership")
}
leaderKeyGuard := clientv3.Compare(clientv3.CreateRevision(leaderKeyPath), "=", leaderResp.Kvs[0].CreateRevision)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the leader-key read with a request timeout.

client.Get(client.Ctx(), leaderKeyPath) inherits the etcd client lifetime context. It has no deadline. TransferPrimary runs on the {service}/primary/transfer request path, so a slow or hung etcd read blocks that request without bound. The new code at lines 154 and 307 already uses etcdutil.DefaultRequestTimeout. Use the same bound here.

As per coding guidelines: "Use context-aware timeouts and backoff for retries".

🛡️ Proposed fix
 	leaderKeyPath := p.GetLeadership().GetLeaderKey()
-	leaderResp, err := client.Get(client.Ctx(), leaderKeyPath)
+	getCtx, getCancel := context.WithTimeout(client.Ctx(), etcdutil.DefaultRequestTimeout)
+	leaderResp, err := client.Get(getCtx, leaderKeyPath)
+	getCancel()
 	if err != nil {
 		return errors.Annotate(err, "failed to read leader key for transfer guard")
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
leaderKeyPath := p.GetLeadership().GetLeaderKey()
leaderResp, err := client.Get(client.Ctx(), leaderKeyPath)
if err != nil {
return errors.Annotate(err, "failed to read leader key for transfer guard")
}
if len(leaderResp.Kvs) == 0 || string(leaderResp.Kvs[0].Value) != p.MemberValue() {
return errors.New("current member is not serving as primary, please check leadership")
}
leaderKeyGuard := clientv3.Compare(clientv3.CreateRevision(leaderKeyPath), "=", leaderResp.Kvs[0].CreateRevision)
leaderKeyPath := p.GetLeadership().GetLeaderKey()
getCtx, getCancel := context.WithTimeout(client.Ctx(), etcdutil.DefaultRequestTimeout)
leaderResp, err := client.Get(getCtx, leaderKeyPath)
getCancel()
if err != nil {
return errors.Annotate(err, "failed to read leader key for transfer guard")
}
if len(leaderResp.Kvs) == 0 || string(leaderResp.Kvs[0].Value) != p.MemberValue() {
return errors.New("current member is not serving as primary, please check leadership")
}
leaderKeyGuard := clientv3.Compare(clientv3.CreateRevision(leaderKeyPath), "=", leaderResp.Kvs[0].CreateRevision)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/mcs/utils/expected_primary.go` around lines 208 - 216, Update the
leader-key read in TransferPrimary around client.Get to use a derived context
with etcdutil.DefaultRequestTimeout, matching the existing timeout pattern used
near lines 154 and 307. Pass that bounded context to client.Get while preserving
the existing error annotation and leadership validation.

Source: Coding guidelines

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.14679% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.41%. Comparing base (e000290) to head (bfd0384).
⚠️ Report is 3 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11123      +/-   ##
==========================================
+ Coverage   79.35%   79.41%   +0.05%     
==========================================
  Files         542      542              
  Lines       76993    77091      +98     
==========================================
+ Hits        61097    61218     +121     
+ Misses      11594    11580      -14     
+ Partials     4302     4293       -9     
Flag Coverage Δ
unittests 79.41% <76.14%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

path := keypath.ExpectedPrimaryPath(msParam)
if expectedValue == "" {
return nil
return clientv3.Compare(clientv3.CreateRevision(path), "=", 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An afa43111d replica still omits this comparison after observing an empty marker. During a rolling upgrade it can pause after that read, resume after an upgraded primary installs a transfer marker and resigns, and win the unconditional campaign, so /primary/transfer returns success with leadership on a non-target replica.

}
if current == "" {
// The marker is already gone (deleted, expired, or it never existed), or the
// transaction failed; in the latter case the marker TTL bounds the staleness.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this reconciliation transaction returns a transient error after an older in-flight transfer rewrites the marker, current == "" lets this winner continue to PromoteSelf. A serving primary does not watch that marker, so the transfer has already returned success but never takes effect; marker expiry does not trigger another election.

…ccess

A replica that predates the expected-primary marker mechanism (e.g. a
not-yet-upgraded node during a rolling upgrade) does not read the marker and
can win the now-vacated leader key through its own unguarded campaign, so
{service}/primary/transfer could return success while leadership actually
went to a different, non-target replica.

TransferPrimary now threads a ctx through to a new post-resign verification
step: it polls the leader key until its holder's identity matches new_primary
on two consecutive checks, or the marker's own TTL elapses, whichever comes
first. Two consecutive matches (not one) are required because the winner of a
campaign still runs its own post-campaign steps (marker reconcile,
primaryCallbacks) before it is durably promoted, and any of those can make it
step back down again shortly after a transient win. The verification is
skipped when new_primary is empty (pick any valid secondary), since there is
no fixed target to check against.

ctx is threaded from each HTTP handler's request context (so an abandoned
request does not leave verification polling for the rest of its timeout
regardless) and from the priority-check background loop's own context.

Updated three integration test assertions that relied on the old
fire-and-forget response: with skipGrantLeader forcing the target to never
win, the initial transfer call now honestly reports failure instead of a
premature 200, while the cluster's eventual recovery (verified separately by
each test) is unaffected.

Signed-off-by: bufferflies <1045931706@qq.com>
@ti-chi-bot ti-chi-bot Bot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Aug 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/mcs/utils/expected_primary.go`:
- Around line 353-375: Update the verification loop around GetProtoMsgWithModRev
so each etcd read uses the outer ctx rather than client.Ctx(). Either replace it
with client.Get(ctx, leaderKeyPath) and decode the returned participant value,
or extend the helper to accept and propagate ctx while preserving the existing
target validation and error handling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 764a00c6-cbef-4b7c-bde7-7519880f8128

📥 Commits

Reviewing files that changed from the base of the PR and between 01a7af9 and 295501d.

📒 Files selected for processing (6)
  • pkg/mcs/resourcemanager/server/apis/v1/api.go
  • pkg/mcs/scheduling/server/apis/v1/api.go
  • pkg/mcs/tso/server/apis/v1/api.go
  • pkg/mcs/utils/expected_primary.go
  • pkg/tso/keyspace_group_manager.go
  • tests/integrations/mcs/members/member_test.go

Comment thread pkg/mcs/utils/expected_primary.go Outdated
Comment on lines +353 to +375
target := member.NewParticipantByService(serviceName)
if ok, _, err := etcdutil.GetProtoMsgWithModRev(client, leaderKeyPath, target); err == nil && ok {
if target.GetName() == newPrimary || slices.Contains(target.GetListenUrls(), newPrimary) {
matchStreak++
if matchStreak >= primaryTransferStableChecks {
return nil
}
} else {
matchStreak = 0
}
currentPrimary = target.GetName()
} else {
matchStreak = 0
currentPrimary = ""
}
select {
case <-ctx.Done():
if currentPrimary == "" {
return errors.Errorf("transfer requested to %s, but no primary was elected: %v", newPrimary, ctx.Err())
}
return errors.Errorf("transfer requested to %s, but %s is currently primary: %v", newPrimary, currentPrimary, ctx.Err())
case <-ticker.C:
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline pkg/utils/etcdutil/etcdutil.go --match GetProtoMsgWithModRev --view expanded
rg -n -C 6 'func GetProtoMsgWithModRev|func get\(|client\.Get\(' \
  pkg/utils/etcdutil/etcdutil.go pkg/mcs/utils/expected_primary.go

Repository: tikv/pd

Length of output: 4225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== expected_primary relevant section =="
sed -n '320,380p' pkg/mcs/utils/expected_primary.go

echo "== etcdutil relevant helper =="
sed -n '232,265p' pkg/utils/etcdutil/etcdutil.go

echo "== searches for GetProtoMsgWithModRev callers and signature variants =="
rg -n 'GetProtoMsgWithModRev\(' pkg/mcs/pkg pkg/utils/etcdutil || true

Repository: tikv/pd

Length of output: 4463


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== EtcdKVGet implementation =="
rg -n -C 8 'func EtcdKVGet|func Get\(ctx context.Context|WithRequireLeader\(ctx\), timeout' pkg/utils/etcdutil/etcdutil.go pkg/utils/etcdutil || true

echo "== all GetProtoMsgWithModRev callers =="
rg -n 'etcdutil\.GetProtoMsgWithModRev|GetProtoMsgWithModRev\(' .

echo "== import context in expected_primary and etcdutil =="
sed -n '1,40p' pkg/mcs/utils/expected_primary.go
sed -n '1,30p' pkg/utils/etcdutil/etcdutil.go

Repository: tikv/pd

Length of output: 7986


Make each verification read honor ctx.

GetProtoMsgWithModRev(client, leaderKeyPath, target) currently calls client.Ctx() for its read, so the loop timeout cannot cancel a slow etcd get. Use client.Get(ctx, leaderKeyPath) or make the helper accept the outer context before decoding the participant value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/mcs/utils/expected_primary.go` around lines 353 - 375, Update the
verification loop around GetProtoMsgWithModRev so each etcd read uses the outer
ctx rather than client.Ctx(). Either replace it with client.Get(ctx,
leaderKeyPath) and decode the returned participant value, or extend the helper
to accept and propagate ctx while preserving the existing target validation and
error handling.

Source: Coding guidelines

…y window

Two follow-up review comments on the post-transfer verification added in
295501d:

- waitForPrimaryTransfer compared the caller-supplied new_primary directly
  against the winner's identity. When a service is registered with a name
  distinct from its advertise address (the default configuration), new_primary
  is the registry name while the leader proto stores the advertise address
  (an "address-groupID" composite for TSO), so the comparison never matched -
  a successful transfer would still wait out the full marker TTL and report
  500. Compare against primaryID instead: it's the resolved ServiceAddr chosen
  during candidate selection, the same identity Participant.IsExpectedPrimary
  already relies on (a marker value is matched against ListenUrls), so it's
  guaranteed to appear in the winner's own ListenUrls regardless of what form
  the caller supplied. new_primary is kept only for the error message, so a
  failure still reads naturally to whoever issued the request.

- The stability requirement (2 consecutive polls ~200ms apart) was too short:
  the leader key is written as soon as the campaign transaction commits, well
  before the winner is durably promoted - it still has to reconcile the
  marker, run primaryCallbacks, and for TSO initialize the allocator (real
  I/O via syncTimestamp) first, and a failure in any of those steps can make
  it step back down shortly after. Two checks 200ms apart could both land
  inside that window. Replaced the fixed poll count with a continuous-match
  duration requirement (2s) sized to more comfortably outlast those
  post-campaign steps under normal conditions - this narrows, and is honestly
  documented as not eliminating, the same class of gap; a full fix would need
  to ask the target directly rather than infer from etcd state.

Signed-off-by: bufferflies <1045931706@qq.com>
matchSince was keyed only on the target's advertised address, not the
leader key's own revision. If the target loses and re-acquires the
leader key between two polls - invisible to us if both polls happen to
observe a "matched" state either side of the gap - matchSince keeps
counting across that gap as if it were one continuous term. The 2-second
stability window could then be satisfied by time accumulated partly
against the earlier, already-reverted term, letting the endpoint report
success while the new term is still mid primaryCallbacks/TSO
initialization - exactly the failure mode the window exists to guard
against.

GetProtoMsgWithModRev already returns the leader key's ModRevision, and
Leadership.Campaign requires CreateRevision(leaderKey) == 0 to win while
removeLeaderKey deletes the key outright, so every fresh term necessarily
gets a new ModRevision. Track the ModRevision alongside matchSince and
restart the window whenever it changes, even if the address still
matches - the 2 seconds now only ever accumulate within one term.

Signed-off-by: bufferflies <1045931706@qq.com>
TransferPrimary previously polled the leader key after marking and
resigning, waiting for the target to be observed stably serving before
reporting success. That verification only narrowed, never eliminated,
the gap between the leader key being written and the target actually
finishing initialization (see tikv#11122 discussion), while making the
API call block for up to the marker's TTL and fail on a short-lived
caller context even when the transfer itself was proceeding normally.

Drop the wait: TransferPrimary now reports success as soon as the
marker is written and the current primary has resigned. The
correctness fixes from the prior commits (mark-before-resign
ordering, the atomic leader-key guard, and DeleteExpectedPrimaryFlag
clearing the marker as soon as any member wins a campaign) are
unaffected and still stand.

Without verification, the worst-case unavailability a transfer can
leave behind - when the target never wins a single campaign at all -
is bounded by the marker's TTL. Shrink that TTL from 3 leader leases
to 1: a target that never wins even once already has no fixed cost to
amortize by waiting longer, and a target that does win clears the
marker immediately regardless of whether it goes on to initialize
successfully, so the multiplier only ever pays for the "never wins"
case.

Signed-off-by: bufferflies <1045931706@qq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the dco. do-not-merge/needs-triage-completed release-note Denotes a PR that will be considered when it comes time to generate release notes. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcs: expected-primary transient marker (#10952) has unguarded races that let /primary/transfer silently no-op or be bypassed

2 participants