Carry the installed snapshot index in the install-snapshot acknowledgement - #121
Merged
Conversation
…ement
A follower answers the last snapshot object with a terminal `resp_msg` context. Until now that
context was a single zero byte meaning only "the install is done", and the index of the installed
snapshot lived exclusively in the leader's `snapshot_sync_ctx`. So when that context was already
destroyed by the time the acknowledgement arrived, `handle_install_snapshot_resp` had nothing to
interpret the response with and dropped it -- even though the install had *succeeded*.
That is not a rare race. `snapshot_sync_ctx_timeout` is a per-round-trip responsiveness budget
(its own doc comment says "if a single snapshot syncing request exceeds this"), and it defaults to
`raft_limits_response_limit * heart_beat_interval_`. The final round trip contains the state
machine's `apply_snapshot`, which is indivisible and can take far longer than that budget no matter
how small `snapshot_transfer_chunk_size` makes the chunks. Measured on a production ensemble: a
254 MB snapshot transferred in 2 s and applied in 14 s against a 10 s budget, and a 418 MB one
split across 4 chunks still timed out inside a 19 s apply.
The consequence is that `next_log_idx`, `matched_idx` and in particular `next_log_idx_floor` were
never set from the installed snapshot, so nothing bounded a subsequent backward log walk. The
follower stayed out of quorum until it happened to converge some other way.
The terminal context now carries the installed snapshot's `last_log_idx`, which is the only thing
the leader's completion bookkeeping ever needed from the sync context:
Format tag 1 byte
Installed snapshot last_log_idx 8 bytes (little endian)
`len == 1` with tag 0 remains the historical indexless marker. For every tag `>= 1` bytes 1..8 are
the index, so a leader can read it out of a tag it does not otherwise understand and later
extensions stay additive. Anything else is malformed and is rejected rather than read as a
completion -- falling back to "treat it as the old marker" would be the *permissive* reading here,
because in this code the old marker means completion.
On the leader, inside the accepted path only:
- With no sync context, an acknowledgement that carries an index is now acted upon instead of
dropped, provided the index passes validation: the response term is current, the index is in
`(0, precommit_index_]`, and `idx + 1` cannot wrap. Every write is monotone (`max`), so a late
acknowledgement can never move a peer backwards. The rest of the success bookkeeping runs too,
including clearing `snapshot_sync_is_needed` -- without that, a follower that answered
`RECEIVING_SNAPSHOT` while applying would leave the flag set and `create_append_entries_req`
would start another full install as soon as the leader held a newer snapshot, preserving most of
the retransfer cost this change removes.
- With a sync context whose snapshot is a *different* index, the in-flight install is no longer
treated as done. `matched_idx` feeds `get_expected_committed_log_idx`, so crediting a peer with a
snapshot it never installed is a safety problem and not just bookkeeping. The index that really
was installed is credited monotonically and the live transfer is left to report for itself.
Rejected responses are untouched: that branch deliberately rewrites the peer's position and zeroes
the floor as follower-ahead recovery.
Mixed versions degrade safely both ways. An old leader's completion test for logical-object
snapshots is only that `resp_msg::get_ctx` is non-null, and it never inspects the payload, so a
9-byte context is still read as done. An old follower's 1-byte context takes the legacy row above
and behaves exactly as before.
Not addressed here, and left for a follow-up: legacy acknowledgements carry no index, so an old
follower plus a live context still cannot be correlated; `raw_binary` snapshots need the snapshot
object for their `resp.get_next_idx() >= snp->size()` test; and
`handle_install_snapshot_resp_new_member` has the same shape on the add-server path.
`tests/unit/snapshot_test.cxx` gains eight cases -- acceptance, monotonicity, no redundant
reinstall, index validation, acknowledgement for a snapshot other than the one in flight, the
legacy marker, malformed payloads delivered both with and without a live context, and an unknown
future tag. Five of them fail without this change. `raft_server_handler` and `FakeNetwork` gain
accessors so the tests can drive the real timeout check and read `precommit_index_` rather than
emulating the post-timeout state.
tiandiwonder
added a commit
to ClickHouse/ClickHouse
that referenced
this pull request
Jul 29, 2026
… discarded Bumps contrib/NuRaft to fb435f2 (branch fix-late-snapshot-install-ack), which makes the install-snapshot acknowledgement carry the installed snapshot's last_log_idx. A leader whose snapshot sync context already timed out can then still act on a successful install instead of dropping it and leaving next_log_idx_floor unset. This is the actual fix for the defect the setting in the previous commit only gives operators a lever against. It is pinned to the pull request branch so that Keeper's integration tests, nightly_keeper and Jepsen exercise it here; the pointer must be moved to the merge commit before this pull request is merged. Related: ClickHouse/NuRaft#121
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.
Problem
A leader silently discards a follower's successful snapshot-install acknowledgement whenever the install takes longer than
snapshot_sync_ctx_timeout, and then has no record that the follower holds the snapshot at all. The follower is fully caught up and says so; the leader does not believe it.This is not a rare race. The timeout is a per-round-trip responsiveness budget — its own doc comment says "if a single snapshot syncing request exceeds this", and
snapshot_sync_ctx::set_offsetresets the timer on every offset advance — and it defaults toraft_limits_response_limit * heart_beat_interval_. But the final round trip of a logical-object install contains the state machine'sapply_snapshot, which is indivisible, so nosnapshot_transfer_chunk_sizecan bring that round trip under a responsiveness budget. Measured on a production ClickHouse Keeper ensemble against a 10 s derived budget:The third row is the load-bearing one: chunking was active and bounded each transfer round trip to a few seconds, and the timeout still fired inside the 19 s apply.
Concrete consequences, all observed on that ensemble:
snapshot install task for peer N timed out: 10000 ms, reset snapshot sync context, immediately followed byno snapshot sync context for this peer, drop the response— even though the follower loggedsuccessfully receive a snapshotand applied it.next_log_idx,matched_idxand in particularnext_log_idx_floorare never set from the installed snapshot, so nothing bounds a subsequent backward log walk. On builds without Fix append sync below snapshot boundary #109 the leader then rewinds one index per round trip, loggingdeclined append: peer N, prev next log idx ..., resp next ...repeatedly. On builds with Fix append sync below snapshot boundary #109 the walk is absorbed in the same second viapeer N compacted snapshot boundary, but the wasted install still happens.Session expirederrors.matched_idxis never set, the peer does not count toward the commit quorum for that whole interval.snapshot_sync_is_needed, and a follower answeringRECEIVING_SNAPSHOTwhile applying is exactly what sets that flag.Root cause
raft_server::handle_hb_timeout→request_append_entriescallscheck_snapshot_timeout(src/handle_snapshot_sync.cxx), which on expiry callsclear_snapshot_sync_ctxand destroys the peer'ssnapshot_sync_ctx. When the follower's terminal response finally arrives,handle_peer_respdispatches it toraft_server::handle_install_snapshot_resp, which findsp->get_snapshot_sync_ctx() == nullptrand returns after logging the drop — so the three writes on the success path, all of which readsync_ctx->get_snapshot()->get_last_log_idx(), never run.The drop itself is correct given the information available, which is why it has survived:
resp_msgcarriesterm/src/next_idx/accepted/ctxand not the installed snapshot's Raft index. Forlogical_objectsnapshots — what ClickHouse Keeper sends viaKeeperStateMachine::read_logical_snp_obj—next_idxis an object cursor and the terminalctxis a bare done flag: a single zero byte written inhandle_install_snapshot_req. The index exists only insidesync_ctx. Without it the response is genuinely uninterpretable, and binding a peer to the wrong snapshot on unverifiable information would be worse than dropping. So the defect is not the timeout and not the drop — it is that the acknowledgement is not self-describing.Fix
Make the acknowledgement carry the one thing the leader's completion bookkeeping actually needs from the context: the installed snapshot's
last_log_idx. The newsnp_install_done_ctxinsrc/handle_snapshot_sync.cxxowns the format, the follower emits it at the single genuinely successful terminal site inhandle_install_snapshot_req, and the leader reads it inhandle_install_snapshot_resp:len == 1with tag0remains the historical indexless marker. For every tag>= 1, bytes 1..8 are the index — fixing that prefix lets a leader read the index out of a tag it does not otherwise understand, so later extensions stay additive. Anything else is malformed and is rejected rather than read as a completion: falling back to "treat it as the old marker" would be the permissive reading here, because in this code the old marker means completion.All parsing happens inside the
resp.get_accepted()path and nowhere else, so rejected responses keep their current behaviour verbatim — that branch deliberately rewrites the peer's position and zeroes the floor as follower-ahead recovery, which must not be disturbed. Within the accepted path:(0, precommit_index_], andidx + 1cannot wrap. Every write is monotone (max), so a late acknowledgement can never move a peer backwards — that is what makes acting without the context safe at all. The rest of the success bookkeeping runs too, including clearingsnapshot_sync_is_needed; without that last part the fix would save little, sincecreate_append_entries_reqwould start another full install as soon as the leader held a newer snapshot.matched_idxfeedsget_expected_committed_log_idx, so crediting a peer with a snapshot it never installed is a safety problem and not merely bookkeeping. Only the index that really was installed is credited, monotonically, and the live transfer is left to finish and report for itself.The timeout is not removed and the drop is not made unconditional. Some timeout must exist, because
clear_snapshot_sync_ctx→destroy_user_snp_ctxis what releases the state machine's user snapshot context; holding that open indefinitely for a possibly-dead follower would leak. This change only makes the drop a fallback instead of the normal outcome.Eight cases were added to
tests/unit/snapshot_test.cxx:late_snapshot_install_ack_accepted_test,..._monotonic_test,..._no_reinstall_test,..._validation_test,..._wrong_snapshot_test,..._legacy_marker_test,..._malformed_testand..._future_format_test. Five fail without this change (accepted, no-reinstall, wrong-snapshot, malformed, future-format), verified by revertingsrc/handle_snapshot_sync.cxxwhile keeping the tests; the other three pass on the unpatched code because it drops the acknowledgement or is unchanged by design, so they guard the new logic rather than reproduce the defect. The pre-existing suites are what guarantee the untouched paths still hold — in particularsnapshot_basic_test,snapshot_new_member_restart_testandsnapshot_leader_switch_testcover normal, timely installs, andsnapshot_rewind_floor_testcovers the #109 floor clamping that this change now feeds a non-zero floor into. All pass withcmake -DDISABLE_ASIO=1:snapshot_test33/33,raft_server_test26/26,leader_election_test9/9,learner_new_joiner_test4/4,failure_test7/7,serialization_test11/11,buffer_test5/5. The change was also built and run inside ClickHouse — 177/177 Keeper and coordination unit tests.raft_server_handlerandFakeNetworkgain accessors so the tests drive the realcheck_snapshot_timeoutand readprecommit_index_rather than emulating the post-timeout state.Compatibility
Degrades safely in both directions, which matters because the incident happened during a mixed-version rollout:
resp_msg::get_ctxis non-null, and it never inspects the payload, so a 9-byte context is still read as done0context takes the legacy row above and behaves exactly as beforeDeliberately not addressed
raw_binarysnapshots, whose completion test isresp.get_next_idx() >= snp->size()and needs the snapshot object regardless.handle_install_snapshot_resp_new_member, the add-server path, which has the identical shape. The follower emits the extended payload there too, which is harmless because that handler ignores the payload.Question for reviewers
Is extending the terminal
ctxacceptable, or would you prefer a dedicatedresp_msgfield or aflagsbit? The compatibility argument above depends on the current completion test staying "non-null only", and you may reasonably want that invariant made explicit rather than relied upon.Related: #109
Related: #118
Related: ClickHouse/ClickHouse#112405