diff --git a/Cargo.lock b/Cargo.lock index a5c76c78593..c1ea73d29e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,6 +420,7 @@ dependencies = [ "pathdiff", "portable-atomic", "rand 0.10.1", + "reflink-copy", "regex", "rusqlite", "rustc-hash 2.1.2", @@ -3825,6 +3826,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "reflink-copy" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + [[package]] name = "regex" version = "1.12.4" @@ -5194,12 +5207,107 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5234,6 +5342,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index df3c457138d..6a11055fc6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ percent-encoding = "2.3.2" proptest = "1.11.0" pulldown-cmark = { version = "0.13.3", default-features = false, features = ["html"] } rand = "0.10.1" +reflink-copy = "0.1.30" regex = "1.12.3" rusqlite = { version = "0.40.0", features = ["bundled"] } rustc-hash = "2.1.2" @@ -202,6 +203,7 @@ os_info.workspace = true pathdiff.workspace = true portable-atomic.workspace = true rand.workspace = true +reflink-copy.workspace = true regex.workspace = true rusqlite = { workspace = true, features = ["fallible_uint"] } rustc-hash.workspace = true diff --git a/crates/cargo-util/src/paths.rs b/crates/cargo-util/src/paths.rs index a8274209802..b7fd88e7dc2 100644 --- a/crates/cargo-util/src/paths.rs +++ b/crates/cargo-util/src/paths.rs @@ -163,6 +163,49 @@ pub fn symlink_metadata>(path: P) -> Result { .with_context(|| format!("failed to load metadata for path `{}`", path.display())) } +/// Returns the number of hard links to a file. +#[cfg(unix)] +pub fn nlink(metadata: &Metadata, _path: &Path) -> io::Result { + use std::os::unix::fs::MetadataExt; + Ok(metadata.nlink()) +} + +/// Returns the number of hard links to a file. +#[cfg(windows)] +pub fn nlink(_metadata: &Metadata, path: &Path) -> io::Result { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle, + OPEN_EXISTING, + }; + + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + wide.push(0); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(handle.as_raw_handle(), &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(info.nNumberOfLinks as u64) +} + /// Reads a file to a string. /// /// Equivalent to [`std::fs::read_to_string`] with better error messages. @@ -887,6 +930,7 @@ fn exclude_from_time_machine_and_cloud_sync(path: &Path) { #[cfg(test)] mod tests { use super::join_paths; + use super::nlink; use super::normalize_path; use super::write; use super::write_atomic; @@ -1103,4 +1147,20 @@ mod tests { assert!(!symlink_path.exists()); assert!(file_path.exists()); } + + #[test] + fn hard_link_count_tracks_links() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("file"); + let linked = dir.path().join("linked"); + std::fs::write(&file, "contents").unwrap(); + assert_eq!(nlink(&file.metadata().unwrap(), &file).unwrap(), 1); + + std::fs::hard_link(&file, &linked).unwrap(); + assert_eq!(nlink(&file.metadata().unwrap(), &file).unwrap(), 2); + assert_eq!(nlink(&linked.metadata().unwrap(), &linked).unwrap(), 2); + + std::fs::remove_file(&linked).unwrap(); + assert_eq!(nlink(&file.metadata().unwrap(), &file).unwrap(), 1); + } } diff --git a/doc/book/src/reference/unstable.md b/doc/book/src/reference/unstable.md index cb99de76598..d8a424d6c2b 100644 --- a/doc/book/src/reference/unstable.md +++ b/doc/book/src/reference/unstable.md @@ -101,6 +101,7 @@ Each new feature described below should explain how to use it. * [json-target-spec](#json-target-spec) --- Allows the use of `.json` custom target specs. * [hint-msrv](#hint-msrv) --- Allows Cargo to set `-Zhint-msrv` * [mem-stats](#mem-stats) --- Reports peak memory usage per build unit in `--timings`. + * [shared-blob-storage](#shared-blob-storage) --- Deduplicates build artifacts between workspaces * rustdoc * [rustdoc-map](#rustdoc-map) --- Provides mappings for documentation to link to external sites like [docs.rs](https://docs.rs/). * [scrape-examples](#scrape-examples) --- Shows examples within documentation. @@ -2077,6 +2078,21 @@ option: hint-msrv = true ``` +## shared-blob-storage +* Tracking Issue: [rust-lang/cargo#17453](https://github.com/rust-lang/cargo/issues/17453) + +Enables Cargo to automatically deduplicate files across build units and workspaces by hardlink/reflinking +against a shared content addressable blob storage located in `~/.cargo/blobs`. + +You can set this via your global `~/.cargo/config.toml`, and nightly Cargo will +automatically use it, while stable Cargo will silently ignore the unstable +option: + +```toml +[unstable] +shared-blob-storage = true +``` + ## builtin-dependencies * Tracking Issue: [rust-lang/cargo#16960](https://github.com/rust-lang/cargo/issues/16960) - RFC: [rust-lang/rfcs#3875](https://rust-lang.github.io/rfcs/3875-build-std-explicit-dependencies.html) diff --git a/src/bin/cargo/commands/clean.rs b/src/bin/cargo/commands/clean.rs index a7ed8195c14..46a33d68f6c 100644 --- a/src/bin/cargo/commands/clean.rs +++ b/src/bin/cargo/commands/clean.rs @@ -130,6 +130,15 @@ pub fn cli() -> Command { ) .value_name("SIZE") .value_parser(parse_human_size), + ) + .arg( + opt( + "max-blob-size", + "Deletes shared blobs until the cache is under the \ + given size (unstable)", + ) + .value_name("SIZE") + .value_parser(parse_human_size), ), ) .after_help(color_print::cstr!( @@ -199,6 +208,7 @@ fn gc(gctx: &GlobalContext, args: &ArgMatches) -> CliResult { max_crate_size: size_opt("max-crate-size"), max_git_size: size_opt("max-git-size"), max_download_size: size_opt("max-download-size"), + max_blob_size: size_opt("max-blob-size"), }; if let Some(age) = duration_opt("max-download-age") { gc_opts.set_max_download_age(age); diff --git a/src/compiler/blob_storage.rs b/src/compiler/blob_storage.rs new file mode 100644 index 00000000000..bbca3c1d76b --- /dev/null +++ b/src/compiler/blob_storage.rs @@ -0,0 +1,249 @@ +use std::fs::File; +use std::io::{ErrorKind, Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::bail; +use cargo_util::paths::create_dir_all; +use filetime::FileTime; +use tracing::{debug, instrument}; + +use crate::CargoResult; +use crate::util::flock::{lock_exclusive, lock_shared}; + +const USAGE_UPDATE_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60); + +pub struct BlobStorage { + root: PathBuf, +} + +impl BlobStorage { + pub fn new(root: PathBuf, build_dir: &Path) -> CargoResult { + create_dir_all(&root)?; + + if !is_same_filesystem(&root, build_dir).unwrap_or(false) { + bail!("blob storage and build-dir are on different file systems") + } + + Ok(Self { root }) + } + + /// Inserts or deduplicates a file already in blob storage. + /// + /// This function will prefer reflink if available on the current filesystem but fallback to + /// hardlinking. + pub fn insert_or_dedup( + &self, + artifact_path: &Path, + timestamp_path: &Path, + ) -> CargoResult { + let hash = Self::hash(artifact_path)?; + let storage_path = self.root.join(&hash); + + if !self.insert(artifact_path, timestamp_path, &storage_path)? { + self.dedup(artifact_path, timestamp_path, &storage_path)?; + } + + Ok(hash) + } + + #[instrument(skip_all)] + pub fn mark_timestamps_used(timestamps_dir: &Path, now: SystemTime) -> CargoResult<()> { + let now_secs = unix_timestamp(now); + for entry in std::fs::read_dir(timestamps_dir)? { + let Ok(entry) = entry else { + continue; + }; + let update = || -> CargoResult<()> { + let file_type = entry.file_type()?; + if file_type.is_dir() { + return Self::mark_timestamps_used(&entry.path(), now); + } + if !file_type.is_file() { + return Ok(()); + } + let path = entry.path(); + if is_timestamp_stale(&path, now_secs) { + write_used_timestamp(&path, now_secs)?; + } + Ok(()) + }; + if let Err(err) = update() { + debug!(path = ?entry.path(), ?err, "failed to update blob usage"); + } + } + Ok(()) + } + + fn insert( + &self, + artifact_path: &Path, + timestamp_path: &Path, + storage_path: &Path, + ) -> CargoResult { + if storage_path.try_exists()? { + return Ok(false); + } + + // This logic is a bit subtle. + // Reflinking is not atomic so we create a temp dir to create that file falling back to + // hardlinking. Then regardless of whether we reflinked or hardlinked, we hard link that + // file into the blob storage so the insert is always atomic. + // + // Importantly, we do not use `std::fs::rename` to move the file in to the blob storage as + // that would overwrite the existing file if there was another process inserted before us. + let staging_dir = tempfile::Builder::new() + .prefix(".blob") + .tempdir_in(&self.root)?; + let staged = staging_dir.path().join("artifact"); + if reflink_copy::reflink(artifact_path, &staged).is_err() { + std::fs::hard_link(artifact_path, &staged)?; + } + #[cfg(target_os = "linux")] + ensure_no_writers(&staged)?; + + match std::fs::hard_link(&staged, storage_path) { + Ok(()) => { + Self::create_timestamp_file(timestamp_path, storage_path)?; + + Ok(true) + } + Err(err) if err.kind() == ErrorKind::AlreadyExists => Ok(false), + Err(err) => Err(err.into()), + } + } + + fn dedup(&self, path: &Path, timestamp_path: &Path, storage_path: &Path) -> CargoResult<()> { + let metadata = path.metadata()?; + let staging_dir = tempfile::Builder::new() + .prefix(".blob") + .tempdir_in(path.parent().unwrap())?; + let replacement = staging_dir.path().join("artifact"); + if reflink_copy::reflink(storage_path, &replacement).is_ok() { + // Reflink creates a different inode so things like mtimes are not preserved + // automatically, which causes issues with rebuild detection. + std::fs::set_permissions(&replacement, metadata.permissions())?; + filetime::set_file_times( + &replacement, + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + )?; + } else { + std::fs::hard_link(storage_path, &replacement)?; + } + #[cfg(target_os = "linux")] + ensure_no_writers(&replacement)?; + std::fs::rename(&replacement, path)?; + + Self::create_timestamp_file(timestamp_path, storage_path)?; + + Ok(()) + } + + fn create_timestamp_file(timestamp_path: &Path, storage_path: &Path) -> CargoResult<()> { + let timestamp = storage_path.with_added_extension("timestamp"); + write_used_timestamp(×tamp, now())?; + create_dir_all(timestamp_path.parent().unwrap())?; + std::fs::hard_link(×tamp, ×tamp_path)?; + Ok(()) + } + + #[instrument] + fn hash(path: &Path) -> CargoResult { + let mut hasher = blake3::Hasher::new(); + let file = File::open(path)?; + hasher.update_reader(file)?; + Ok(hasher.finalize().to_hex().to_string()) + } +} + +fn is_timestamp_stale(path: &Path, now_secs: u64) -> bool { + match read_timestamp_file(path) { + Some(stored) => now_secs.saturating_sub(stored) >= USAGE_UPDATE_INTERVAL.as_secs(), + None => true, + } +} + +pub fn read_timestamp_file(path: &Path) -> Option { + let mut contents = String::new(); + let file = File::open(path).ok()?; + lock_shared(&file).ok()?; + file.take(32).read_to_string(&mut contents).ok()?; + contents.trim().parse().ok() +} + +/// Writes unix seconds to a timestamp file, creating it if needed. +/// This truncates and rewrites so the hardlink is preserved. +fn write_used_timestamp(path: &Path, now_secs: u64) -> std::io::Result<()> { + let mut file = File::create(path)?; + lock_exclusive(&file)?; + write!(file, "{now_secs}")?; + Ok(()) +} + +fn now() -> u64 { + unix_timestamp(SystemTime::now()) +} + +fn unix_timestamp(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default() +} + +/// Errors if there we cannot get a read lease to a file (ensuring no writers) +/// +/// On Linux we need this to avoid concurrency issues when deduplicating with reflinks. +/// During reflinking, there is a brief window where we hold a write lease to the file. +/// If during this period, another worker fork's (say to spawn a rustc process) that process +/// will inhierit the writable fd. This is problematic as we cannot execute a file that has a +/// writable fd which breaks things like executing build scripts. +#[cfg(target_os = "linux")] +fn ensure_no_writers(path: &Path) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let file = File::open(path)?; + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETLEASE, libc::F_RDLCK) } == -1 { + return Err(std::io::Error::last_os_error()); + } + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETLEASE, libc::F_UNLCK) } == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +fn is_same_filesystem(dir1: &Path, dir2: &Path) -> std::io::Result { + cfg_select! { + unix => { + use std::os::unix::fs::MetadataExt; + let meta1 = std::fs::metadata(dir1)?; + let meta2 = std::fs::metadata(dir2)?; + Ok(meta1.dev() == meta2.dev()) + } + windows => { + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, + GetFileInformationByHandle, + }; + + // FIXME: Ideally we use std if/when https://github.com/rust-lang/rust/issues/63010 is + // stabilized + fn volume_serial_number(path: &Path) -> std::io::Result { + let file = OpenOptions::new() + .access_mode(0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path)?; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(info.dwVolumeSerialNumber) + } + + Ok(volume_serial_number(dir1)? == volume_serial_number(dir2)?) + } + } +} diff --git a/src/compiler/build_runner/compilation_files.rs b/src/compiler/build_runner/compilation_files.rs index ee2b6601d3b..5d8427cff08 100644 --- a/src/compiler/build_runner/compilation_files.rs +++ b/src/compiler/build_runner/compilation_files.rs @@ -1,5 +1,6 @@ //! See [`CompilationFiles`]. +use crate::compiler::blob_storage::BlobStorage; use crate::util::data_structures::HashMap; use std::cell::OnceCell; use std::fmt; @@ -7,7 +8,7 @@ use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use tracing::debug; +use tracing::{debug, warn}; use super::{BuildContext, BuildRunner, CompileKind, FileFlavor, Layout}; use crate::compiler::trim_paths; @@ -136,6 +137,8 @@ pub struct CompilationFiles<'a, 'gctx> { metas: HashMap, /// For each Unit, a list all files produced. outputs: HashMap>>>, + /// The shared blob storage + blob_storage: Option>, } /// Info about a single file emitted by the compiler. @@ -167,7 +170,7 @@ impl<'a, 'gctx: 'a> CompilationFiles<'a, 'gctx> { build_runner: &BuildRunner<'a, 'gctx>, host: Layout, target: HashMap, - ) -> CompilationFiles<'a, 'gctx> { + ) -> CargoResult> { let mut metas = HashMap::default(); for unit in &build_runner.bcx.roots { metadata_of(unit, build_runner, &mut metas); @@ -177,7 +180,17 @@ impl<'a, 'gctx: 'a> CompilationFiles<'a, 'gctx> { .cloned() .map(|unit| (unit, OnceCell::new())) .collect(); - CompilationFiles { + let blob_storage = build_runner + .bcx + .gctx + .blob_storage_dir() + .and_then(|shared| { + BlobStorage::new(shared, build_runner.bcx.ws.build_dir().as_path_unlocked()) + .inspect_err(|err| warn!(?err, "Could not initialize blob storage, disabling")) + .ok() + }) + .map(Arc::new); + Ok(CompilationFiles { ws: build_runner.bcx.ws, host, target, @@ -185,7 +198,8 @@ impl<'a, 'gctx: 'a> CompilationFiles<'a, 'gctx> { roots: build_runner.bcx.roots.clone(), metas, outputs, - } + blob_storage, + }) } /// Returns the appropriate directory layout for either a plugin or not. @@ -303,6 +317,17 @@ impl<'a, 'gctx: 'a> CompilationFiles<'a, 'gctx> { .out_force_new_layout(&dir) } + /// Root directory for the unit in the new build layout. + pub fn build_unit_dir(&self, unit: &Unit) -> PathBuf { + let dir = self.pkg_dir(unit); + self.layout(unit.kind).build_dir().build_unit(&dir) + } + + /// Directory where the timestamps for the given unit should go. + pub fn timestamps_dir(&self, unit: &Unit) -> PathBuf { + self.build_unit_dir(unit).join(".timestamps") + } + /// Directory where the fingerprint for the given unit should go. pub fn fingerprint_dir(&self, unit: &Unit) -> PathBuf { let dir = self.pkg_dir(unit); @@ -647,6 +672,10 @@ impl<'a, 'gctx: 'a> CompilationFiles<'a, 'gctx> { Ok(Arc::new(ret)) } + pub fn blob_storage(&self) -> Option> { + self.blob_storage.clone() + } + /// Append the SBOM suffix to the file name. fn append_sbom_suffix(link: &PathBuf) -> PathBuf { const SBOM_FILE_EXTENSION: &str = ".cargo-sbom.json"; diff --git a/src/compiler/build_runner/mod.rs b/src/compiler/build_runner/mod.rs index 4bf69f6585e..c370cf4057e 100644 --- a/src/compiler/build_runner/mod.rs +++ b/src/compiler/build_runner/mod.rs @@ -455,7 +455,7 @@ impl<'a, 'gctx> BuildRunner<'a, 'gctx> { self.record_units_requiring_metadata(); - let files = CompilationFiles::new(self, host_layout, targets); + let files = CompilationFiles::new(self, host_layout, targets)?; self.files = Some(files); Ok(()) } diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index d13f9227839..8ca4ebc1447 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -29,6 +29,7 @@ //! [`ops::cargo_compile::compile`]: crate::ops::compile pub mod artifact; +pub mod blob_storage; mod build_config; pub(crate) mod build_context; pub(crate) mod build_runner; @@ -54,6 +55,7 @@ pub mod unit_dependencies; pub mod unit_graph; pub mod unused_deps; +use crate::compiler::blob_storage::BlobStorage; use crate::util::data_structures::{HashMap, HashSet}; use std::borrow::Cow; use std::cell::OnceCell; @@ -258,6 +260,21 @@ fn compile<'gctx>( work.then(link_targets(build_runner, unit, true)?) }); + if should_dedup_out_dir(build_runner, unit) + && let Some(blob_storage) = build_runner.files().blob_storage() + { + let timestamps_dir = build_runner.files().timestamps_dir(unit); + if job.freshness().is_dirty() { + let unit_dir = build_runner.files().build_unit_dir(unit); + let out_dir = build_runner.files().out_dir_new_layout(unit); + job.after(Work::new(move |_state| { + deduplicate_out_dir(&unit_dir, &out_dir, ×tamps_dir, &blob_storage) + })); + } else { + mark_build_unit_used(×tamps_dir); + } + } + // If -Zfine-grain-locking is enabled, we wrap the job with an upgrade to exclusive // lock before starting, then downgrade to a shared lock after the job is finished. if build_runner.bcx.gctx.cli_unstable().fine_grain_locking && job.freshness().is_dirty() @@ -679,6 +696,56 @@ fn downgrade_lock_to_shared(lock: LockKey) -> Work { }) } +fn should_dedup_out_dir(build_runner: &BuildRunner<'_, '_>, unit: &Unit) -> bool { + build_runner.bcx.gctx.cli_unstable().build_dir_new_layout + && build_runner.bcx.gctx.cli_unstable().shared_blob_storage + && !unit.is_local() +} + +#[tracing::instrument(skip_all)] +fn deduplicate_out_dir( + unit_dir: &Path, + out_dir: &Path, + timestamps_dir: &Path, + blob_storage: &BlobStorage, +) -> CargoResult<()> { + if let Err(err) = fs::remove_dir_all(timestamps_dir) + && err.kind() != std::io::ErrorKind::NotFound + { + debug!(?timestamps_dir, ?err, "failed to clear blob timestamps"); + return Ok(()); + } + for entry in walkdir::WalkDir::new(out_dir) { + let Ok(entry) = entry else { + continue; + }; + if entry.file_type().is_file() { + let relative_path = entry.path().strip_prefix(unit_dir)?; + let timestamp_path = timestamps_dir + .join(relative_path) + .with_added_extension("timestamp"); + match blob_storage.insert_or_dedup(entry.path(), ×tamp_path) { + Ok(hash) => { + trace!(path = ?entry.path(), hash, "added blob timestamp tracking file into blob storage"); + } + Err(err) => { + debug!(path = ?entry.path(), ?err, "failed to add blob timestamp tracking file into blob storage"); + } + } + } + } + + Ok(()) +} + +#[tracing::instrument(skip_all)] +fn mark_build_unit_used(timestamps_dir: &Path) { + let now = std::time::SystemTime::now(); + if let Err(err) = BlobStorage::mark_timestamps_used(timestamps_dir, now) { + debug!(?timestamps_dir, ?err, "failed to update blob usage"); + } +} + /// Link the compiled target (often of form `foo-{metadata_hash}`) to the /// final target. This must happen during both "Fresh" and "Compile". fn link_targets( diff --git a/src/context/mod.rs b/src/context/mod.rs index 851262100ca..e653fec8d7c 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -823,6 +823,14 @@ impl GlobalContext { Ok(Filesystem::new(path)) } + pub fn blob_storage_dir(&self) -> Option { + if !self.cli_unstable().shared_blob_storage || !self.cli_unstable().build_dir_new_layout { + return None; + } + + return Some(self.home_path.as_path_unlocked().join("blobs")); + } + /// Get a configuration value by key. /// /// This does NOT look at environment variables. See `get_cv_with_env` for diff --git a/src/workspace/features.rs b/src/workspace/features.rs index bcdc38de967..2338abff7b5 100644 --- a/src/workspace/features.rs +++ b/src/workspace/features.rs @@ -930,6 +930,7 @@ unstable_cli_options!( script: bool = ("Enable support for single-file, `.rs` packages"), section_timings: bool = ("Enable support for extended compilation sections in --timings output"), separate_nightlies: bool, + shared_blob_storage: bool = ("Enable shared blob storage"), skip_rustdoc_fingerprint: bool, target_applies_to_host: bool = ("Enable the `target-applies-to-host` key in the .cargo/config.toml file"), trim_paths: bool = ("Enable the `trim-paths` option in profiles"), @@ -1469,6 +1470,7 @@ impl CliUnstable { "sbom" => self.sbom = parse_empty(k, v)?, "section-timings" => self.section_timings = parse_empty(k, v)?, "separate-nightlies" => self.separate_nightlies = parse_empty(k, v)?, + "shared-blob-storage" => self.shared_blob_storage = parse_empty(k, v)?, "checksum-freshness" => self.checksum_freshness = parse_empty(k, v)?, "skip-rustdoc-fingerprint" => self.skip_rustdoc_fingerprint = parse_empty(k, v)?, "script" => self.script = parse_empty(k, v)?, diff --git a/src/workspace/gc.rs b/src/workspace/gc.rs index 108c05810e8..92771c8d23e 100644 --- a/src/workspace/gc.rs +++ b/src/workspace/gc.rs @@ -19,6 +19,7 @@ //! module documentation for an in-depth explanation of how global cache //! tracking works. +use crate::compiler::blob_storage::read_timestamp_file; use crate::context::{CargoCacheConfig, GlobalCleanConfig}; use crate::ops::CleanContext; use crate::util::cache_lock::{CacheLock, CacheLockMode}; @@ -26,6 +27,11 @@ use crate::util::time_span::maybe_parse_time_span; use crate::workspace::global_cache_tracker::{self, GlobalCacheTracker}; use crate::{CargoResult, GlobalContext}; use anyhow::{Context as _, format_err}; +use cargo_util::paths; +use std::collections::BinaryHeap; +use std::fs; +use std::io::ErrorKind; +use std::path::PathBuf; use std::time::Duration; /// Default max age to auto-clean extracted sources, which can be recovered @@ -110,6 +116,8 @@ pub struct GcOpts { pub max_git_size: Option, /// The `--max-download-size` CLI option. pub max_download_size: Option, + /// The `--max-blob-size` CLI option. + pub max_blob_size: Option, } impl GcOpts { @@ -124,6 +132,7 @@ impl GcOpts { || self.max_crate_size.is_some() || self.max_git_size.is_some() || self.max_download_size.is_some() + || self.max_blob_size.is_some() } /// Returns whether any download cache cleaning options based on size are set. @@ -281,9 +290,135 @@ impl<'a, 'gctx> Gc<'a, 'gctx> { /// Performs garbage collection based on the given options. pub fn gc(&mut self, clean_ctx: &mut CleanContext<'gctx>, gc_opts: &GcOpts) -> CargoResult<()> { self.global_cache_tracker.clean(clean_ctx, gc_opts)?; + if let Some(blob_dir) = self.gctx.blob_storage_dir() + && let Some(max_blob_size) = gc_opts.max_blob_size + { + self.clean_blobs(clean_ctx, &blob_dir, max_blob_size)?; + } // In the future, other gc operations go here, such as target cleaning. Ok(()) } + + fn clean_blobs( + &self, + clean_ctx: &mut CleanContext<'gctx>, + blob_dir: &std::path::Path, + max_blob_size: u64, + ) -> CargoResult<()> { + let entries = match fs::read_dir(blob_dir) { + Ok(entries) => entries, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(e).with_context(|| { + format!("failed to read blob cache `{}`", blob_dir.display()) + }); + } + }; + + // Field order determines deletion priority. + // We prioritize orphaned blobs meaning they are no longer + // used by any workspaces. Then we try to delete the oldest + // blobs. Then prioritizing largest size if there are any ties. + #[derive(Eq, PartialEq, Ord, PartialOrd)] + struct FileItem { + referenced: bool, + last_used: u64, + size: u64, + path: PathBuf, + } + + // First get the total directory size + let mut total_size = 0u64; + for entry in entries { + let Ok(entry) = entry else { continue }; + if entry + .file_name() + .as_os_str() + .to_str() + .is_some_and(|name| name.ends_with(".timestamp")) + { + continue; + } + if let Ok(metadata) = entry.metadata() + && metadata.is_file() + { + total_size = total_size.saturating_add(metadata.len()); + } + } + if total_size <= max_blob_size { + return Ok(()); + } + + // Now loop over all of the files and add them to a heap + // while keeping track of the bytes. The heap orders files + // by the priority so we can quickly replace files with + // older files. Once we are done, we should have only the + // files we want to delete left. + + let bytes_to_free = total_size - max_blob_size; + let mut selected_size = 0u64; + let mut selected = BinaryHeap::new(); + + for entry in fs::read_dir(blob_dir)? { + let Ok(entry) = entry else { continue }; + if entry + .file_name() + .as_os_str() + .to_str() + .is_some_and(|name| name.ends_with(".timestamp")) + { + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + let path = entry.path(); + let timestamp_path = path.with_added_extension("timestamp"); + let last_used = read_timestamp_file(×tamp_path).unwrap_or(0); + let timestamp_links = timestamp_path + .metadata() + .ok() + .as_ref() + .and_then(|marker| paths::nlink(marker, ×tamp_path).ok()); + let Ok(blob_links) = paths::nlink(&metadata, &path) else { + continue; + }; + let item = FileItem { + referenced: blob_links > 1 || timestamp_links.is_some_and(|links| links > 1), + last_used, + path, + size: metadata.len(), + }; + + let is_higher_priority_to_delete = selected + .peek() + .is_some_and(|worst: &FileItem| item < *worst); + + if selected_size < bytes_to_free || is_higher_priority_to_delete { + selected_size = selected_size.saturating_add(item.size); + selected.push(item); + + // Now that we added a new file, we can potentially remove newer files + // that we don't need to hit the size target. + while selected + .peek() + .is_some_and(|worst| selected_size - worst.size >= bytes_to_free) + { + selected_size -= selected.pop().unwrap().size; + } + } + } + + let mut delete_paths = Vec::with_capacity(selected.len() * 2); + for item in selected { + delete_paths.push(item.path.with_added_extension("timestamp")); + delete_paths.push(item.path); + } + clean_ctx.remove_paths(&delete_paths) + } } /// Returns the shorter duration from `cur_span` versus `config_span`. diff --git a/tests/testsuite/blob_storage.rs b/tests/testsuite/blob_storage.rs new file mode 100644 index 00000000000..b305d64156c --- /dev/null +++ b/tests/testsuite/blob_storage.rs @@ -0,0 +1,97 @@ +use std::fs; + +use crate::prelude::*; +use cargo_test_support::paths::ReadOnly; +use cargo_test_support::registry::Package; +use cargo_test_support::{paths, prelude::*, project, t}; + +#[cargo_test] +fn disabled_without_unstable_flag() { + Package::new("bar", "0.1.0").publish(); + + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + edition = "2015" + + [dependencies] + bar = "0.1.0" + "#, + ) + .file("src/lib.rs", "") + .build(); + + p.cargo("build").run(); + + assert!(!paths::home().join(".cargo/blobs").exists()); +} + +#[cargo_test] +fn stores_dependency_artifact_by_content_hash() { + Package::new("bar", "0.1.0").publish(); + + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + edition = "2015" + + [dependencies] + bar = "0.1.0" + "#, + ) + .file("src/lib.rs", "") + .build(); + + p.cargo("build") + .arg("-Zshared-blob-storage") + .masquerade_as_nightly_cargo(&["shared-blob-storage"]) + .run(); + + let artifact = p + .glob("target/debug/build/bar/*/out/libbar-*.rlib") + .next() + .unwrap() + .unwrap(); + let contents = t!(fs::read(artifact)); + let hash = blake3::hash(&contents).to_hex(); + let blob = paths::home().join(".cargo/blobs").join(hash.as_str()); + assert_eq!(t!(fs::read(blob)), contents); +} + +#[cargo_test] +fn readonly_cargo_home_still_works() { + Package::new("bar", "0.1.0").publish(); + + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + edition = "2015" + + [dependencies] + bar = "0.1.0" + "#, + ) + .file("src/lib.rs", "") + .build(); + + p.cargo("generate-lockfile").run(); + p.cargo("fetch --locked").run(); + + let _readonly = ReadOnly::new(paths::cargo_home()); + p.cargo("build") + .arg("-Zshared-blob-storage") + .masquerade_as_nightly_cargo(&["shared-blob-storage"]) + .run(); +} diff --git a/tests/testsuite/cargo/z_help/stdout.term.svg b/tests/testsuite/cargo/z_help/stdout.term.svg index 7f17c79c0c4..36fbc3b80aa 100644 --- a/tests/testsuite/cargo/z_help/stdout.term.svg +++ b/tests/testsuite/cargo/z_help/stdout.term.svg @@ -1,4 +1,4 @@ - +