Skip to content

fix: do not count defrag-prevented ranges as deduped in metrics - #886

Closed
XciD wants to merge 1 commit into
mainfrom
adrien/fix-defrag-prevented-metrics
Closed

XciD wants to merge 1 commit into
mainfrom
adrien/fix-defrag-prevented-metrics

Conversation

@XciD

@XciD XciD commented Jul 3, 2026

Copy link
Copy Markdown
Member

Problem

In FileDeduper::process_chunks, a matched dedup range is counted into deduped_chunks, deduped_bytes, total_chunks and total_bytes before the defrag-prevention gate (allow_dedup_on_next_range). When the gate rejects the range, its chunks fall through to the new-data path below, which does its own total_*/new_* accounting. As a result:

  • every defrag-prevented byte is double-counted in total_bytes (measured: total_bytes = file_size + defrag_prevented_dedup_bytes, exactly),
  • those bytes are also misreported as deduped_bytes even though they were re-stored,
  • any debug build cleaning a file that trips defrag prevention panics on the file_size == total_bytes debug_assert in SingleFileCleaner::finish.

File reconstruction info is unaffected (the registered file size and segments are correct); this is a metrics/telemetry bug plus a debug-build crash.

Fix

Move the four increments after the defrag gate so each chunk is counted exactly once, either as deduped (gate passed) or as new (gate rejected, new-data path). defrag_prevented_* counters keep their existing semantics.

Regression test

test_dedup_metrics_when_defrag_prevention_triggers reproduces the scenario organically rather than by mocking the tracker: upload content as small shuffled pieces (fragmented CAS layout, as produced by out-of-order writers), then cleanly re-upload the assembled file from a client with an empty shard cache, so dedup discovery goes through the sampled global dedup queries and only matches in piece-sized runs. The CPR tracker then rejects ranges (defrag_prevented_dedup_bytes > 0 is asserted so the test cannot pass vacuously). Before the fix the test fails on the SingleFileCleaner debug_assert with total_bytes inflated by exactly defrag_prevented_dedup_bytes; after the fix deduped + new == total == file_size holds.

Side note from the same investigation, out of scope here: on such fragmented references, defrag prevention re-stores a significant share of already-stored content (~17% in the test above, ~50% observed on production bucket files, i.e. ~2x stored bytes with heavily fragmented layouts). That looks like a design discussion worth having separately.

Deduplication metrics counted every matched range as deduped and added
it to total_bytes before the defrag-prevention gate. When the gate
rejects the range, its chunks fall through to the new-data path, which
does its own total/new accounting, so every defrag-prevented byte was
double-counted in total_bytes and misreported as deduped. Any debug
build cleaning a file that trips defrag prevention panics on the
file_size == total_bytes debug_assert in SingleFileCleaner::finish.

Move the deduped/total increments after the gate so each chunk is
counted exactly once, either as deduped or as new.

The regression test reproduces the scenario organically: content
uploaded as small shuffled pieces (fragmented CAS layout), then
cleanly re-uploaded from a client with no local shard cache. Dedup
only matches in piece-sized runs, the CPR tracker starts rejecting
ranges, and the old accounting inflated total_bytes by exactly
defrag_prevented_dedup_bytes.
XciD added a commit to huggingface/hf-mount that referenced this pull request Jul 6, 2026
## Problem

`link()` returns ENOTSUP, so hardlink-first import pipelines fall back
to a full copy through the mount. Copying a file whose bytes already
live in CAS re-chunks and re-uploads everything, and dedup against the
existing xorbs runs into xet-core's defrag prevention, which re-stores a
large share of the bytes in short fragments.

Measured on production buckets (multi-GB media files imported this way):
~2x stored bytes, +90-160% xorb overhead, reconstructions of 15-17k
segments alternating between two xorb sets, CPR pinned at the
defrag-prevention hysteresis equilibrium (~8 chunks/segment). Related:
the metrics side of that investigation is huggingface/xet-core#886.

## Change

Implement `link()` as a **server-side copy**: one batch `AddFile`
pointing at the source's committed xet hash. No bytes move through CAS,
the import is instant, and the original clean layout is preserved.

The alias gets its own inode, so this intentionally diverges from POSIX
same-inode semantics: writes to one path never affect the other, and
`st_ino` differs between the two paths. Callers that hardlink for
instant-copy semantics (the *arr import case) get exactly what they
need. The previous ENOTSUP rationale (in-memory links never persisted to
the Hub) no longer applies since the alias is a real Hub entry.

Guards, mirroring the rename/create paths:
- dirty source or no committed hash: ENOTSUP (callers keep their copy
fallback, and we never alias a stale hash)
- overlay mode: ENOTSUP (must not mutate the remote)
- directory source: EPERM, missing parent: ENOENT, non-directory parent:
ENOTDIR, existing target: EEXIST (checked against remote children too,
and re-checked under the write lock)
- read-only mount: EROFS, OS junk names: EACCES

Structured like `rename()`: validate under read lock, commit the batch
op, insert the alias under write lock (with negative-cache removal and
queued-delete cancellation for the destination path).

NFS backend unchanged: nfsserve 0.11 does not dispatch `NFSPROC3_LINK`
(proc_unavail), so hardlinks over NFS fail at the protocol level before
reaching us. Supporting it would need an upstream nfsserve change.

## Tests

Four new tests in `virtual_fs/tests.rs`: happy path (exactly one AddFile
with the source hash, no delete, alias resolvable with its own inode,
source untouched), dirty source (ENOTSUP, no remote op), existing target
(EEXIST, no remote op), directory source (EPERM). Full lib suite: 340
passed.
@seanses

seanses commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Close in favor of #931

@seanses seanses closed this Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants