Skip to content

feat(vfs): sparse writes v3, session-scoped coverage staging - #187

Open
XciD wants to merge 2 commits into
mainfrom
feat/sparse-writes-v3
Open

feat(vfs): sparse writes v3, session-scoped coverage staging#187
XciD wants to merge 2 commits into
mainfrom
feat/sparse-writes-v3

Conversation

@XciD

@XciD XciD commented Jun 12, 2026

Copy link
Copy Markdown
Member

Context

Third attempt at the sparse-write feature, replacing #41 (feat/append-write lineage) and #180 (coverage-map rewrite). Both previous attempts converged on working happy paths and then accumulated corruption edge cases in review. Two deep review passes over #180 produced an inventory of every failure found across both attempts; this PR was rebuilt from main around the structural causes instead of patching symptoms.

The requirement, re-analyzed

Edit huge CAS-backed files in place (the delta-weight-sync workload: open an existing model file, write small deltas, fsync, close) without downloading the file at open and without re-uploading it whole at flush. NFS and FUSE. Multi-client is last-writer-wins. Off by default behind --sparse-writes (implies --advanced-writes); files under a threshold (default 256 MiB, HF_MOUNT_SPARSE_MIN_BYTES) keep the standard download-then-upload path, where CDC dedup already makes small files cheap.

Why the previous attempts kept breaking

Every corruption bug found in #41 and #180 traces to one of five structural causes:

  1. Sparse state outliving its write session. Retained coverage interacted with poll rotation, reopen, and setattr drift rebuilds (silent write loss on reopen, chimeric commits after remote shrink, stale-keyed rebuilds).
  2. Remote identity rotating under live local state. Poll/HEAD revalidation rotated xet_hash/size while handles were open or an open was mid-download, so flushes committed old bytes under new identities, with no self-healing.
  3. Multiple sources of truth. staging_is_current, sparse_write, per-handle flags and is_dirty desynced; decisions made from pre-lock snapshots were stale in exactly the dangerous direction.
  4. Flush pipeline divergence. Route decisions (sparse vs full upload) re-derived at three sites; partial-failure paths left commits in limbo with no retry.
  5. Unbounded resources. Snapshot RAM, dirty-range counts, and retry storms had no caps.

Design principles in this PR (each kills a class)

