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
43 changes: 28 additions & 15 deletions crates/sandfs-core/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@ type CacheKey = (u64, SourceKey);
struct Cache {
dirs: Mutex<HashMap<CacheKey, Arc<Slot<Arc<Listing>>>>>,
blobs: Mutex<HashMap<CacheKey, Arc<Slot<Bytes>>>>,
/// Entries already found, by mount id and path below the mount, so resolving a
/// path costs one lookup instead of one listing lookup per component. Only
/// Directories already found, by mount id and path below the mount, so resolving a
/// path costs a lookup or two instead of one listing lookup per component. Only
/// entries that exist are kept, so probing for missing names costs no memory.
entries: Mutex<HashMap<u64, HashMap<Box<str>, SourceEntry>>>,
/// A path that is its entry's key, as in a folder, shares the key's bytes.
entries: Mutex<HashMap<u64, HashMap<SourceKey, SourceEntry>>>,
/// Directories already prefetched, and whether recursively, so each is asked once.
prefetched: Mutex<HashSet<(CacheKey, bool)>>,
/// Base files each directory needed fetched in the current look: sizes missing
Expand Down Expand Up @@ -93,7 +94,8 @@ impl Cache {
}

fn remember_entry(&self, mount: u64, rel: &str, entry: SourceEntry) {
self.entries.lock().expect("cache lock").entry(mount).or_default().insert(rel.into(), entry);
let rel = if *entry.key == *rel { entry.key.clone() } else { rel.into() };
self.entries.lock().expect("cache lock").entry(mount).or_default().insert(rel, entry);
}

/// Replace a cached listing with a newer answer from the same source.
Expand All @@ -109,19 +111,17 @@ impl Cache {
/// Put `entry` in the cached listing `key` under `name`, in place of the entry with
/// the same source key, and say whether that changed it. The listing's own lock is
/// held throughout, so two files of one directory changed at once both stay changed.
/// `then` runs under that lock too, so what it caches from `entry` changes in the
/// same order as the listing.
fn patch_listing(&self, key: &CacheKey, name: &str, entry: SourceEntry, then: impl FnOnce(SourceEntry)) -> bool {
/// Files aren't in the path-entry cache, so the listing is the one place to change.
fn patch_listing(&self, key: &CacheKey, name: &str, entry: SourceEntry) -> bool {
let Some(slot) = self.dirs.lock().expect("cache lock").get(key).cloned() else { return false };
let mut listing = slot.lock().expect("cache slot lock");
let Some(current) = listing.as_ref() else { return false };
if current.get(name).is_none_or(|listed| listed.key != entry.key || *listed == entry) {
return false;
}
let mut fresh = (**current).clone();
fresh.insert(name.to_owned(), entry.clone());
fresh.insert(name.to_owned(), entry);
*listing = Some(Arc::new(fresh));
then(entry);
true
}

Expand Down Expand Up @@ -472,10 +472,7 @@ impl Workspace {
if let (Some(parent), Some(name)) = (path.parent(), path.name())
&& let Some(dir) = self.base_dir(&parent, &mount)?
&& self.cached_listing(&mount, &dir)?.get(name).is_some_and(|entry| entry.key == key)
&& let Some((mount_path, _)) = self.mounts.iter().find(|(_, owner)| owner.id == mount.id)
&& self.cache.patch_listing(&(mount.id, dir), name, now, |now| {
self.cache.remember_entry(mount.id, &path.as_str()[mount_path.as_str().len() + 1..], now);
})
&& self.cache.patch_listing(&(mount.id, dir), name, now)
{
changed = true;
}
Expand Down Expand Up @@ -1038,13 +1035,29 @@ impl Workspace {
if let Some(entry) = self.cache.entry(mount.id, rel) {
return Ok(Some(entry));
}
let mut parts = rel.split('/').peekable();
// Only directories are remembered, as files far outnumber them: a file is found
// in its directory's listing, one lookup more.
let mut key = mount.root.clone();
let mut done = 0;
if let Some((dir, _)) = rel.rsplit_once('/')
&& let Some(found) = self.cache.entry(mount.id, dir)
{
if !found.kind.is_dir() {
return Ok(None);
}
key = found.key;
done = dir.len() + 1;
}
let mut parts = rel[done..].split('/').peekable();
while let Some(part) = parts.next() {
let listing = self.cached_listing(mount, &key)?;
let Some(entry) = listing.get(part) else { return Ok(None) };
done += part.len();
if entry.kind.is_dir() {
self.cache.remember_entry(mount.id, &rel[..done], entry.clone());
}
done += 1;
if parts.peek().is_none() {
self.cache.remember_entry(mount.id, rel, entry.clone());
return Ok(Some(entry.clone()));
}
if !entry.kind.is_dir() {
Expand Down
14 changes: 10 additions & 4 deletions crates/sandfs/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const MAX_MEMORY_FILE: u64 = 4 << 30;
/// can't read a range on its own. The kernel asks for a file in chunks and caches
/// what it gets, so only the few files being read right now are worth keeping.
const RECENT_FILES: usize = 16;
const RECENT_BYTES: usize = 256 << 20;
const RECENT_BYTES: usize = 64 << 20;
/// How long a file goes without a write before its buffer is committed and saved.
/// Nothing tells this filesystem when a file is closed, so a quiet spell stands in.
pub const SETTLE: Duration = Duration::from_millis(250);
Expand Down Expand Up @@ -175,7 +175,8 @@ struct Node {
sized: Cell<bool>,
/// For a removed file, the workspace as it was before: the kernel may still have
/// it open, and an open file stays readable until the kernel forgets the inode.
removed_from: Option<Workspace>,
/// Boxed, as few nodes have one and every node pays for its room.
removed_from: Option<Box<Workspace>>,
}

/// An open directory, listed once so `readdir` pages through a stable list.
Expand Down Expand Up @@ -865,6 +866,11 @@ impl Inner {
}
}
self.buffers.remove(&ino);
// A table keeps its room once grown: after a walk of a big folder the
// kernel forgets nearly all of it, and the room goes too.
if self.nodes.capacity() > 4096 && self.nodes.len() < self.nodes.capacity() / 4 {
self.nodes.shrink_to(self.nodes.len() * 2);
}
}
}

Expand All @@ -886,7 +892,7 @@ impl Inner {
}
if let Some(node) = self.nodes.get_mut(&ino) {
node.linked = false;
node.removed_from = before.filter(|_| self.keep_removed);
node.removed_from = before.filter(|_| self.keep_removed).map(Box::new);
// Without removed files kept readable (NFS), nothing ever says a client is
// done with an inode, so a removed one goes now; a client still holding its
// handle gets an error.
Expand Down Expand Up @@ -1232,7 +1238,7 @@ impl Inner {
fn contents(&self, ino: u64) -> FsResult<(&Workspace, &VPath)> {
match self.nodes.get(&ino) {
Some(Node { linked: true, path, .. }) => Ok((&self.ws, path)),
Some(Node { removed_from: Some(before), path, .. }) => Ok((before, path)),
Some(Node { removed_from: Some(before), path, .. }) => Ok((&**before, path)),
_ => Err(Errno::ENOENT),
}
}
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- **sandfs and sandfs-core, less memory per file**: a mount keeps less for each file it is asked about. After a walk of 200,000 files (`find` and `ls -lR`), the process serving the mount held 89 MB where it held 147 MB, and walks take as long as before. Three changes do it. The record of each file the kernel knows is smaller. Files are found through their folder's listing, and only folders are kept in the table that resolves paths in one lookup. The table of files the kernel knows gives its room back once the kernel forgets most of them, which took a further 14 MB off after the kernel forgot the walk. A git mount also keeps at most 64 MiB of whole files read lately, down from 256 MiB.
- **sandfs and sandfs-cli, linked worktrees in a folder**: a git worktree's `.git` is a file naming its repository by full path, so git in a mount or fork of a folder holding one wrote that repository on the disk: a commit landed in the real repository while the files it was made from stayed in the workspace, and the real worktree then looked changed. A mount and a fork now show such a file with the path the repository has there: relative, when the repository is in the folder too, so the commit stays in the workspace; otherwise a path that isn't there, so git stops instead of writing outside it, and the file says "this worktree's repository is outside this folder, at PATH". `sandfs changes` now says which folders hold a repository with commits made in the workspace, which copies leave out with the rest of `.git`; in the library that is the new `Changes::committed`.
- **sandfs and sandfs-core, a file that shrank in the folder under a mount**: a mount of a folder lists each file once and reads its bytes from the folder as they are. When a file got shorter in the folder after that (a copy onto it truncates it first), a read came back short of the listed size, and macOS's NFS client filled the rest with zeros: copying files from a mount onto the folder under it wrote zeros into them. Now a read that comes back short makes the mount ask the folder again for that file's size and time alone. On Linux the read is the file's end and a later `stat` gives the new size. On macOS, whose client won't take a smaller size from a read, the read fails ("Stale NFS file handle") and the file gets a new inode, which the client looks up within a second or so and then reads as the file is now. A read never gives zeros. In `sandfs-core` this is `Workspace::recheck` and a new `Source::entry_now` with a default that changes nothing, which `DirSource` implements. Errors from a folder now name the path they happened at ("source error: PATH: Permission denied").
- **sandfs and sandfs-cli, real folders on Linux**: `sandfs fork` of a git clone (or of a fork kept as a folder) into a folder on a disk that clones files, btrfs or XFS, now keeps the fork there as a real folder of clones of the clone's files, as on macOS, instead of mounting it. It takes no space for the files, about 30 ms to make, and builds and git run in it at the disk's own speed, where a mount ran a build after an edit 25-40% slower and created small files 2-5 times slower. sandfs finds out by cloning one file next to the folder and removing it. On ext4, or with `--read-only` or `--mount`, the fork is mounted as before.
Expand Down
Loading