Repository navigation
Open recovered service from a later primary if the opening is rolled back - #8450
Merged
Merged
Conversation
…back Only the node that is primary when it completes private recovery opened the recovered service. If an election rolled back that unsigned opening, and the new primary had completed private recovery as a backup, nothing re-opened the service, which stayed in WaitingForRecoveryShares indefinitely. This is the intermittent recovery_with_election failure in the Long e2e - Debug job. A node which has begun private recovery now tries, once per view in which it is primary and part of the network, to open the service until it sees the opening commit. It does not write a second opening if its store already holds one, which also stops a node elected during private recovery from failing at the end of recovery when its log already opens the service. Adds recovery_with_election_after_backups_recovered, which forces the failing ordering deterministically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The recovered nodes run with ignore_first_sigterm, and were only SIGKILLed when the test passed, so a failing run could leave them running (one ran for about 40 minutes in CI). Collect the recovered network as soon as it is created and SIGKILL its nodes in a finally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Address the critical state-transition issue and the test determinism and cleanup findings.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
This PR fixes recovered-service opening after an election rollback and adds regression coverage.
Changes:
- Adds recovery-opening retry state and logic.
- Adds election-ordering tests and cleanup handling.
- Documents the fix in the changelog.
| File | Description |
|---|---|
tests/recovery.py |
Adds election regression coverage and recovered-node cleanup. |
src/node/node_state.h |
Implements recovery-opening state and retries. |
CHANGELOG.md |
Documents the recovery liveness fix. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Max (maxtropets)
approved these changes
Sep 25, 2026
Review feedback: the forced ordering relied on the election beating the old primary's next signature, so on a slow runner the opening could commit first. Suspend the primary as soon as every backup has begun private recovery instead: it cannot sign again until about sig_ms_interval after the signature which committed the final share, so a backup which completed private recovery as a backup is elected and must open the service, independent of private recovery and election durations. Assert that no backup left the initial view before completing private recovery, and SIGKILL suspended nodes on cleanup. Such a new primary opens the service asynchronously after its election, so poll for the service to be open rather than checking once. Also clarify the recovered_service_opening comment on why the service hook can mark the opening Committed before private recovery begins. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.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
recovery_with_electionfails intermittently withService status WaitingForRecoveryShares (expected one of ['Open']). This was found while investigating theLong e2e - Debugfailure on #8435 (run 36037012519), which is not caused by that PR.Only the node that is primary when it completes private recovery opens the recovered service. In the failing run:
become_leaderrolled back to the last committable index (92), dropping the unsigned opening.Every node was then part of the network, but nothing could open the service:
transition_service_to_openis a no-op inWaitingForRecoveryShares, and re-submitting recovery shares fails because the nodes are no longer part of the public network. The service stays inWaitingForRecoverySharesindefinitely.This liveness gap was anticipated in #3755. It became visible recently: #8412 made
recovery_with_electionassert that the service opens, and #8444 shortened the private ledger, so the backups now usually complete private recovery before the forced election. Outside tests the window is wider, since the default signature interval is 1s rather than the tests' 100ms.Implementation summary
NodeStatetracks the opening of the recovered service:NotRecovering, thenPendingonce private recovery begins, thenCommittedonce the service table's global hook sees anOpencommit for the current identity.Committedis final, because global hooks run in table order and the opening may be seen in the same compaction as the trigger for private recovery.Pending, a node which has completed private recovery and is primary makes, fromtick(), at most one attempt per view to open the service. An attempt only writes the opening if the node's own store still showsWaitingForRecoveryShares. A failed commit or aCompactedVersionConflictallows another attempt in the same view.current service status is Openfrom the ledger handler, which was fatal. A commit failure there is now retried fromtick()rather than thrown.recovery_with_election_after_backups_recoveredforces the failing ordering. With the default 1s signature interval, it suspends the primary as soon as every backup has begun private recovery, before the primary can sign an opening. A backup that completed private recovery as a backup is then elected and must open the service. The test checks that no backup left the initial view before completing private recovery, and that the opening committed in the new primary's view.run_recovery_with_electionnow SIGKILLs the recovered nodes, including a suspended primary, in afinally, so a failing run no longer leaves nodes running withignore_first_sigterm. In the failing CI run, one kept running for about 40 minutes.Safety and compatibility
WaitingForRecoveryShares. Afterbecome_leader's rollback a primary's store matches its log, so every branch of the log contains at most one opening, and Raft commits a single branch. KV conflict detection stops concurrent attempts on the same node from both committing.NodeState::lock, liketransition_service_to_open. Exceptions other thanCompactedVersionConflictare not caught, so they remain fatal as before.tick()is a single atomic load.Validation
Done, with a local Debug build (
-DGLIBCXX_DEBUG=ON -DLONG_TESTS=ON):mainwith the service stuck inWaitingForRecoveryShares(4/4 runs of the first, SIGTERM-based version, 5/5 of the current, suspend-based one). It passes in every run with the fix (14 runs of the suspend-based version, 4 of them after mergingmain). In each run the new primary opened the service fromtick(), and the other backup rolled back the old primary's unsigned opening. One run of the existingrecovery_with_electionhit the CI ordering by chance, and the service was opened by the successor.recovery_test(re-run after mergingmain),recovery_stale_snapshot_join_test,recovery_intermediate_snapshot_join_test,recovery_snapshot_endorsements_testandopen_service_testpass.finally, they outlive the test.Not done:
scripts/ci-checks.sh. These are covered by CI.