fix(range_upload): don't drop the tail of segments partially swallowed by window extension - #948
fix(range_upload): don't drop the tail of segments partially swallowed by window extension#948coyotte508 wants to merge 2 commits into
Conversation
…d by window extension
|
Claude generated For Assaf: context for chunker protocol protection Question considered: we need re-chunking the composed file later to produce the same chunks (canonical chunking). Does this change break that? No — it strengthens that guarantee rather than breaking it. The composed file is canonical (a full re-chunk reproduces its chunk list) iff the cleaner's re-chunk of the window lands a boundary exactly at
So canonicity hinges entirely on the resync at Two consequences:
|
|
(btw the PR was fully LLM-generated, feel free to rework it / close & reopen / etc) |
Add `test_two_windows_each_ending_mid_segment`, exercising the per-window `effective_end` bookkeeping across two windows: round 1 makes both edits so the base file has segments near each edit site, which is what lets the server return two windows instead of one coalesced window. Comment/doc fixes: - `compose_mdb`'s skip loop is equivalent whether bounded by `w.end` or `w.effective_end`; what preserves the partially-covered segment's tail is the window streaming original bytes through `effective_end`. - `UploadedWindow` offsets never exceed `original_size`. - Spell out why the tail chunks must be bit-identical, and that the `acc == hash_split_size` check is what verifies it. - Reword the chunk-boundary error and the `DirtyInput` bullet count. Simplify: truncate the window chunk list instead of carrying a merge count, and use `MDBFileInfo::file_size()` in the tests.
|
btw @assafvayner & xet team, please merge if ok with PR! (I mean I'm not sure I should merge or not, leaving it up to you) |
|
@coyotte508 Sorry for the delay. I'll review and merge |
The bug
upload_rangessnaps the caller's dirty ranges to segment boundaries before sending them toGET /v2/file-chunk-hashes, but the server (and the mirrored logic inchunk_window_builder.rs) extends each window past the requested end until two consecutive clean chunks have stable CDC sizes (is_stable_chunk_size) — always at least two chunks past the dirty range unless it hits EOF. So the returneddirty_byte_rangeend usually lands mid-segment.compose_mdbthen skipped original segments with:which skips every original segment whose start is
< w.end— including a segment only partially covered by the window. The remainder of that segment,[w.end, next_segment_boundary), was silently dropped from the composed MDB: the composed segment list came out shorter than the file (e.g. 590,533 bytes of an 8 MB file in the new regression test).Why existing tests missed it
random_datagenerator (((i+seed)*2654435761) >> 16bytes) is so weakly random that the CDC chunker only ever produces max-size (128 KB) chunks, which are all unstable peris_stable_chunk_size(stable range is[16 KB, 120 KB)for the 64 KB target). With no stable pair ever found, server windows always extend to EOF, where composition is trivially correct — the partial-swallow path was never exercised.assert_eq!(result.hash(), clean_hash.hex())pattern first performs a clean upload of the identical expected content, which registers the same file hash with a correct MDB — masking the truncated one for any subsequentget_file_reconstruction_info/download.Production impact
Production xetcas does verify the file hash against the composed shard, so it rejects the shard and the whole edit operation fails loudly — i.e. mid-file range edits via
upload_rangesfail on realistic (high-entropy) data whenever the window extension stops mid-segment, which is the common case.The fix (client-side, no API change)
For each server window, compute
effective_end= the first original segment boundary>= w.end(the trailing entry ofseg_byte_startsis the file size, so it always exists):effective_end(notw.end) into the window's cleaner, with the matchingmiddle_size. This is safe hash-wise: the server's stable-boundary rule guarantees the re-chunker has re-synced with the original chunk sequence byw.end, and the chunker resets its state at every chunk boundary, so the chunks produced for[w.end, effective_end)are bit-identical to the original file's chunks there.hash_ranges) cover original chunks starting atw.end, so only the window chunks covering the first(w.end - w_start) + added - removedoutput bytes enter the window'sMerkleHashSubtree::from_chunks; the tail chunks duplicate gap-covered chunks and are excluded. An internal error is returned if that split size doesn't land exactly on a chunk boundary (re-sync failure — should never happen).effective_endincompose_mdb's segment-skip loop, so no segment is ever partially swallowed: the window's ownmdb.segments(which now cover[w_start, effective_end)) supply data + verification for the whole span. This also keepsgap_verificationconsumption consistent with the server contract: the server emits no entry for a segment partially overlapped by one of its windows, and witheffective_endthe client re-uploads that whole segment, consuming no entry.effective_endwould cross the next window's start, rather than compose a corrupt file.Leftover-edit accounting and edit-to-window assignment still use the server's
w.end(edits always lie within the requested range, which is<= w.end).The same strategy is already implemented and validated against production xetcas in huggingface.js: huggingface/huggingface.js#2407
Tests
strong_random_datahelper (splitmix64): high-entropy bytes giving a realistic CDC chunk-size distribution with stable-sized chunks, plus a warning comment onrandom_dataexplaining why it can't exercise these paths.test_second_edit_round_keeps_partially_swallowed_segment_tail: two rounds of edits on strong-random data; asserts the composed MDB'sunpacked_segment_bytessum equals the file size and the downloaded content matches byte-for-byte — before any clean upload of the same content (which would mask the bug). Fails on the old code withcomposed MDB is truncated: segments cover 590533 of 8388608 bytes.test_mid_file_edit_window_ends_mid_segment: queriesget_file_chunk_hashesdirectly and asserts the returned window end is not on a segment boundary (proving the partial-swallow path is exercised), then asserts complete/correct composition. Also fails on the old code.cargo test -p xet-data --features simulation --lib range_upload: 30 passed, 0 failed, 3 ignored (stress tests). No new clippy or fmt diagnostics on the touched file.cc @assafvayner
Note
High Risk
Changes core
upload_ranges/compose_mdbbehavior for partial-segment CDC windows; incorrect chunk trimming or segment skipping would corrupt file hashes and MDBs in production.Overview
Fixes range upload composition when the CAS server extends each dirty window past the requested end to a stable CDC boundary, which usually stops mid-segment. Previously,
compose_mdbskipped original segments using onlyw.end, so the tail[w.end, segment_boundary)was dropped from the composed MDB—production rejects those shards; the sim server did not catch it.For each window, the client now computes
effective_end(next original segment boundary ≥w.end), re-streams original bytes through that boundary into the cleaner, trims duplicate tail chunks before the Merkle hash merge (gap subtrees still cover fromw.end), and skips replaced segments usingeffective_endwhen splicing MDB segments. Internal errors guard crossed adjacent windows and missing chunk boundaries atw.end.Adds
strong_random_dataand regression tests (multi-round edits, mid-segment window probes, two-window cases) that assert full MDB coverage before a clean upload would mask a truncated MDB; documents why the oldrandom_datahelper never exercised this path.Reviewed by Cursor Bugbot for commit a771935. Bugbot is set up for automated code reviews on this repo. Configure here.