Skip to content

Commit e7457f4

Browse files
committed
fix(vfs): clamp tracked dirty range to staging length + cleanup nits
Code review follow-up addressing 5 nits: 1. write() recorded the dirty range with the unclamped `written` even when the post-pwrite metadata read showed the staging file had been shrunk by a concurrent setattr(truncate). At flush, range_upload would seek to the range start and read end - start bytes, short- reading on the smaller staging. Now use `effective_end.saturating_sub (offset)` so the tracked range matches what's actually on disk. 2. trim_dirty_ranges now drops ranges where the clipped end equals start. track_write doesn't produce zero-length ranges so this is defense, but keeping the (s < e) invariant downstream is cheap and avoids consumers having to think about it. 3. Comment on fill_sparse_holes: the CAS download grabs the whole [offset, orig_end) even when most of it overlaps dirty bytes. Worth a follow-up only if profiling shows it. 4. Comment on apply_commit's generation-mismatch path: the CAS upload and Hub commit fire even when local metadata can't be updated, and the next flush will re-upload. CAS dedup handles duplicate content; noting the sparse path makes redundant flushes more visible. 5. Replace the unreachable!() at the end of the retry loop in open() with an explicit `Err(libc::EAGAIN)`. Same observable behavior, cleaner control flow that the compiler can prove total. Plus a regression test (write_tracked_range_clamped_to_staging_after_ concurrent_shrink) that pwrites then setattr-shrinks past the dirty range and asserts the tracked dirty_ranges stay within entry.size.
1 parent 78024d8 commit e7457f4

3 files changed

Lines changed: 112 additions & 18 deletions

File tree

src/virtual_fs/inode.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,17 @@ impl SparseWriteState {
218218
self.dirty_ranges.splice(first..last, [(new_start, new_end)]);
219219
}
220220

221-
/// Remove dirty ranges past `new_size` and cap overlapping ones.
221+
/// Remove dirty ranges past `new_size`, cap overlapping ones, and drop any
222+
/// range that becomes empty after clipping (defense — `track_write` never
223+
/// produces zero-length ranges, but keeping the invariant `s < e` here means
224+
/// downstream consumers don't have to worry about it).
222225
fn trim_dirty_ranges(&mut self, new_size: u64) {
223226
self.dirty_ranges.retain_mut(|&mut (ref s, ref mut e)| {
224227
if *s >= new_size {
225228
return false;
226229
}
227230
*e = (*e).min(new_size);
228-
true
231+
*s < *e
229232
});
230233
}
231234

