fix: deterministic election recovery for restarted and equal-clock nodes (#2197)#2203
Open
oliver0006 wants to merge 1 commit into
Open
fix: deterministic election recovery for restarted and equal-clock nodes (#2197)#2203oliver0006 wants to merge 1 commit into
oliver0006 wants to merge 1 commit into
Conversation
Two related election wedges (issue exo-explore#2197): Stale messages were dropped silently, so a node campaigning below the cluster clock (e.g. a restarted master starting over from 0) never learned the current clock and could stay invisible forever. Peers now reply to stale messages with their current status, as in the classic bully algorithm, so the sender deterministically adopts the cluster clock and rejoins the election. An equal-clock candidacy received while no campaign was running was appended to a candidate list that nothing would ever evaluate, creating a dead round. Campaign liveness is now tracked and such a candidacy starts a campaign that resolves the round. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Motivation
Fixes #2197 — both reported wedges.
election_clockis process-volatile, and peers silently dropped any message below their clock. A master restarting from clock 0 into a cluster at clock N campaigned into the void: nobody answered, so it could never learn the cluster clock, and recovery depended on an incidental connection event._candidates— a list that only a running campaign ever evaluates (max(candidates)at timeout). The candidacy sat there forever.Changes
message.clock < self.clock): instead of dropping silently, the receiver answers with its current status. This is the missing piece of the classic bully algorithm: the stale campaigner receives a higher-clock message, adopts the cluster clock through the existingclock > self.clockpath, and rejoins the election deterministically. Replies are bounded (one per stale message received) and can never ping-pong, since staleness is strictly one-sided._campaigns_started/_campaigns_finished, incremented at scheduling time and in the campaign'sfinally): an equal-clock candidacy arriving with no active campaign now starts one, so the round resolves instead of dying in the list.Why It Works
Both fixes stay at the protocol level — no persistence, no new message types, no changes to the ordering semantics of
ElectionMessage. A restarted node converges in one round-trip: its stale broadcast triggers a status reply, the reply carries the cluster clock, and the normal higher-clock path takes over from there. The issue's Additional context mentions a durable on-disk election clock as the fix deployed in your fork — that approach also covers full-cluster restarts and would compose cleanly with this change; happy to discuss whether it's wanted upstream as a follow-up (@rogerstom-lgtm).Note on an existing test:
test_ignores_older_messagesasserted the silent-drop behavior this issue reports as a bug. It's updated to assert the new contract: an older message still never changes the clock or starts a round, but the sender now gets a status reply.Test Plan
Manual Testing
Hardware: Fedora Linux. Ran the new regression tests against the unfixed
election.py: 6 fail (both unit repros, the two-node convergence test, and the updated stale-message contract, across both async backends). Against the fix: all 20 pass.Automated Testing
Following the unit-level repro shapes suggested in the issue:
test_equal_clock_candidacy_without_active_campaign_starts_a_round— an equal-clock candidacy from a better candidate, arriving after the previous round resolved, must produce anElectionResultelecting it (used to die in the list).test_stale_message_gets_current_status_reply— a node campaigning at clock 1 while the cluster is at 5 must receive a status reply carrying clock 5 (used to get silence).test_restarted_master_rejoins_cluster_deterministically— end-to-end: two wiredElectioninstances, survivor at clock 5, fresh node booting from clock 0; the restarted node must reach the cluster clock and both nodes must converge on a single session, with no connection event involved. Asserts convergence rather than the winner, since round outcome depends on seniority tie-breaking that predates this change.Full
uv run pytest src→ 345 passed.uv run basedpyright0 errors;ruff checkandruff format --checkpass.Disclosure: co-written by a human (@oliver0006) directing the work and Claude AI (Fable 5) writing code, following AGENTS.md and .clauderules.
🤖 Generated with Claude Code