Fix data races on Aft leadership state accessors - #8249
Open
Amaury Chamayou (achamayou) wants to merge 2 commits into
Open
Fix data races on Aft leadership state accessors#8249Amaury Chamayou (achamayou) wants to merge 2 commits into
Amaury Chamayou (achamayou) wants to merge 2 commits into
Conversation
Aft::is_primary(), is_candidate() and their callers read state->leadership_state without holding state->lock, while every write to it is made under that lock during election transitions. ThreadSanitizer reports this as a data race against a concurrent election. Taking state->lock in those accessors is not an option. Store::commit() calls is_primary() while holding the KV version lock, and Aft calls into the Store from under state->lock (become_leader, compact, rollback), so locking there would invert an existing lock order. Make the value atomic instead, so the unsynchronised reads are well defined for all seven call sites rather than just the one TSAN happened to hit. std::atomic is neither copyable nor movable, and so cannot be a field of a type declared with the DECLARE_JSON_* macros, which round-trip each field by value; a small wrapper restores value semantics for serialisation while keeping every access atomic. The serialised form is unchanged. No behaviour change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 75d99c5d-6efa-4048-8032-8c78b97208d9
Copilot started reviewing on behalf of
Amaury Chamayou (achamayou)
August 31, 2026 18:26
View session
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a ThreadSanitizer-reported data race in the AFT consensus implementation by making State::leadership_state safe to read from unsynchronised accessors (eg. Aft::is_primary()), while preserving existing lock ordering and JSON serialisation behavior used by raft tracing.
Changes:
- Introduce
AtomicLeadershipState(atomic-backed, JSON round-trippable wrapper) and switchState::leadership_stateto use it. - Update AFT code paths to use explicit
load()/store()for leadership-state reads/writes (including logging/tracing).
Custom instructions used:
.github/copilot-instructions.md.github/instructions/reviewing.instructions.md
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/consensus/aft/raft.h | Update all leadership-state reads/writes to atomic load()/store() to remove racy unsynchronised reads. |
| src/consensus/aft/impl/state.h | Add AtomicLeadershipState wrapper and update State to keep JSON serialisation intact while making leadership-state access atomic. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
leader_id is written under state->lock on every leadership transition, and read under it by get_details(), but Aft::primary() returned it with no synchronisation. Same class of race as leadership_state, in the adjacent accessor. It cannot be made atomic, being a std::optional<NodeId>, but it does not need to be: unlike is_primary(), primary() is not called from Store::commit() under the KV version lock, and it is never called from inside Aft, so taking state->lock here introduces no ordering risk. The adjacent can_replicate() already takes the same lock from the same callers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 75d99c5d-6efa-4048-8032-8c78b97208d9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #8247.
Independent of the #8242..#8246 concurrency stack - based on
main, touches no files that stack touches.The race
Aft::is_primary()reads mutex-protected state without the mutex:leadership_stateis written on every election transition, all understate->lock(PreVoteCandidate,Candidate,Leader,Follower,None). The unsynchronised accessors sit directly beside one that does lock, so this reads as an oversight rather than a deliberate relaxed read. ThreadSanitizer reports it against a concurrent election.The most easily reached racing reader is
Store::commit():Why not just take the lock
That deadlocks.
Store::commit()callsis_primary()underversion_lock, which would createversion_lock -> state->lock. The opposite edge already exists - Aft calls into the Store from understate->lock, and those entry points takeversion_lock:raft.h:2219store->initialise_term(...)inbecome_leader(), three lines before theleadership_state = Leaderwriteraft.h:2625store->compact(idx)raft.h:2707store->rollback(...)So the value has to be made safe to read without the lock, rather than moved under it.
The change
leadership_statebecomes atomic, and all 31 access sites become explicitload()/store(). This fixes all sevenis_primary()call sites - four innode_state.h, two instore.h(includingStore::compact()decidinggenerate_snapshot), one innode_interface.h- not just the one TSAN happened to hit.std::atomicis neither copyable nor movable, so it cannot be a field of a type declared with theDECLARE_JSON_*macros:DECLARE_JSON_REQUIRED_FIELDSexpands tot.field = it->get<decltype(TYPE::field)>(), which must return by value. A small wrapper restores value semantics for serialisation while keeping every access atomic, which leavesState's declaration untouched - so no other field's serialisation can have changed.Acquire/release ordering, so a reader that observes
Leaderalso observes the writes the new leader made before the transition.Why this is minimal
State'sDECLARE_JSON_*declaration is unchanged.Testing
raft_test(1,001,014 assertions),kv_test,map_test,history_test,snapshotter_test,snapshot_test,frontend_testall pass.CCF_RAFT_TRACINGandVERBOSE_RAFT_LOGGING, which are the only builds that serialise and log this field.main. Serialising aStatebefore and after gives exactly:from_jsonround-trip is preserved. This matters because theCCF_RAFT_TRACINGJSON feeds TLA+ trace validation.No CHANGELOG entry: no user-facing API or behaviour change.
Labelled
run-long-test.Follow-up from review
Aft::primary()had the same defect in the adjacent accessor: it returnedleader_idunsynchronised, while every write to it (raft.h1213, 1850, 2130, 2175, 2232, 2281, 2407) happens understate->lock, andget_details()already reads it under that lock at 604.leader_idis astd::optional<NodeId>, so it cannot be made atomic. It does not need to be: unlikeis_primary(),primary()is not called fromStore::commit()underversion_lock, and is never called from insideAft, so takingstate->lockthere introduces no ordering risk. The adjacentcan_replicate()already takes the same lock from the same callers.