Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 100 additions & 26 deletions vfs/src/nfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,34 @@ impl NfsTree {
Timer::new(&self.stats, op)
}

fn fattr(&self, attributes: &Attributes) -> fattr3 {
self.fattr_context().fattr(attributes)
}

fn fattr_context(&self) -> FattrContext {
FattrContext {
tree: self.tree.clone(),
fsid: self.fsid,
uid: self.uid,
gid: self.gid,
}
}
}

/// What building a `fattr3` needs beyond one item's own attributes.
///
/// Split out of [`NfsTree`] so a directory iterator can size its entries
/// without borrowing the tree it came from. It carries the [`OverlayTree`]
/// because both the mode bits and the attributes themselves come from there.
#[derive(Clone)]
struct FattrContext {
tree: Arc<OverlayTree>,
fsid: u64,
uid: u32,
gid: u32,
}

impl FattrContext {
fn fattr(&self, attributes: &Attributes) -> fattr3 {
let (type_, nlink) = match attributes.kind {
// nlink 2 is the conventional answer for a directory whose
Expand Down Expand Up @@ -145,7 +173,9 @@ impl NfsTree {
ctime: time,
}
}
}

impl NfsTree {
/// Attributes of an inode, as the protocol wants them.
async fn fattr_of(&self, inode: u64) -> Result<fattr3, nfsstat3> {
let attributes = self
Expand Down Expand Up @@ -323,6 +353,7 @@ impl NfsTree {
Ok(DirIterator {
entries: entries.into_iter(),
next_cookie: cookie + 1,
fattr: self.fattr_context(),
})
}
}
Expand All @@ -332,30 +363,72 @@ struct DirIterator {
entries: std::vec::IntoIter<TreeEntry>,
/// Position to hand the client for the entry about to be yielded.
next_cookie: cookie3,
fattr: FattrContext,
}

impl DirIterator {
/// The next entry, with no attribute work done for it.
///
/// `readdir` and `readdirplus` read one listing but should not pay one
/// cost: only the plus form has anywhere to put an attribute.
fn next_entry(&mut self) -> Option<(TreeEntry, cookie3)> {
let entry = self.entries.next()?;
let cookie = self.next_cookie;
self.next_cookie += 1;
Some((entry, cookie))
}
}

impl ReadDirPlusIterator<FileHandleU64> for DirIterator {
/// Inlines attributes for the entries we can size without reading content.
///
/// This returned `None` for every entry until now, and that was the right
/// trade when it was written: sizing a file meant reading it, so filling
/// these in would have turned listing a directory into reading every file
/// in it. The premise expired underneath it. `Backend::file_size` answers
/// from the git object header -- a loose object carries its length in the
/// first bytes of its zlib stream, a packed one in its pack entry header --
/// so neither is inflated, and `getattr` already takes that path.
///
/// The trade now runs the other way. Omitting attributes costs the client
/// one LOOKUP per entry it cares about, measured at 63.9us of round trip,
/// against a local object-header read well under 1us. On a cold walk of a
/// 21,196-entry tree that was 22,477 LOOKUPs, 72% of every RPC the walk
/// made. It also leaves `d_type` unset, so a caller that would have used
/// the dirent hint to avoid a stat cannot, and stats anyway.
async fn next(&mut self) -> NextResult<DirEntryPlus<FileHandleU64>> {
match self.entries.next() {
None => NextResult::Eof,
Some(entry) => {
let cookie = self.next_cookie;
self.next_cookie += 1;
NextResult::Ok(DirEntryPlus {
fileid: entry.inode,
name: filename3::from(entry.name.into_bytes()),
cookie,
// Deliberately omitted. READDIRPLUS attributes are optional in
// NFSv3, and filling them in would mean reading every file in
// the directory just to list it, because the jj backend only
// reveals a file's size by handing over its content. Leaving
// them out costs a client one GETATTR per file it actually
// cares about and costs a plain `ls` nothing at all.
name_attributes: None,
name_handle: Some(FileHandleU64::new(entry.inode)),
})
let Some((entry, cookie)) = self.next_entry() else {
return NextResult::Eof;
};
// NFSv3 makes these optional per entry, so each case decides alone.
let name_attributes = match (entry.conflicted, entry.kind) {
// The one case the original trade still protects. A conflicted path
// has no content until its sides are materialized into marker text,
// so sizing it here would do exactly the work this used to warn
// about. Leave it to a LOOKUP.
(true, _) => None,
// Same reason: the tree reveals a link's target, and so its length,
// only by handing the target over.
(false, EntryKind::Symlink) => None,
// Both cheap. A directory reports a constant, and a file's length
// comes from the object header, or from a scratch-layer stat when
// the overlay owns it.
(false, EntryKind::Directory | EntryKind::File { .. }) => {
match self.fattr.tree.getattr(entry.inode).await {
Ok(attributes) => Some(self.fattr.fattr(&attributes)),
// Attributes are an optimization, so failing to size one
// entry must not fail the listing it appears in.
Err(_) => None,
}
}
}
};
NextResult::Ok(DirEntryPlus {
fileid: entry.inode,
name: filename3::from(entry.name.into_bytes()),
cookie,
name_attributes,
name_handle: Some(FileHandleU64::new(entry.inode)),
})
}
}

Expand Down Expand Up @@ -549,14 +622,15 @@ struct NamesOnly(DirIterator);

impl ReadDirIterator for NamesOnly {
async fn next(&mut self) -> NextResult<DirEntry> {
match self.0.next().await {
NextResult::Ok(entry) => NextResult::Ok(entry3 {
fileid: entry.fileid,
name: entry.name,
cookie: entry.cookie,
// Deliberately not the plus iterator's `next`: routing through it would
// size every entry only to drop the answer on the floor.
match self.0.next_entry() {
Some((entry, cookie)) => NextResult::Ok(entry3 {
fileid: entry.inode,
name: filename3::from(entry.name.into_bytes()),
cookie,
}),
NextResult::Eof => NextResult::Eof,
NextResult::Err(status) => NextResult::Err(status),
None => NextResult::Eof,
}
}
}
Expand Down
130 changes: 130 additions & 0 deletions vfs/tests/test_nfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,3 +684,133 @@ async fn test_nfs_create_write_read_and_refuse_removing_a_tracked_file() {
.collect();
assert_eq!(names, ["installed.txt", "src"]);
}

/// READDIRPLUS must inline attributes for entries it can size without reading
/// content, and omit them for the entries where sizing still means a read.
///
/// The distinction is the whole point of the optimization: a client that gets
/// attributes inline skips a LOOKUP per entry, which on a cold walk of a real
/// tree was 70% of every RPC made. Omitting them for the cases that would cost
/// a content read is what keeps listing a directory cheaper than reading it.
#[tokio::test]
async fn test_nfs_readdirplus_inlines_cheaply_sized_attributes() {
let test_repo = TestRepo::init();
let mut builder = TestTreeBuilder::new(test_repo.repo.store().clone());
builder.file(repo_path("hello.txt"), "hello nfs\n");
builder
.file(repo_path("run.sh"), "#!/bin/sh\ntrue\n")
.executable(true);
builder.symlink(repo_path("link"), "hello.txt");
builder.file(repo_path("sub/inner.txt"), "inner\n");
let tree = builder.write_merged_tree();

let mut connection = serve(snapshot_of(&tree)).await;
let root = connection.root_nfs_fh3();
let client = &mut connection.nfs3_client;

let listing = client
.readdirplus(&READDIRPLUS3args {
dir: root,
cookie: 0,
cookieverf: Default::default(),
dircount: 4096,
maxcount: 65536,
})
.await
.expect("readdirplus rpc")
.unwrap();

let mut seen: Vec<(String, Option<(ftype3, u64)>)> = listing
.reply
.entries
.0
.iter()
.map(|entry| {
let name = String::from_utf8(entry.name.0.as_ref().to_vec()).expect("utf-8 name");
let attributes = match &entry.name_attributes {
Nfs3Option::Some(attributes) => Some((attributes.type_, attributes.size)),
Nfs3Option::None => None,
};
(name, attributes)
})
.collect();
seen.sort_by(|a, b| a.0.cmp(&b.0));

assert_eq!(
seen,
vec![
// Sized from the git object header, so inlined.
("hello.txt".to_string(), Some((ftype3::NF3REG, 10))),
// A symlink's length is only knowable by fetching its target, so it
// keeps costing a LOOKUP rather than making every listing read.
("link".to_string(), None),
("run.sh".to_string(), Some((ftype3::NF3REG, 15))),
// A directory reports a constant, so it is free to inline.
("sub".to_string(), Some((ftype3::NF3DIR, 4096))),
],
"readdirplus must inline exactly the attributes it can compute without reading content"
);
}

/// A conflicted path keeps its attributes out of READDIRPLUS.
///
/// This is the one case the pre-`file_size` reasoning still protects: a
/// conflict has no content until its sides are materialized into marker text,
/// so sizing it inside a directory listing would do exactly the expensive work
/// that inlining attributes is supposed to avoid.
#[tokio::test]
async fn test_nfs_readdirplus_omits_attributes_for_a_conflict() {
let test_repo = TestRepo::init();
let mut builder = TestThreeWayMergeTreeBuilder::new(test_repo.repo.store().clone());
builder.base().file(repo_path("f"), "base\n");
builder.parent1().file(repo_path("f"), "left\n");
builder.parent2().file(repo_path("f"), "right\n");
let tree = builder.write_merged_tree();

let mut connection = serve(snapshot_of(&tree)).await;
let root = connection.root_nfs_fh3();
let client = &mut connection.nfs3_client;

let listing = client
.readdirplus(&READDIRPLUS3args {
dir: root.clone(),
cookie: 0,
cookieverf: Default::default(),
dircount: 4096,
maxcount: 65536,
})
.await
.expect("readdirplus rpc")
.unwrap();
let conflicted = listing
.reply
.entries
.0
.iter()
.find(|entry| entry.name.0.as_ref() == b"f")
.expect("the conflicted entry is listed");
assert!(
matches!(conflicted.name_attributes, Nfs3Option::None),
"a conflicted entry must not be sized inside a listing"
);

// And it must still resolve by the route that does pay to materialize it,
// so omitting the attributes costs a round trip and nothing else.
let looked_up = client
.lookup(&LOOKUP3args {
what: diropargs3 {
dir: root,
name: name("f"),
},
})
.await
.expect("lookup rpc")
.unwrap();
match &looked_up.obj_attributes {
Nfs3Option::Some(attributes) => {
assert_eq!(attributes.type_, ftype3::NF3REG);
assert!(attributes.size > 0, "a materialized conflict has content");
}
Nfs3Option::None => panic!("lookup must still size a conflicted path"),
}
}