fix(engine): give local --append-verify the phase-2 redo model - #7235
Merged
Conversation
oferchen
force-pushed
the
fix/local-append-verify-redo
branch
from
August 6, 2026 21:28
28d9254 to
d0fb281
Compare
oferchen
marked this pull request as ready for review
August 6, 2026 22:42
oferchen
force-pushed
the
fix/local-append-verify-redo
branch
from
August 7, 2026 00:34
d0fb281 to
5abe965
Compare
The local-copy executor treated --append-verify as an a-priori prefix comparison: determine_append_mode() compared the destination prefix against the source before transferring and, on mismatch, returned AppendMode::Disabled - silently degrading to an ordinary single-pass whole-file copy. The final bytes were right, so nothing failed, but the whole append -> verify -> retain -> redo model was absent: local --append-verify never appended, never retained a partial, never warned, and never ran a second pass. Upstream's order is the opposite. Pass one is always a pure append: the sender jumps last_match to the destination length and zeroes the block count (match.c:372-391) and the generator emits a sum header with no block sums (generator.c:787). receive_data() then compares whole-file checksums (receiver.c:517-519), which under --append-verify fold in the pre-existing prefix on both sides (match.c:373-386, receiver.c:357-371). A mismatch keeps the appended bytes, because --append implies --inplace (options.c:2400-2411) and receiver.c:1029 takes its `|| inplace` leg for recv_ok == 0, warns (receiver.c:1063-1097), and asks the generator to redo the file with append_mode negated and ignore_times bumped (generator.c:2186-2200) against a session whose whole_file was already forced to 0 (generator.c:2288-2289). Upstream runs that loop locally too - local_server (main.c:1468) forks local_child (main.c:649-655) and do_recv forks again (main.c:1050) so recv_files and generate_files run over a socketpair. execute_transfer is split into execute_transfer_once, which reports a TransferOutcome, and a verify_redo wrapper that supplies the second pass. determine_append_mode now appends regardless and reports verify_failed; the prefix comparison is kept as the predicate because locally it is the whole-file comparison, both sides summing the same appended tail. Measured local pull, 200 KiB source over a 100 KiB zero-filled seed, against rsync 3.4.4: transfers 1 -> 2, Literal 204,800 -> 205,300, Total transferred file size 204,800 -> 409,600, and the retained-update WARNING now reaches stderr under -v and stays silent by default, all matching upstream exactly. Matched data remains over-counted by the pre-existing append accounting defect, which reproduces on plain --append with a matching prefix and is tracked separately.
execute_with_append_verify_rewrites_on_mismatch asserted 6 literal bytes for a 6-byte source over a 3-byte mismatching seed - the count you get only if the append never happens and the file is copied whole in one pass. Measured on that exact fixture, rsync 3.4.4 reports 2 transfers and 9 literal bytes: 3 appended, then all 6 re-sent as literal by the redo, because the 6-byte basis is a single short block that cannot match. Matched data is pinned at its current 3 rather than upstream's 0. Append mode never calls matched() (match.c:389-390 zeroes the block count and skips the hash loop) so the pre-existing prefix contributes nothing upstream, while the local summary derives matched as file_size - literal_bytes. That accounting defect is pre-existing and tracked separately; pinning it here makes the assertion fail loudly and name the upstream answer once it is fixed.
Both were re-read against rsync-3.4.4 rather than trusted. The whole-file checksum comparison is receiver.c:518-519, not 517 - 517 is the DEBUG_GTE(DELTASUM,2) "got file_sum" trace just above it. The leg that makes a dry run skip the recv_ok switch entirely is the `if (!do_xfers)` block at receiver.c:805-810; receiver.c:797 is the unrelated read-batch "Skipping batched update" path.
The local summary derived `matched = file_size - literal_bytes`, which silently assumes every byte that was not literal came from a block match. Append mode falsifies that: the pre-existing prefix is neither literal nor matched, so the derivation reported the whole skipped prefix as matched data. Upstream never derives this figure. `stats.matched_data` grows in exactly one place, `matched()` at match.c:121, reached only through `hash_search()`. A whole-file transfer has `s->count == 0` so the hash loop never runs, and append mode zeroes the count outright (match.c:389-390 `last_match = s->flength; s->count = 0;`). Both therefore report zero matched bytes however little of the file was literal. So report it the way upstream produces it. `FileCopyOutcome` carries a matched-byte count, the delta loop accumulates it at the two points where it emits a matched block, and every path that never consults a signature - whole-file, sparse whole-file, append, the clone/reflink fast paths, and special-file placeholders - reports MATCHED_NONE. This leaves the ordinary delta case numerically identical, because there every byte really is either literal or matched, and corrects append without special-casing the statistic. MEASURED against rsync 3.4.4, `-a --append --ignore-times --stats`, source "abcdef" over a matching "abc": upstream Literal 3 / Matched 0, oc was Literal 3 / Matched 3 and is now Literal 3 / Matched 0. Two tests had encoded the derivation and now assert the upstream values.
oferchen
force-pushed
the
fix/local-append-verify-redo
branch
from
August 8, 2026 19:50
5abe965 to
88d4cb5
Compare
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
The local-copy executor implemented
--append-verifyas an a-priori prefix comparison:determine_append_mode()compared the destination's existing prefix against the source before transferring and, on mismatch, returnedAppendMode::Disabled- silently degrading to an ordinary single-pass whole-file copy.The final bytes were correct, so the bug was invisible unless you looked at the transfer accounting. But the entire append -> verify -> retain -> redo model was missing: local
--append-verifynever appended, never retained a partial, never warned, and never ran a second pass.Measured on a 200 KiB source with a destination seeded with 100 KiB of zeros (a non-matching prefix):
-vstdoutpayload.binoncepayload.bintwice-vstderrWARNING: payload.bin failed verification -- update retained (will try again).Upstream's order, which is the opposite of ours
last_matchto the destination's length and zeroes the block count, never callingmatched()(match.c:372-391), and the generator writes a sum header with no block sums (generator.c:787-if (append_mode > 0 && f_copy < 0) return 0;). Pass one is always a pure append.receive_data()compares the sender's whole-file checksum against the receiver's (receiver.c:518-519). Under--append-verifyboth sides fold the pre-existing prefix into that sum (match.c:373-386,receiver.c:357-371) and then the identical appended tail, so the comparison reduces exactly to "do the two prefixes agree".--appendimplies--inplace(options.c:2400-2411), soreceiver.c:1029takes its|| inplaceleg andfinish_transfer()runs even forrecv_ok == 0. The appended bytes stay on disk.receiver.c:1063-1097).recv_generator()withappend_modenegated andignore_timesbumped (generator.c:2186-2200), andwhole_filewas already forced to 0 for the session because append mode is active (generator.c:2288-2289), so the retained partial is described as the delta basis.Upstream runs this two-phase loop locally, not just over the network:
local_server(main.c:1468) takesdo_cmd()down thelocal_child()fork (main.c:649-655), anddo_recv()forks again (main.c:1050) sorecv_files()andgenerate_files()run concurrently over a socketpair. "Local is different" was never available as a justification.Every line cited above was re-read against
rsync-3.4.4while writing this, not carried over from an existing comment.Approach
The local executor is a separate implementation of the receiver, so it has to reproduce those semantics explicitly rather than inherit them.
execute_transferis split intoexecute_transfer_once(one pass, returns aTransferOutcome) and a thinverify_redowrapper that supplies the second pass. The wrapper is a direct transcription ofgenerator.c:2175-2217:determine_append_modeno longer cancels the append on a mismatching prefix. It appends anyway and reportsverify_failed, which is upstream'srecv_ok == 0for this file. The prefix comparison is kept as the predicate because locally it is the whole-file checksum comparison - both sides sum the same appended tail, so the two sums agree iff the two prefixes do.append_allowed/append_verifyoff,whole_file_enabledoff (generator.c:2288-2289, which is what overrides thewhole_file = 1a local transfer would otherwise default to),ignore_times_enabledon (generator.c:2188- without it the quick-check would skip the redo outright, since pass one just gave the partial the source's size and mtime), anduse_sparse_writesrestored from the session setting (receiver.c:761,771negatessparse_filesalongsideappend_mode).Matched data: same code path, fixed as a separate commitMatched datawas also wrong, and the cause turned out to be the same append path rather than an unrelated defect. The local summary derivedmatched = file_size - literal_bytes, which silently assumes every non-literal byte came from a block match. Append mode falsifies that: the pre-existing prefix is neither literal nor matched, so the whole skipped prefix was reported as matched data.Upstream never derives the figure.
stats.matched_datagrows in exactly one place,matched()atmatch.c:121, reached only viahash_search()- which a whole-file transfer never enters (s->count == 0) and which append mode skips outright (match.c:389-390setslast_match = s->flength; s->count = 0;).So it is now reported the way upstream produces it:
FileCopyOutcomecarries a matched-byte count, the delta loop accumulates it at the two points where it emits a matched block, and every path that never consults a signature (whole-file, sparse whole-file, append, the clone/reflink fast paths, special-file placeholders) reportsMATCHED_NONE. The ordinary delta case is numerically unchanged - there every byte really is either literal or matched - so this corrects append without special-casing the statistic.Measured on plain
--appendwith a matching prefix, where no verification failure and no redo are involved at all:abcdefoverabcResult
Measured, local pull, upstream 3.4.4 as the oracle, stdout and stderr captured separately:
-vstdoutpayload.binx1payload.binx2-vstderrThe default-verbosity cell is silent on stdout as well as stderr, so it does not depend on a gate that only reads stderr.
The one remaining difference on this fixture is
Total bytes sent/Total bytes received(oc 205,300 / 0 against upstream 206,089 / 5,920). That is the local path's deliberately unsynthesised wire accounting, pre-existing and tracked separately.Tests
verify_redo.rsunit tests pin each negated flag against the upstream line that negates it, and that unrelated flags survive.append.rs- the test that assertedAppendMode::Disabledon a failed verification encoded the bug; it now asserts the append still happens and reportsverify_failed.execute_append.rsassertingfiles_copied() == 1on a mismatching prefix (now 2), one inbandwidth.rsasserting 6 literal bytes where the append never happened (now 9), and one assertingmatched_bytes() == 3for an appended-over prefix (now 0).--appendnever redoes even with a wrong prefix, and its skipped prefix counts as neither literal nor matched.