Cause Mechanism
State outliving the session SparseWriteState is session-scoped: dropped at last clean close (release side) and by commit-apply when no handle remains. Idle inodes carry no sparse state at all.
Rotation under live state Identity freeze: update_remote_file refuses to rotate while any handle is open (the deletion path already did this); callers only invalidate kernel caches when a rotation actually applied; open's drift check covers both the sparse and the full-download path (EAGAIN retry).
Multiple sources of truth Untrusted staging discipline: staging files are reused only when `is_dirty
Flush divergence One route per item (UploadRoute), computed once, drives Pass A, Pass B membership, and commit dispatch. Failed/aborted commits return to the loop and retry with exponential backoff (clamped floor, no busy-mount collapse, abandoned at shutdown per the #186 bounded-drain philosophy). Missing-staging inodes resolve to a terminal state instead of blocking rotation forever.
Unbounded resources Snapshot budget (256 MiB/round, iterative compose with torn-revision abort on concurrent writes), dirty-range coalescing over covered gaps above 4096 entries, CAS-fetch retry parity with the lazy-read path, and no panic ever under the inode table lock (EIO with the entry unmutated).

What is carried over

The coverage-map core of #180 (coverage/dirty_ranges invariant, dirty_ranges ⊆ coverage structural), the range_upload xet-core integration, and the full test corpus of both PRs.

Tests

  • 420 lib tests green with --features fuse,nfs (407 default), including a regression test for every corruption class found in the two review passes, each proven red against the code it fixes.
  • Deterministic race tests via mock gates: torn multi-round compose abort, Pass A/Pass B failure convergence, rotation refusal under open handles.
  • Same-inode multi-worker stress harnesses with byte-level oracles (mock CAS).
  • fsx_paranoid (CAS round-trip after every random mutation) and sparse_concurrent_real (multi-worker, real CAS) wired into the fsx CI job with --sparse-writes.
  • Local CI proxy run before push: fmt --check, clippy -D warnings on all four feature combos, lib tests with fuse,nfs, nfs_ops 4/4.

Known limitations (documented in code)

  • A file held open indefinitely freezes its remote metadata until last close (deliberate: serving a frozen identity beats chimeric commits; the next poll cycle re-syncs).
  • Hole-fragmented write-only workloads (random small writes, never read) still grow the dirty-range vec; only the covered-gap coalescing bounds the common read-mostly case. An interval tree is the eventual fix if this workload materializes.
  • Multi-round composes leave intermediate (uncommitted) CAS file entries server-side, one per extra round; only over-budget (>256 MiB dirty) flushes produce them.

Supersedes #41 and #180.

XciD added 2 commits June 12, 2026 23:39
Open-for-write on a CAS-backed file punches a sparse hole instead of
downloading the content; reads fill holes lazily from CAS and cache them
into staging; flush composes the new revision via range_upload from the
dirty ranges only. Off by default behind --sparse-writes (implies
--advanced-writes), with a small-file threshold (default 256 MiB,
HF_MOUNT_SPARSE_MIN_BYTES) below which the standard download-then-upload
path stays in effect.

Third iteration of this feature. The previous two (#41, #180) kept
accumulating corruption edge cases rooted in the same structural causes;
this one removes the causes instead of patching their symptoms:

- Session-scoped sparse state: SparseWriteState lives strictly from the
  first write-open to the last clean close (release-side drop plus
  commit-apply drop when no handle remains). No retained cross-session
  state for poll rotation, reopen reclassification, or setattr drift
  rebuilds to interact with.
- Identity freeze: update_remote_file refuses to rotate xet_hash/size
  while ANY handle is open (callers only invalidate kernel caches when
  the rotation applied), and open's drift check covers both the sparse
  and the download path, so an open racing a remote update retries
  instead of committing old bytes under a new identity.
- Untrusted staging discipline: a staging file is reused only when the
  inode marks it meaningful (is_dirty || staging_is_current); a bare
  exists() is never trusted, killing the leftover-file-committed-as-
  content class. Decisions are re-read under the install write lock, not
  from pre-lock snapshots.
- One flush route per item: UploadRoute (FullStaging | SparseCompose) is
  computed once and drives Pass A, Pass B membership, and the commit
  dispatch; routes cannot disagree.
- Bounded everything: per-round snapshot budget (256 MiB default) with
  torn-compose abort on concurrent writes, dirty-range coalescing over
  covered gaps above 4096 entries, CAS-fetch retry parity with the lazy
  read path, and flush retry carryover with exponential backoff and a
  clamped floor.
- No panics under locks: invariant violations surface as EIO with the
  entry unmutated (and this call's staging creation undone).
Carries the accumulated test corpus from both previous attempts plus
regression tests for every corruption class found in review:

- VFS invariant tests: open skips download, lazy hole fill and staging
  cache, flush composition, setattr paths, drift handling, no-op flush
  hash retention.
- Regression tests: reopen-after-fast-path-commit write loss, rotation
  under open handle (chimeric size/content commit), torn multi-round
  compose, untrusted staging leftovers, non-Xet rotation EIO (lock
  poisoning), session-scoped state drop, flush retry convergence after
  Pass A/Pass B failures, snapshot budget bounding, eligibility-predicate
  unification, transient CAS failure retry on sparse reads.
- Same-inode multi-worker stress harnesses (mock CAS) with byte-level
  oracle verification.
- fsx_paranoid (CAS round-trip per mutation) and sparse_concurrent_real
  (multi-worker same-inode against real CAS) wired into the fsx CI job
  with --sparse-writes.
@github-actions

Copy link
Copy Markdown
Contributor

POSIX Compliance (pjdfstest)

============================================================
  pjdfstest POSIX Compliance Results
------------------------------------------------------------
  Files: 130/130 passed    Tests: 832 total (0 subtests failed)
  Result: PASS
------------------------------------------------------------
  Category               Passed    Total   Status
  -------------------- -------- -------- --------
  chflags                     5        5       OK
  chmod                       8        8       OK
  chown                       6        6       OK
  ftruncate                  13       13       OK
  granular                    5        5       OK
  mkdir                       9        9       OK
  open                       19       19       OK
  posix_fallocate             1        1       OK
  rename                     10       10       OK
  rmdir                      11       11       OK
  symlink                    10       10       OK
  truncate                   13       13       OK
  unlink                     11       11       OK
  utimensat                   9        9       OK
============================================================

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results

============================================================
  Benchmark — 50MB
------------------------------------------------------------
  Metric                                 FUSE          NFS
  ------------------------------ ------------ ------------
  Sequential read                    223.7 MB/s     251.5 MB/s
  Sequential re-read                2037.7 MB/s    2101.1 MB/s
  Range read (1MB@25MB)                0.5 ms         0.2 ms
  Random reads (100x4KB avg)           0.0 ms         0.0 ms
  Sequential write (FUSE)           1206.6 MB/s
  Close latency (CAS+Hub)            0.085 s
  Write end-to-end                   394.3 MB/s
  Dedup write                       1429.9 MB/s
  Dedup close latency                0.107 s
  Dedup end-to-end                   352.6 MB/s
============================================================
============================================================
  Benchmark — 200MB
------------------------------------------------------------
  Metric                                 FUSE          NFS
  ------------------------------ ------------ ------------
  Sequential read                    600.3 MB/s     948.6 MB/s
  Sequential re-read                1976.5 MB/s    2103.8 MB/s
  Range read (1MB@25MB)                0.3 ms         0.2 ms
  Random reads (100x4KB avg)           0.0 ms         0.0 ms
  Sequential write (FUSE)           1535.4 MB/s
  Close latency (CAS+Hub)            0.158 s
  Write end-to-end                   694.2 MB/s
  Dedup write                       1545.1 MB/s
  Dedup close latency                0.126 s
  Dedup end-to-end                   783.4 MB/s
============================================================
============================================================
  Benchmark — 500MB
------------------------------------------------------------
  Metric                                 FUSE          NFS
  ------------------------------ ------------ ------------
  Sequential read                   1122.5 MB/s     794.7 MB/s
  Sequential re-read                1974.9 MB/s    2168.4 MB/s
  Range read (1MB@25MB)                0.3 ms         0.2 ms
  Random reads (100x4KB avg)           0.0 ms         0.0 ms
  Sequential write (FUSE)           1521.9 MB/s
  Close latency (CAS+Hub)            0.125 s
  Write end-to-end                  1102.6 MB/s
  Dedup write                       1499.6 MB/s
  Dedup close latency                0.127 s
  Dedup end-to-end                  1087.0 MB/s
============================================================
============================================================
  fio Benchmark Results
------------------------------------------------------------
  Job                        FUSE MB/s   NFS MB/s  FUSE IOPS   NFS IOPS
  ------------------------- ---------- ---------- ---------- ----------
  seq-read-100M                  450.5      375.9                      
  seq-reread-100M               2040.8       27.0                      
  rand-read-4k-100M                0.1        0.1         19         16
  seq-read-5x10M                 735.3      781.2                      
  rand-read-10x1M                  0.1        0.1         34         37
  Random Read Latency           FUSE avg      NFS avg
  ------------------------- ------------ ------------
  rand-read-4k-100M           53811.4 us   62576.8 us
  rand-read-10x1M             29020.5 us   26892.5 us
============================================================

@XciD
XciD marked this pull request as ready for review June 13, 2026 08:44
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.

1 participant