Skip to content

Commit ff72fdb

Browse files
committed
fs: Add async filesystem import with parallel verity computation
Previously, we had a lot of synchronous code for interacting with the local filesystem, but the tar processing used by OCI was async. This created a need to do "the same thing" in two different ways. But importing from a local filesystem is equally amenable to being async! Replacing that with a model where we read the filesystem metadata synchronously, but defer fsverity computation and object import to worker threads results in *dramatic* speedup for large filesystem trees. Computing the composefs digest of the 40G `target/` directory I have locally is 1m20s before this patch, and 8s after (32 cores, so a lot more CPU time used of course). Three optimized paths depending on context: - Secure repo: std::io::copy (uses copy_file_range for reflinks on CoW filesystems) then kernel fsverity enable + measure - Insecure repo: tee through FsVerityHasher while copying to tmpfile, computing the digest in a single pass - No repo: incremental FsVerityHasher from fd, one block at a time Also: the composefs-http ensure_object call is migrated to ensure_object_async which it should have been using in the first place! This is a clear advantage of having one way to do it. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 722cb17 commit ff72fdb

4 files changed

Lines changed: 476 additions & 271 deletions

File tree

crates/cfsctl/src/lib.rs

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ use std::{ffi::OsString, path::PathBuf};
2929
#[cfg(feature = "oci")]
3030
use std::{fs::create_dir_all, io::IsTerminal};
3131

32-
#[cfg(any(feature = "oci", feature = "http"))]
3332
use std::sync::Arc;
3433

3534
use anyhow::{Context as _, Result};
@@ -573,11 +572,18 @@ pub async fn run_app(args: App) -> Result<()> {
573572
);
574573
}
575574

576-
if args.no_repo {
575+
// Commands that only need verity digests (no object storage) can
576+
// run without opening a repository.
577+
if args.no_repo
578+
|| matches!(
579+
args.cmd,
580+
Command::ComputeId { .. } | Command::CreateDumpfile { .. }
581+
)
582+
{
577583
let effective_hash = args.hash.unwrap_or(HashType::Sha512);
578584
return match effective_hash {
579-
HashType::Sha256 => run_cmd_without_repo::<Sha256HashValue>(args),
580-
HashType::Sha512 => run_cmd_without_repo::<Sha512HashValue>(args),
585+
HashType::Sha256 => run_cmd_without_repo::<Sha256HashValue>(args).await,
586+
HashType::Sha512 => run_cmd_without_repo::<Sha512HashValue>(args).await,
581587
};
582588
}
583589

@@ -714,17 +720,25 @@ fn load_filesystem_from_oci_image<ObjectID: FsVerityHashValue>(
714720
Ok(fs)
715721
}
716722

717-
fn load_filesystem_from_ondisk_fs<ObjectID: FsVerityHashValue>(
723+
async fn load_filesystem_from_ondisk_fs<ObjectID: FsVerityHashValue>(
718724
fs_opts: &FsReadOptions,
719-
repo: Option<&Repository<ObjectID>>,
725+
repo: Option<Arc<Repository<ObjectID>>>,
720726
) -> Result<FileSystem<RegularFile<ObjectID>>> {
727+
// The async API needs an OwnedFd; fs_opts.path is typically absolute
728+
// so the dirfd is unused for path resolution, but required by the API.
729+
let dirfd = rustix::fs::openat(
730+
CWD,
731+
".",
732+
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
733+
Mode::empty(),
734+
)?;
721735
let mut fs = if fs_opts.no_propagate_usr_to_root {
722-
composefs::fs::read_filesystem(CWD, &fs_opts.path, repo)?
736+
composefs::fs::read_filesystem(dirfd, fs_opts.path.clone(), repo.clone()).await?
723737
} else {
724-
composefs::fs::read_container_root(CWD, &fs_opts.path, repo)?
738+
composefs::fs::read_container_root(dirfd, fs_opts.path.clone(), repo.clone()).await?
725739
};
726740
if fs_opts.bootable {
727-
if let Some(repo) = repo {
741+
if let Some(repo) = &repo {
728742
fs.transform_for_boot(repo)?;
729743
} else {
730744
let rootfd = rustix::fs::openat(
@@ -797,15 +811,15 @@ fn dump_file_impl(
797811
}
798812

799813
/// Run commands that don't require a repository.
800-
pub fn run_cmd_without_repo<ObjectID: FsVerityHashValue>(args: App) -> Result<()> {
814+
pub async fn run_cmd_without_repo<ObjectID: FsVerityHashValue>(args: App) -> Result<()> {
801815
match args.cmd {
802816
Command::ComputeId { fs_opts } => {
803-
let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None)?;
817+
let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
804818
let id = fs.compute_image_id();
805819
println!("{}", id.to_hex());
806820
}
807821
Command::CreateDumpfile { fs_opts } => {
808-
let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None)?;
822+
let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None).await?;
809823
fs.print_dumpfile()?;
810824
}
811825
_ => {
@@ -820,6 +834,7 @@ pub async fn run_cmd_with_repo<ObjectID>(repo: Repository<ObjectID>, args: App)
820834
where
821835
ObjectID: FsVerityHashValue,
822836
{
837+
let repo = Arc::new(repo);
823838
match args.cmd {
824839
Command::Init { .. } => {
825840
// Handled in run_app before we get here
@@ -841,7 +856,6 @@ where
841856
#[cfg(feature = "oci")]
842857
Command::Oci { cmd: oci_cmd } => match oci_cmd {
843858
OciCommand::ImportLayer { name, ref digest } => {
844-
let repo = Arc::new(repo);
845859
let (object_id, _stats) = composefs_oci::import_layer(
846860
&repo,
847861
digest,
@@ -899,9 +913,8 @@ where
899913
} => {
900914
// If no explicit name provided, use the image reference as the tag
901915
let tag_name = name.as_deref().unwrap_or(image);
902-
let repo_arc = Arc::new(repo);
903916
let (result, stats) =
904-
composefs_oci::pull_image(&repo_arc, image, Some(tag_name), None).await?;
917+
composefs_oci::pull_image(&repo, image, Some(tag_name), None).await?;
905918

906919
println!("manifest {}", result.manifest_digest);
907920
println!("config {}", result.config_digest);
@@ -917,7 +930,7 @@ where
917930

918931
if bootable {
919932
let image_verity =
920-
composefs_oci::generate_boot_image(&repo_arc, &result.manifest_digest)?;
933+
composefs_oci::generate_boot_image(&repo, &result.manifest_digest)?;
921934
println!("Boot image: {}", image_verity.to_hex());
922935
}
923936
}
@@ -1088,18 +1101,13 @@ where
10881101
fs_opts,
10891102
ref image_name,
10901103
} => {
1091-
let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(&repo))?;
1104+
let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?;
10921105
let id = fs.commit_image(&repo, image_name.as_deref())?;
10931106
println!("{}", id.to_id());
10941107
}
1095-
Command::ComputeId { fs_opts } => {
1096-
let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(&repo))?;
1097-
let id = fs.compute_image_id();
1098-
println!("{}", id.to_hex());
1099-
}
1100-
Command::CreateDumpfile { fs_opts } => {
1101-
let fs = load_filesystem_from_ondisk_fs::<ObjectID>(&fs_opts, None)?;
1102-
fs.print_dumpfile()?;
1108+
Command::ComputeId { .. } | Command::CreateDumpfile { .. } => {
1109+
// Handled in run_app before opening the repo
1110+
unreachable!("compute-id and create-dumpfile are dispatched without a repo");
11031111
}
11041112
Command::Mount { name, mountpoint } => {
11051113
repo.mount_at(&name, &mountpoint)?;
@@ -1165,7 +1173,7 @@ where
11651173
}
11661174
#[cfg(feature = "http")]
11671175
Command::Fetch { url, name } => {
1168-
let (digest, verity) = composefs_http::download(&url, &name, Arc::new(repo)).await?;
1176+
let (digest, verity) = composefs_http::download(&url, &name, Arc::clone(&repo)).await?;
11691177
println!("content {digest}");
11701178
println!("verity {}", verity.to_hex());
11711179
}

crates/composefs-http/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ impl<ObjectID: FsVerityHashValue> Downloader<ObjectID> {
8484
let my_id = if is_symlink {
8585
ObjectID::from_object_pathname(&data)?
8686
} else {
87-
self.repo.ensure_object(&data)?
87+
self.repo.ensure_object_async(data.into()).await?
8888
};
8989
progress.inc(1);
9090

0 commit comments

Comments
 (0)