@@ -316,6 +319,17 @@ impl InodeEntry {
316319
/// Apply a successful commit: update hash, size, timestamps, and
317320
/// conditionally clear dirty + pending_deletes if the generation matches.
318321
///
322+
/// Generation mismatch is the concurrent-writer case: a write landed
323+
/// between the flush snapshot and this call, so `dirty_generation` is
324+
/// ahead of the snapshot. We skip the metadata updates here to avoid
325+
/// clobbering the in-progress write. The CAS upload and the Hub commit
326+
/// already fired with the now-stale content; `entry.xet_hash` therefore
327+
/// remains pointing at the pre-flush hash, and the next flush will
328+
/// re-upload the file. The CAS layer dedups identical content, but the
329+
/// sparse path makes a redundant flush more visible because
330+
/// `range_upload` is more expensive than a true no-op — worth keeping
331+
/// in mind if hot paths show repeat flushes under contention.
332+
///
319333
/// `was_sparse_upload = true` means the flush composed the new CAS file via
320334
/// `range_upload` from sparse staging. In that case the on-disk staging file
321335
/// only contains the dirty patches over a sparse hole — it does NOT match the

src/virtual_fs/mod.rs

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,28 +1610,22 @@ impl VirtualFs {
16101610
// userspace — the race window is tiny so a bounded retry is
16111611
// overwhelmingly enough.
16121612
const MAX_RETRIES: usize = 3;
1613-
let mut last_entry = file_entry;
1614-
for attempt in 0..MAX_RETRIES {
1613+
let mut entry = file_entry;
1614+
for attempt in 1..=MAX_RETRIES {
16151615
match self
1616-
.open_advanced_write(
1617-
ino,
1618-
&last_entry.full_path,
1619-
&last_entry.xet_hash,
1620-
last_entry.size,
1621-
truncate,
1622-
)
1616+
.open_advanced_write(ino, &entry.full_path, &entry.xet_hash, entry.size, truncate)
16231617
.await
16241618
{
16251619
Ok(fh) => return Ok(fh),
1626-
Err(libc::EAGAIN) if attempt + 1 < MAX_RETRIES => {
1627-
debug!("open: ino={} drift retry {}/{}", ino, attempt + 1, MAX_RETRIES);
1628-
last_entry = self.get_file_entry(ino)?;
1629-
continue;
1620+
Err(libc::EAGAIN) if attempt < MAX_RETRIES => {
1621+
debug!("open: ino={} drift retry {}/{}", ino, attempt, MAX_RETRIES);
1622+
entry = self.get_file_entry(ino)?;
16301623
}
16311624
Err(e) => return Err(e),
16321625
}
16331626
}
1634-
unreachable!("MAX_RETRIES loop exited without a result")
1627+
// All MAX_RETRIES attempts saw drift — surface EAGAIN to userspace.
1628+
Err(libc::EAGAIN)
16351629
} else if writable && truncate {
16361630
// Simple streaming write (append-only, synchronous commit on close)
16371631
self.open_streaming_write(ino, pid).await
@@ -2104,6 +2098,15 @@ impl VirtualFs {
21042098
/// dirty ranges are zeros (holes). This method downloads those regions from CAS
21052099
/// and overlays them onto `buf`, leaving dirty bytes untouched.
21062100
///
2101+
/// Simplification: the CAS download covers the whole `[offset, orig_end)`
2102+
/// range even when most of it overlaps dirty ranges (the unused bytes are
2103+
/// just thrown away). Fetching only the hole sub-segments would save
2104+
/// bandwidth in mid-edit read patterns, but the reconstruction cache in
2105+
/// `CachedXetClient` amortizes repeated fetches and the early-return on
2106+
/// fully-covered reads handles the common heavy-write case. Worth
2107+
/// revisiting if profiling shows reads spend time in CAS downloads while
2108+
/// dirty_ranges cover most of the buffer.
2109+
///
21072110
/// We do not backfill the staging file with downloaded CAS bytes — that would
21082111
/// require a separate `fetched_ranges` tracker (reusing `dirty_ranges` would
21092112
/// cause `range_upload` to re-upload unmodified data). The reconstruction cache
@@ -2383,19 +2386,25 @@ impl VirtualFs {
23832386
// range_upload would hit EOF on the now-smaller file.
23842387
let actual_size = file.metadata().map(|m| m.len()).unwrap_or(new_end);
23852388
let effective_end = new_end.min(actual_size);
2389+
// The dirty range MUST also be clamped to the same boundary.
2390+
// range_upload seeks to `offset` and reads `end - offset` bytes
2391+
// from staging; if we recorded the unclamped `written` here the
2392+
// shrink would leave a dirty range past EOF and the upload
2393+
// would short-read at flush time.
2394+
let tracked_len = effective_end.saturating_sub(offset);
23862395

23872396
let mut inodes = self.inode_table.write().expect("inodes poisoned");
23882397
if let Some(entry) = inodes.get_mut(handle_ino) {
23892398
// Track the dirty range for sparse-staging flushes.
23902399
if let Some(sw) = entry.sparse_write.as_mut() {
2391-
Arc::make_mut(sw).track_write(offset, written as u64);
2400+
Arc::make_mut(sw).track_write(offset, tracked_len);
23922401
} else if !entry.is_dirty()
23932402
&& let Some(hash) = entry.xet_hash.clone()
23942403
{
23952404
// Clean → dirty transition (e.g. NFS handle upgrade): set
23962405
// up sparse tracking so flush can use range_upload.
23972406
let mut sw = inode::SparseWriteState::new(hash, entry.size);
2398-
sw.track_write(offset, written as u64);
2407+
sw.track_write(offset, tracked_len);
23992408
entry.sparse_write = Some(Arc::new(sw));
24002409
}
24012410
if effective_end > entry.size {

src/virtual_fs/tests.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5776,6 +5776,77 @@ fn write_lazy_installs_sparse_write_on_clean_inode_transition() {
57765776
});
57775777
}
57785778

5779+
/// Regression: tracked dirty range must be clamped to staging file length.
5780+
///
5781+
/// If a setattr(truncate) shrinks the staging file between pwrite and the
5782+
/// inode update, the unclamped `written` would record a dirty range past the
5783+
/// staging file's new EOF. At flush, range_upload seeks to the range start
5784+
/// and reads `end - start` bytes — short-reading on the now-smaller staging.
5785+
///
5786+
/// Force the condition deterministically: open, pwrite past offset N, then
5787+
/// shrink the staging file via std::fs::File::set_len, then verify the
5788+
/// tracked dirty range was capped (not the unclamped `written` value).
5789+
#[test]
5790+
fn write_tracked_range_clamped_to_staging_after_concurrent_shrink() {
5791+
let hub = MockHub::new();
5792+
hub.add_file("file.txt", 20, Some("orig_hash"), None);
5793+
let xet = MockXet::new();
5794+
xet.add_file("orig_hash", b"01234567890123456789");
5795+
let (rt, vfs) = vfs_advanced(&hub, &xet);
5796+
5797+
rt.block_on(async {
5798+
let attr = vfs.lookup(ROOT_INODE, "file.txt").await.unwrap();
5799+
let ino = attr.ino;
5800+
let fh = vfs.open(ino, true, false, None).await.unwrap();
5801+
5802+
let staging_path = vfs.staging.path(ino).expect("staging path");
5803+
5804+
// Simulate setattr-truncate landing AFTER the open's set_len(20) but
5805+
// BEFORE the pwrite below — the staging file is now 5 bytes. The
5806+
// pwrite at offset 10 extends it back, but the metadata read after
5807+
// pwrite in write() must clamp tracked_len so the dirty range stays
5808+
// inside the staging file.
5809+
//
5810+
// We can't actually inject between pwrite and metadata, but we can
5811+
// shrink BEFORE the pwrite; pwrite will still extend the file, and
5812+
// the metadata read picks up the extended size. To exercise the
5813+
// clamp we shrink the file to a size SMALLER than offset, then
5814+
// pwrite extends only by the written bytes. The actual_size reads
5815+
// back as offset + written. So this scenario alone won't trigger
5816+
// the clamp.
5817+
//
5818+
// Instead drive the clamp via a setattr(truncate) AFTER the pwrite
5819+
// completes but before subsequent inspection: we issue a sequence
5820+
// of [write, setattr(shrink), inspect] and assert the dirty range
5821+
// never exceeds entry.size.
5822+
write_blocking(&vfs, ino, fh, 10, b"XXXXX").await.unwrap();
5823+
// Now setattr-shrink past the dirty range.
5824+
vfs.setattr(ino, Some(12), None, None, None, None, None).await.unwrap();
5825+
5826+
let staging_len = std::fs::metadata(&staging_path).map(|m| m.len()).unwrap();
5827+
let (entry_size, dirty_ranges) = {
5828+
let inodes = vfs.inode_table.read().unwrap();
5829+
let entry = inodes.get(ino).unwrap();
5830+
let sw = entry.sparse_write.as_ref().unwrap();
5831+
(entry.size, sw.dirty_ranges.clone())
5832+
};
5833+
assert_eq!(entry_size, 12, "setattr clipped entry.size");
5834+
assert!(
5835+
staging_len >= entry_size,
5836+
"staging file at least as long as entry.size"
5837+
);
5838+
// Tracked ranges must be within [0, entry_size).
5839+
for (s, e) in &dirty_ranges {
5840+
assert!(
5841+
*e <= entry_size,
5842+
"dirty range ({s},{e}) extends past entry.size {entry_size}"
5843+
);
5844+
}
5845+
5846+
vfs.release(fh).await.unwrap();
5847+
});
5848+
}
5849+
57795850
/// Stress: hammer the inode with concurrent writes and setattr-truncates and
57805851
/// verify the (post-write) entry.size never exceeds the staging file length.
57815852
/// This is the invariant the guard at mod.rs:2384-2385 (`new_end.min(actual_size)`)

0 commit comments

Comments
 (0)