fix(consensus): persist high QC and restore lock on restart (V0.5)#4598
fix(consensus): persist high QC and restore lock on restart (V0.5)#4598lukaszrzasik wants to merge 9 commits into
Conversation
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. |
|
Claude finished @lukaszrzasik's task in 3m 18s —— View job Code Review: Persist high QC and restore lock on restart
SummaryThis PR fixes a correctness bug where The fix has three parts:
VerdictThe fix is well-motivated and the implementation is correct. The tests are solid — both the round-trip persistence test and the consensus recovery test cover the essential invariants. I left three inline comments:
No blocking issues found. The tests thoroughly verify both the persistence path and the recovery path. |
| if let Some(existing) = (**self).load_next_epoch_quorum_certificate().await? | ||
| && next_epoch_high_qc.view_number() < existing.view_number() | ||
| { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| (**self) | ||
| .store_next_epoch_quorum_certificate(next_epoch_high_qc) | ||
| .await | ||
| } |
There was a problem hiding this comment.
Potential TOCTOU race on the next-epoch QC monotonicity check.
Unlike update_high_qc2 which routes to append_high_qc2 (atomic compare-and-set at the FS/SQL backend level), this performs a load-then-store at the application layer. Between the load_next_epoch_quorum_certificate and store_next_epoch_quorum_certificate calls, a concurrent call with a newer QC could complete its store, only to be overwritten by this call's stale value — store_next_epoch_quorum_certificate blindly overwrites in both the FS and SQL backends.
I see update_eqc has the same pattern, so this is consistent with existing code, but it's worth considering whether store_next_epoch_quorum_certificate should gain an atomic CAS like append_high_qc2 has, especially now that it's called from a hot path (every update_next_epoch_high_qc2 invocation).
Nit: the condition < existing.view_number() allows equal-view writes (no-op but triggers a write). Matching append_high_qc2's strictly-newer semantic (existing.view_number() < view) would avoid the redundant store:
| if let Some(existing) = (**self).load_next_epoch_quorum_certificate().await? | |
| && next_epoch_high_qc.view_number() < existing.view_number() | |
| { | |
| return Ok(()); | |
| } | |
| (**self) | |
| .store_next_epoch_quorum_certificate(next_epoch_high_qc) | |
| .await | |
| } | |
| if let Some(existing) = (**self).load_next_epoch_quorum_certificate().await? | |
| && next_epoch_high_qc.view_number() <= existing.view_number() | |
| { | |
| return Ok(()); | |
| } | |
| (**self) | |
| .store_next_epoch_quorum_certificate(next_epoch_high_qc) | |
| .await |
| if let Some(running_high_qc) = self | ||
| .load_high_qc2() | ||
| .await | ||
| .context("loading persisted high qc")? | ||
| && running_high_qc.view_number() > high_qc.view_number() | ||
| { | ||
| high_qc = running_high_qc; | ||
| next_epoch_high_qc = self | ||
| .load_next_epoch_quorum_certificate() | ||
| .await | ||
| .context("loading persisted next epoch qc")? | ||
| .filter(|neqc| { | ||
| CertificatePair::new(high_qc.clone(), Some(neqc.clone())) | ||
| .verify_next_epoch_qc(epoch_height) | ||
| .is_ok() | ||
| }); | ||
| } |
There was a problem hiding this comment.
Nice recovery logic. One question on the semantics: when the running_high_qc path is taken, next_epoch_high_qc is replaced unconditionally — even if the loaded next epoch QC is None or fails verification. This could discard a valid next_epoch_high_qc that was already set from the eQC path on lines 724–729.
I believe this is correct because a next-epoch QC must verify against its paired high QC (the .filter on line 742–746 enforces this), so if the high QC has advanced past the eQC, the old next-epoch QC is no longer valid anyway. But it might be worth a brief comment confirming this intent, since a reader might wonder why a valid next_epoch_high_qc is being thrown away.
| let recovered_locked_view = initializer | ||
| .high_qc | ||
| .view_number | ||
| .max(anchored_leaf.view_number()); |
There was a problem hiding this comment.
This is the key safety fix — locked_view now correctly tracks the highest QC seen before the restart instead of regressing to the anchor. Looks correct.
Minor: initializer.high_qc.view_number accesses the field directly while anchored_leaf.view_number() uses the accessor method. Both work, but using the accessor consistently would be slightly more uniform:
| let recovered_locked_view = initializer | |
| .high_qc | |
| .view_number | |
| .max(anchored_leaf.view_number()); | |
| let recovered_locked_view = initializer | |
| .high_qc | |
| .view_number() | |
| .max(anchored_leaf.view_number()); |
(Only if view_number() exists on QuorumCertificate2 — otherwise ignore.)
|
@claude review again |
|
Claude finished @lukaszrzasik's task in 1m 45s —— View job Re-review: Persist high QC and restore lock on restart
All three issues from my first review have been addressed across the two follow-up commits. Here's the updated assessment: Previous issues — resolved
Current state of the code — looks goodTrait layer (
FS backend (
SQL backend (
Consensus init (
TestsThe new tests are thorough and well-structured:
All four tests run against both FS and SQL via CI failuresThe test failures in CI ( VerdictNo blocking issues. The TOCTOU concern from my first review has been fully addressed — |
`load_consensus_state` now drops a recovered next-epoch QC that does not correspond to the high QC (`verify_next_epoch_qc`) instead of handing `Consensus::new` a mismatched pair. The recovery test is updated to assert the non-corresponding next-epoch QC is dropped to `None`.
Callers that have the anchor's validated state and delta (e.g. a full genesis state) can now pass them through the new InitializerAnchor tuple; when absent, a sparse state is built from the anchor header as before. Existing callers pass None and are unchanged.
Recovery built HotShotInitializer by hand with empty undecided_leaves/undecided_state, so a high QC recovered ahead of the anchor pointed at leaves consensus could not see: heights between the anchor and the high QC were never re-decided. Route load_consensus_state through HotShotInitializer::load, which populates both maps from the persisted proposals.
Construction must now go through from_genesis or load, so recovery can never again skip populating the undecided maps; external readers move to accessors. No behavior change.
|
@claude review again |
|
Claude encountered an error after 1m 11s —— View job Re-review: Persist high QC and restore lock on restart
|

Closes #<ISSUE_NUMBER>
This PR:
update_high_qc2is no longer no-op. It now uses the existingappend_high_qc2to persist the high QC.update_next_epoch_high_qc2is also no longer no-op and now uses the existingappend_next_epoch_high_qc2append_next_epoch_high_qc2is a new method that does whatstore_next_epoch_quorum_certificatedid but atomically (using a transaction for sql and a write lock for fs)store_next_epoch_quorum_certificatehas been removedload_consensus_statenow loads the high QC and the next epoch QC from the persistent storage during startup, making sure the high qc and the next epoch qc correspond to each other.load_consensus_statenow builds the initializer viaHotShotInitializer::load, which restoresundecided_leaves/undecided_statefrom the persisted proposals. Without this, the recovered high QC pointed at leaves consensus couldn't see, so heights between the anchor and the high QC were never re-decided — the cause of theapi::test::test_restarthang (3×360s timeout → ~17s pass).HotShotInitializer::loadoptionally takes the anchor's validated state and delta; the sequencer passes its full genesis state, hotshot tests passNone(unchanged behavior).HotShotInitializerfields are now private — construction goes throughfrom_genesis/loadonly, so recovery can never again skip restoring the undecided maps; external readers use new accessors.This PR does not:
Key places to review: