From 9210d413e14ee37c08613db5b6c4bfd8ac5ff28a Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 07:46:48 -0700 Subject: [PATCH 01/28] fix: request B2's maximum list page size OpenDAL only sends maxFileCount when ListOptions.limit is set. Without it, B2 defaults to 100 names per page, so prune/check pack listing becomes thousands of round-trips. Request 10000 for scheme b2. --- crates/backend/src/opendal.rs | 72 +++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/crates/backend/src/opendal.rs b/crates/backend/src/opendal.rs index c830c8cea..7ff11f711 100644 --- a/crates/backend/src/opendal.rs +++ b/crates/backend/src/opendal.rs @@ -34,6 +34,14 @@ use crate::reqwest::reqwest_client; mod constants { /// Default number of retries pub(super) const DEFAULT_RETRY: usize = 5; + + /// B2 `b2_list_file_names` page size to request. + /// + /// `OpenDAL` only sends `maxFileCount` when `ListOptions.limit` is set. If we + /// omit it, B2 defaults to 100 names per page (max 10000). Prune (and + /// check) list every pack under `data/`, so the default turns a few dozen + /// round-trips into thousands and dominates runtime against B2. + pub(super) const B2_LIST_PAGE_SIZE: usize = 10_000; } /// `OpenDALBackend` contains a wrapper around an blocking operator of the `OpenDAL` library. @@ -217,6 +225,30 @@ impl OpenDALBackend { Ok(Self { operator }) } + /// Listing options used for repository (and source) listings. + /// + /// For B2 this raises the per-request page size from the API default of 100 + /// to the documented maximum of 10000. + fn list_options(&self, recursive: bool) -> ListOptions { + ListOptions { + recursive, + limit: self.list_page_size(), + ..Default::default() + } + } + + /// Backend-specific list page size, if we should override the service default. + fn list_page_size(&self) -> Option { + let info = self.operator.info(); + if !info.capability().list_with_limit { + return None; + } + match info.scheme() { + "b2" => Some(constants::B2_LIST_PAGE_SIZE), + _ => None, + } + } + /// Return a path for the given file type and id. /// /// # Arguments @@ -249,10 +281,7 @@ impl OpenDALBackend { /// # Errors /// If listing fails or exclude patterns cannot be compiled pub fn as_source(self, excludes: &Excludes) -> RusticResult { - let list_options = ListOptions { - recursive: true, - ..Default::default() - }; + let list_options = self.list_options(true); // openDAL lister may entries in random order; hence we collect and sort them here. // This also allows to handle listing errors directly let mut entries: Vec<_> = self @@ -348,14 +377,9 @@ impl ReadBackend for OpenDALBackend { } let path = tpe.dirname().to_string() + "/"; - let list_options = ListOptions { - recursive: true, - ..Default::default() - }; - let lister = self .operator - .lister_options(&path, list_options) + .lister_options(&path, self.list_options(true)) .map_err(|err| { RusticError::with_source(ErrorKind::Backend, "Listing failed for `{type}`", err) .attach_context("type", tpe.to_string()) @@ -405,13 +429,9 @@ impl ReadBackend for OpenDALBackend { } let path = tpe.dirname().to_string() + "/"; - let list_options = ListOptions { - recursive: true, - ..Default::default() - }; let lister = self .operator - .lister_options(&path, list_options) + .lister_options(&path, self.list_options(true)) .map_err(|err| { RusticError::with_source(ErrorKind::Backend, "Listing failed for `{type}`", err) .attach_context("type", tpe.to_string()) @@ -632,6 +652,28 @@ mod tests { assert!(Throttle::from_str(input).is_err()); } + #[rstest] + #[case("b2", Some(constants::B2_LIST_PAGE_SIZE))] + #[case("s3_aws", None)] + fn list_page_size_matches_scheme( + #[case] fixture: &str, + #[case] expected: Option, + ) -> Result<()> { + #[derive(Deserialize)] + struct TestCase { + path: String, + options: BTreeMap, + } + + let fixture_path = PathBuf::from(format!("tests/fixtures/opendal/{fixture}.toml")); + let test: TestCase = toml::from_str(&fs::read_to_string(fixture_path)?)?; + let backend = OpenDALBackend::new(test.path, test.options)?; + + assert_eq!(backend.list_page_size(), expected); + assert_eq!(backend.list_options(true).limit, expected); + Ok(()) + } + #[rstest] fn new_opendal_backend( #[files("tests/fixtures/opendal/*.toml")] test_case: PathBuf, From d468d444aeb8dbdfeccf67713ef17ebba8fbd620 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 07:47:00 -0700 Subject: [PATCH 02/28] fix: fetch 4 MiB unused pack gaps in one read 256 KiB MAX_HOLESIZE split prune/restore pack reads into extra HTTP range GETs on high-latency stores. 4 MiB is cheaper than another RTT on a ~100 Mbps link. --- crates/core/src/blob.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/core/src/blob.rs b/crates/core/src/blob.rs index 4ba312f22..8b410c698 100644 --- a/crates/core/src/blob.rs +++ b/crates/core/src/blob.rs @@ -13,8 +13,13 @@ pub(super) mod constants { /// The maximum size of pack-part which is read at once from the backend. /// (needed to limit the memory size used for large backends) pub(crate) const LIMIT_PACK_READ: u32 = 40 * 1024 * 1024; // 40 MiB - /// The maximum size of holes which are still read when repacking - pub(crate) const MAX_HOLESIZE: u32 = 256 * 1024; // 256 kiB + /// Maximum unused gap that is still fetched with the surrounding blobs. + /// + /// 256 KiB was too small for high-latency object stores (B2): every larger + /// hole became another HTTP range GET, and prune/restore issued those + /// sequentially. 4 MiB is about one RTT of extra download on a ~100 Mbps + /// link, which is cheaper than an extra request. + pub(crate) const MAX_HOLESIZE: u32 = 4 * 1024 * 1024; // 4 MiB } /// All [`BlobType`]s which are supported by the repository From a75f3c80ba869c3a386156e94650b5fa3d3acdec Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 07:47:17 -0700 Subject: [PATCH 03/28] fix: range-GET prune repack chunks in parallel Unused gaps in a pack were fetched one after another. Issue those range GETs in parallel; the packer already serializes writes. --- crates/core/src/commands/prune.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/core/src/commands/prune.rs b/crates/core/src/commands/prune.rs index 306a5ab42..364c61e72 100644 --- a/crates/core/src/commands/prune.rs +++ b/crates/core/src/commands/prune.rs @@ -1416,14 +1416,15 @@ pub(crate) fn prune_repository( }) .collect(); - // TODO: repack in parallel - for blobs in blob_chunks { + // Range-GETs for holes in the same pack run in parallel. The + // packer already serializes writes via its channel / lock. + blob_chunks.into_par_iter().try_for_each(|blobs| { if opts.fast_repack { - repacker.copy_fast(blobs, &p)?; + repacker.copy_fast(blobs, &p) } else { - repacker.copy(blobs, &p)?; + repacker.copy(blobs, &p) } - } + })?; Ok(()) })?; _ = tree_repacker.finalize()?; From b99e7192dae5f5f80decb1b12703221e25ea3cab Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 07:47:26 -0700 Subject: [PATCH 04/28] fix: prefetch index files with extra IO workers stream_list used CPU-count Rayon workers and a zero-capacity channel, so reading index/snapshots was one GET at a time on high-latency backends. Use 16-32 workers with prefetch so GETs overlap decrypt/parse. --- crates/core/src/backend/decrypt.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/core/src/backend/decrypt.rs b/crates/core/src/backend/decrypt.rs index c36d74ac5..bdc1ec25c 100644 --- a/crates/core/src/backend/decrypt.rs +++ b/crates/core/src/backend/decrypt.rs @@ -190,17 +190,26 @@ pub trait DecryptReadBackend: ReadBackend + Clone + 'static { /// If the files could not be read. fn stream_list(&self, list: Vec, p: &Progress) -> StreamResult { p.set_length(list.len() as u64); - // we use a zero-capacity channel; the loading is typically the bottleneck, not the processing. - let (tx, rx) = bounded(0); + // Index/snapshot files are small; on B2 this is RTT-bound (one GET each). + // Restic uses `connections + GOMAXPROCS`. Keep extra workers so some can + // GET while others decrypt/parse, and buffer so send does not stall IO. + let workers = (rayon::current_num_threads() + 16).clamp(16, 32); + let (tx, rx) = bounded(workers.saturating_mul(2)); let be = self.clone(); let p = p.clone(); spawn(move || { - _ = list.into_par_iter().try_for_each(|id| { - let file = be.get_file::(&id).map(|file| (id, file)); - p.inc(1); - tx.send(file).ok() // abort as soon as possible if sending fails, i.e. if the receiver is dropped - }); + let work = || { + _ = list.into_par_iter().try_for_each(|id| { + let file = be.get_file::(&id).map(|file| (id, file)); + p.inc(1); + tx.send(file).ok() + }); + }; + match rayon::ThreadPoolBuilder::new().num_threads(workers).build() { + Ok(pool) => pool.install(work), + Err(_) => work(), + } }); Ok(rx) } From ec07d6a37bb6789b3d2363c29026e2681a88b496 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 07:47:37 -0700 Subject: [PATCH 05/28] fix: scale tree loaders during prune used-blob walk TreeStreamerOnce was hardcoded to 4 workers, which left B2 tree-pack GETs idle. Use 2x CPUs (8-32) with a larger out channel, and a HashSet for visited tree ids. --- crates/core/src/blob/tree.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/core/src/blob/tree.rs b/crates/core/src/blob/tree.rs index d2fbe4083..19a7c42c8 100644 --- a/crates/core/src/blob/tree.rs +++ b/crates/core/src/blob/tree.rs @@ -5,7 +5,7 @@ pub mod rewrite; use std::{ borrow::Cow, cmp::Ordering, - collections::{BTreeMap, BTreeSet, BinaryHeap}, + collections::{BTreeMap, BinaryHeap, HashSet}, ffi::OsStr, mem, path::{Component, Path, PathBuf, Prefix}, @@ -16,6 +16,7 @@ use crossbeam_channel::{Receiver, Sender, bounded, unbounded}; use derive_setters::Setters; use ignore::Match; use ignore::overrides::Override; +use rayon::current_num_threads; use serde::{Deserialize, Deserializer}; use serde_derive::Serialize; @@ -53,8 +54,20 @@ pub enum TreeErrorKind { pub(crate) type TreeResult = Result; pub(super) mod constants { - /// The maximum number of trees that are loaded in parallel - pub(super) const MAX_TREE_LOADER: usize = 4; + /// Minimum / maximum tree-loader threads for `TreeStreamerOnce`. + /// + /// Four was too few on high-latency backends (B2): prune's "finding used + /// blobs..." walks every unique tree with a pack range GET. Restic uses + /// `connections + GOMAXPROCS` workers. We scale with Rayon (2× CPUs, + /// clamped) so a 4-core box gets 8 loaders, not 4. + pub(super) const MIN_TREE_LOADER: usize = 8; + pub(super) const MAX_TREE_LOADER: usize = 32; +} + +fn tree_loader_count() -> usize { + current_num_threads() + .saturating_mul(2) + .clamp(constants::MIN_TREE_LOADER, constants::MAX_TREE_LOADER) } pub(crate) type TreeStreamItem = RusticResult<(PathBuf, Tree)>; @@ -623,7 +636,7 @@ where #[derive(Debug)] pub struct TreeStreamerOnce { /// The visited tree IDs - visited: BTreeSet, + visited: HashSet, /// The queue to send tree IDs to queue_in: Option>, /// The queue to receive trees from @@ -661,10 +674,11 @@ impl TreeStreamerOnce { ) -> RusticResult { p.set_length(ids.len() as u64); - let (out_tx, out_rx) = bounded(constants::MAX_TREE_LOADER); + let loaders = tree_loader_count(); + let (out_tx, out_rx) = bounded(loaders.saturating_mul(4).max(32)); let (in_tx, in_rx) = unbounded(); - for _ in 0..constants::MAX_TREE_LOADER { + for _ in 0..loaders { let be = be.clone(); let index = index.clone(); let in_rx = in_rx.clone(); @@ -683,7 +697,7 @@ impl TreeStreamerOnce { let counter = vec![0; ids.len()]; let mut streamer = Self { - visited: BTreeSet::new(), + visited: HashSet::new(), queue_in: Some(in_tx), queue_out: out_rx, p, From 43365bee04756885c19b0ad98125df8d4cbf71f3 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 07:47:59 -0700 Subject: [PATCH 06/28] fix: store prune used blob ids in a HashMap The used-id set was a BTreeMap, so inserts got slower as prune walked more blobs. A HashMap keeps that path O(1). --- crates/core/src/commands/prune.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/core/src/commands/prune.rs b/crates/core/src/commands/prune.rs index 364c61e72..3229a76b3 100644 --- a/crates/core/src/commands/prune.rs +++ b/crates/core/src/commands/prune.rs @@ -4,7 +4,7 @@ /// accessors along with logging macros. Customize as you see fit. use std::{ cmp::Ordering, - collections::{BTreeMap, BTreeSet}, + collections::{BTreeMap, BTreeSet, HashMap}, str::FromStr, }; @@ -584,7 +584,7 @@ pub struct PrunePlan { /// The time the plan was created time: Zoned, /// The ids of the blobs which are used - used_ids: BTreeMap, + used_ids: HashMap, /// The ids of the existing packs existing_packs: BTreeMap, /// The packs which should be repacked @@ -604,7 +604,7 @@ impl PrunePlan { /// * `existing_packs` - The ids of the existing packs /// * `index_files` - The index files fn new( - used_ids: BTreeMap, + used_ids: HashMap, existing_packs: BTreeMap, index_files: Vec<(IndexId, IndexFile)>, ) -> Self { @@ -1492,8 +1492,8 @@ impl PackInfo { /// # Arguments /// /// * `pack` - The `PrunePack` to create the `PackInfo` from - /// * `used_ids` - The `BTreeMap` of used ids - fn from_pack(pack: &PrunePack, used_ids: &mut BTreeMap) -> Self { + /// * `used_ids` - The map of used ids + fn from_pack(pack: &PrunePack, used_ids: &mut HashMap) -> Self { let mut pi = Self { blob_type: pack.blob_type, used_blobs: 0, @@ -1585,7 +1585,7 @@ fn find_used_blobs( be: &impl DecryptReadBackend, index: &impl ReadGlobalIndex, ignore_snaps: &[SnapshotId], -) -> RusticResult> { +) -> RusticResult> { let ignore_snaps: BTreeSet<_> = ignore_snaps.iter().collect(); let p = repo.progress_counter("reading snapshots..."); @@ -1602,7 +1602,7 @@ fn find_used_blobs( .try_collect()?; p.finish(); - let mut ids: BTreeMap<_, _> = snap_trees + let mut ids: HashMap<_, _> = snap_trees .iter() .map(|id| (BlobId::from(**id), 0)) .collect(); From edb089dd97fe69c744a6ef276dc8cb71c3c150a7 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Mon, 7 Sep 2026 13:35:27 -0700 Subject: [PATCH 07/28] fix: collect index entries in bounded chunks A single Vec of blob ids doubles on growth. On a large repo that request is hundreds of MiB while the old buffer is still live, and musl aborts (openzwave: memory allocation of 807665664 bytes failed at reading index 906/1551). Store ids and full entries in 1 Mi-entry chunks and binary-search each sorted chunk. --- crates/core/src/index/binarysorted.rs | 250 +++++++++++++++++++------- 1 file changed, 189 insertions(+), 61 deletions(-) diff --git a/crates/core/src/index/binarysorted.rs b/crates/core/src/index/binarysorted.rs index f399da24c..4e9e41a8d 100644 --- a/crates/core/src/index/binarysorted.rs +++ b/crates/core/src/index/binarysorted.rs @@ -1,3 +1,5 @@ +use std::cmp::Ordering; + use rayon::prelude::*; use crate::{ @@ -20,6 +22,77 @@ pub(crate) struct SortedEntry { location: BlobLocation, } +/// Max entries in one collector chunk. +/// +/// Growing a single `Vec` of blob ids doubles it. On a large repo that request +/// is hundreds of MiB while the old buffer is still live, and musl aborts: +/// `memory allocation of N bytes failed`. Chunks cap each allocation. +const ENTRY_CHUNK_LEN: usize = if cfg!(test) { 4 } else { 1 << 20 }; + +/// Append-only vec of bounded chunks. Lookups binary-search every chunk. +#[derive(Debug)] +pub(crate) struct Chunked { + chunks: Vec>, +} + +impl Default for Chunked { + fn default() -> Self { + Self { chunks: Vec::new() } + } +} + +impl Chunked { + fn push(&mut self, item: T) { + if self + .chunks + .last() + .is_none_or(|chunk| chunk.len() >= ENTRY_CHUNK_LEN) + { + self.chunks.push(Vec::with_capacity(ENTRY_CHUNK_LEN)); + } + self.chunks + .last_mut() + .expect("chunk is created above") + .push(item); + } + + fn shrink_last(&mut self) { + if let Some(last) = self.chunks.last_mut() { + last.shrink_to_fit(); + } + } + + fn par_sort_unstable(&mut self) + where + T: Ord + Send, + { + self.chunks.par_iter_mut().for_each(|chunk| { + chunk.sort_unstable(); + }); + } + + fn par_sort_unstable_by(&mut self, compare: F) + where + T: Send, + F: Fn(&T, &T) -> Ordering + Sync, + { + self.chunks.par_iter_mut().for_each(|chunk| { + chunk.sort_unstable_by(&compare); + }); + } + + fn par_sort_unstable_by_key(&mut self, f: F) + where + T: Send, + K: Ord, + F: Fn(&T) -> K + Sync, + { + self.chunks.par_iter_mut().for_each(|chunk| { + chunk.sort_unstable_by_key(&f); + }); + } +} + /// `IndexType` determines which information is stored in the index. #[derive(Debug, Clone, Copy)] pub enum IndexType { @@ -36,8 +109,8 @@ pub enum IndexType { pub(crate) enum EntriesVariants { #[default] None, - Ids(Vec), - FullEntries(Vec), + Ids(Chunked), + FullEntries(Chunked), } #[derive(Default, Debug)] @@ -54,7 +127,8 @@ pub struct IndexCollector(BlobTypeMap); pub struct PackIndexes { c: Index, tpe: BlobType, - idx: BlobTypeMap<(u32, usize)>, + pack_idx: BlobTypeMap, + cursors: BlobTypeMap>, } #[derive(Debug)] @@ -89,11 +163,11 @@ impl IndexCollector { pub fn new(tpe: IndexType) -> Self { let mut collector = Self::default(); - collector.0[BlobType::Tree].entries = EntriesVariants::FullEntries(Vec::new()); + collector.0[BlobType::Tree].entries = EntriesVariants::FullEntries(Chunked::default()); collector.0[BlobType::Data].entries = match tpe { IndexType::OnlyTrees => EntriesVariants::None, - IndexType::DataIds => EntriesVariants::Ids(Vec::new()), - IndexType::Full => EntriesVariants::FullEntries(Vec::new()), + IndexType::DataIds => EntriesVariants::Ids(Chunked::default()), + IndexType::Full => EntriesVariants::FullEntries(Chunked::default()), }; collector @@ -110,8 +184,14 @@ impl IndexCollector { Index(self.0.map(|_, mut tc| { match &mut tc.entries { EntriesVariants::None => {} - EntriesVariants::Ids(ids) => ids.par_sort_unstable(), - EntriesVariants::FullEntries(entries) => entries.par_sort_unstable_by_key(|e| e.id), + EntriesVariants::Ids(ids) => { + ids.shrink_last(); + ids.par_sort_unstable(); + } + EntriesVariants::FullEntries(entries) => { + entries.shrink_last(); + entries.par_sort_unstable_by_key(|e| e.id); + } } let packs = tc.packs.into_iter().map(|(id, _)| id).collect(); @@ -130,7 +210,6 @@ impl Extend for IndexCollector { T: IntoIterator, { for p in iter { - let len = p.blobs.len(); let blob_type = p.blob_type(); let size = p.pack_size(); @@ -140,12 +219,6 @@ impl Extend for IndexCollector { self.0[blob_type].total_size += u64::from(size); - match &mut self.0[blob_type].entries { - EntriesVariants::None => {} - EntriesVariants::Ids(idents) => idents.reserve(len), - EntriesVariants::FullEntries(entries) => entries.reserve(len), - } - for blob in &p.blobs { let be = SortedEntry { id: blob.id, @@ -166,39 +239,45 @@ impl Iterator for PackIndexes { type Item = IndexPack; fn next(&mut self) -> Option { - let (pack_idx, idx) = loop { - let (pack_idx, idx) = &mut self.idx[self.tpe]; - let pack_count = u32::try_from(self.c.0[self.tpe].packs.len()) - .expect("pack count should fit into u32"); - if *pack_idx >= pack_count { - if self.tpe == BlobType::Data { + loop { + let tpe = self.tpe; + let pack_count = + u32::try_from(self.c.0[tpe].packs.len()).expect("pack count should fit into u32"); + if self.pack_idx[tpe] >= pack_count { + if tpe == BlobType::Data { return None; } self.tpe = BlobType::Data; - } else { - break (pack_idx, idx); + continue; } - }; - let mut pack = IndexPack { - id: self.c.0[self.tpe].packs[*pack_idx as usize], - ..Default::default() - }; + let pack_idx = self.pack_idx[tpe]; + let mut pack = IndexPack { + id: self.c.0[tpe].packs[pack_idx as usize], + ..Default::default() + }; - if let EntriesVariants::FullEntries(entries) = &self.c.0[self.tpe].entries { - while *idx < entries.len() && entries[*idx].pack_idx == *pack_idx { - let entry = &entries[*idx]; - pack.blobs.push(IndexBlob { - id: entry.id, - tpe: self.tpe, - location: entry.location, - }); - *idx += 1; + if let EntriesVariants::FullEntries(entries) = &self.c.0[tpe].entries { + let cursors = &mut self.cursors[tpe]; + if cursors.len() != entries.chunks.len() { + cursors.resize(entries.chunks.len(), 0); + } + for (chunk, cursor) in entries.chunks.iter().zip(cursors.iter_mut()) { + while *cursor < chunk.len() && chunk[*cursor].pack_idx == pack_idx { + let entry = &chunk[*cursor]; + pack.blobs.push(IndexBlob { + id: entry.id, + tpe, + location: entry.location, + }); + *cursor += 1; + } + } } - } - *pack_idx += 1; - Some(pack) + self.pack_idx[tpe] += 1; + return Some(pack); + } } } @@ -214,34 +293,32 @@ impl IntoIterator for Index { } } PackIndexes { - c: Self(self.0.map(|_, mut tc| { - if let EntriesVariants::FullEntries(entries) = &mut tc.entries { - entries.par_sort_unstable_by(|e1, e2| e1.pack_idx.cmp(&e2.pack_idx)); - } - - tc - })), + c: self, tpe: BlobType::Tree, - idx: BlobTypeMap::default(), + pack_idx: BlobTypeMap::default(), + cursors: BlobTypeMap::default(), } } } impl ReadIndex for Index { fn get_id(&self, blob_type: BlobType, id: &BlobId) -> Option { - let EntriesVariants::FullEntries(vec) = &self.0[blob_type].entries else { + let EntriesVariants::FullEntries(entries) = &self.0[blob_type].entries else { // get_id() only gives results if index contains full entries return None; }; - vec.binary_search_by_key(id, |e| e.id).ok().map(|index| { - let be = &vec[index]; - IndexEntry::new( - blob_type, - self.0[blob_type].packs[be.pack_idx as usize], - be.location, - ) - }) + for chunk in &entries.chunks { + if let Ok(index) = chunk.binary_search_by_key(id, |e| e.id) { + let be = &chunk[index]; + return Some(IndexEntry::new( + blob_type, + self.0[blob_type].packs[be.pack_idx as usize], + be.location, + )); + } + } + None } fn total_size(&self, blob_type: BlobType) -> u64 { @@ -250,10 +327,14 @@ impl ReadIndex for Index { fn has(&self, blob_type: BlobType, id: &BlobId) -> bool { match &self.0[blob_type].entries { - EntriesVariants::FullEntries(entries) => { - entries.binary_search_by_key(id, |e| e.id).is_ok() - } - EntriesVariants::Ids(ids) => ids.binary_search(id).is_ok(), + EntriesVariants::FullEntries(entries) => entries + .chunks + .iter() + .any(|chunk| chunk.binary_search_by_key(id, |e| e.id).is_ok()), + EntriesVariants::Ids(ids) => ids + .chunks + .iter() + .any(|chunk| chunk.binary_search(id).is_ok()), // has() only gives results if index contains full entries or ids EntriesVariants::None => false, } @@ -439,6 +520,53 @@ mod tests { ); assert!(!index.has(BlobType::Tree, &id)); assert!(index.get_id(BlobType::Tree, &id).is_none()); + + // This id is in the second test-sized chunk (ENTRY_CHUNK_LEN is 4 under cfg(test)). + let id = "ee67585c7c53324e74537ab7aa44f889c0767c1b67e7e336fae6204aef2d4c73".parse()?; + assert!(index.has(BlobType::Data, &id)); + assert_eq!( + index.get_id(BlobType::Data, &id), + Some(IndexEntry { + blob_type: BlobType::Data, + pack: "3b25ec6d16401c31099c259311562160b1b5efbcf70bd69d0463104d3b8148fc".parse()?, + location: BlobLocation { + offset: 7737, + length: 7686, + uncompressed_length: Some(NonZeroU32::new(29928).unwrap()), + } + }), + ); + Ok(()) + } + + #[test] + fn into_iter_groups_blobs_by_pack() -> RusticResult<()> { + let packs: Vec<_> = index(IndexType::Full).into_iter().collect(); + assert_eq!(packs.len(), 3); + assert_eq!( + packs[0].id, + "8431a27d38dd7d192dc37abd43a85d6dc4298de72fc8f583c5d7cdd09fa47274".parse()? + ); + assert_eq!(packs[0].blobs.len(), 2); + assert_eq!( + packs[1].id, + "217f145b63fbc10267f5a686186689ea3389bed0d6a54b50ffc84d71f99eb7fa".parse()? + ); + assert_eq!(packs[1].blobs.len(), 3); + assert_eq!( + packs[2].id, + "3b25ec6d16401c31099c259311562160b1b5efbcf70bd69d0463104d3b8148fc".parse()? + ); + assert_eq!(packs[2].blobs.len(), 4); + Ok(()) + } + + #[test] + fn data_ids_has_across_chunks() -> RusticResult<()> { + let index = index(IndexType::DataIds); + let id = "f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2".parse()?; + assert!(index.has(BlobType::Data, &id)); + assert!(index.get_id(BlobType::Data, &id).is_none()); Ok(()) } } From 04644770c8fe9f10bb1a1a68fae6445282e46873 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 07:48:12 -0700 Subject: [PATCH 08/28] fix: keep cached pack files open for tree blob reads Cache read_partial opened and closed the pack file for every tree blob. Keep up to 2048 FDs and pread from them. Hits skip LockPool and exists(); write/remove drop the cached handle. --- crates/core/src/backend/cache.rs | 255 +++++++++++++++++++++++++++---- 1 file changed, 226 insertions(+), 29 deletions(-) diff --git a/crates/core/src/backend/cache.rs b/crates/core/src/backend/cache.rs index 7cacd6539..cadc3c954 100644 --- a/crates/core/src/backend/cache.rs +++ b/crates/core/src/backend/cache.rs @@ -1,7 +1,8 @@ use std::{ collections::HashMap, + fmt, fs::{self, File}, - io::{self, Read, Seek, SeekFrom}, + io::{self, Read}, path::{Path, PathBuf}, sync::Arc, }; @@ -19,6 +20,34 @@ use crate::{ repofile::configfile::RepositoryId, }; +mod constants { + /// Pack files kept open for cache `read_partial`. + /// + /// Tree walking reads many blobs from the same packs. Opening the cache + /// file per blob was ~65% of prune CPU in a Time Profiler trace. + pub(super) const OPEN_FILE_CAPACITY: usize = 2048; +} + +type OpenFileCache = quick_cache::sync::Cache>; + +fn is_too_many_open_files(err: &io::Error) -> bool { + #[cfg(unix)] + { + // POSIX EMFILE / ENFILE. Do not treat 4 as a match: that is EINTR. + matches!(err.raw_os_error(), Some(24 | 23)) + } + #[cfg(windows)] + { + // ERROR_TOO_MANY_OPEN_FILES + err.raw_os_error() == Some(4) + } + #[cfg(not(any(unix, windows)))] + { + let _ = err; + false + } +} + /// Backend that caches data. /// /// This backend caches data in a directory. @@ -159,12 +188,18 @@ impl ReadBackend for CachedBackend { length: u32, ) -> RusticResult { if cacheable || tpe.is_cacheable() { - let guard = self.lock_pool.blocking_lock(*id); - if self.cache.path(tpe, id).exists() { - // early drop the lock guard, so we can read the cache in parallel. - drop(guard); + match self.cache.read_partial(tpe, id, offset, length) { + Ok(Some(data)) => return Ok(data), + Ok(None) => {} + Err(err) => warn!( + "Error in cache backend reading {tpe:?},{id}: {}", + err.display_log() + ), } + // Miss: serialize fills of the same pack so two threads don't both + // download it. Hits above skip this lock and the exists()+open storm. + let _guard = self.lock_pool.blocking_lock(*id); match self.cache.read_partial(tpe, id, offset, length) { Ok(Some(data)) => return Ok(data), Ok(None) => {} @@ -263,10 +298,21 @@ impl WriteBackend for CachedBackend { } /// Backend that caches data in a directory. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Cache { /// The path to the cache. path: PathBuf, + /// Recently used cache files kept open for `read_partial`. + open_files: Arc, +} + +impl fmt::Debug for Cache { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Cache") + .field("path", &self.path) + .field("open_files", &self.open_files.len()) + .finish() + } } impl Cache { @@ -329,7 +375,10 @@ impl Cache { .attach_context("id", id.to_string()) })?; - Ok(Self { path }) + Ok(Self { + path, + open_files: Arc::new(OpenFileCache::new(constants::OPEN_FILE_CAPACITY)), + }) } /// Returns the path to the location of this [`Cache`]. @@ -501,11 +550,23 @@ impl Cache { ) -> RusticResult> { trace!("cache reading tpe: {tpe:?}, id: {id}, offset: {offset}"); - let path = self.path(tpe, id); + if let Some(file) = self.open_files.get(id) { + match Self::read_range(&file, offset, length) { + Ok(data) => { + trace!("cache hit!"); + return Ok(Some(data)); + } + Err(_) => { + // Stale or truncated FD; reopen from disk. + _ = self.open_files.remove(id); + } + } + } - let mut file = match File::open(&path) { - Ok(file) => file, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + let path = self.path(tpe, id); + let file = match self.open_cached(id, &path) { + Ok(Some(file)) => file, + Ok(None) => return Ok(None), Err(err) => { return Err(RusticError::with_source( ErrorKind::InputOutput, @@ -518,23 +579,7 @@ impl Cache { } }; - _ = file - .seek(SeekFrom::Start(u64::from(offset))) - .map_err(|err| { - RusticError::with_source( - ErrorKind::InputOutput, - "Failed to seek to `{offset}` in file `{path}`", - err, - ) - .attach_context("path", path.display().to_string()) - .attach_context("tpe", tpe.to_string()) - .attach_context("id", id.to_string()) - .attach_context("offset", offset.to_string()) - })?; - - let mut vec = vec![0; length as usize]; - - file.read_exact(&mut vec).map_err(|err| { + let data = Self::read_range(&file, offset, length).map_err(|err| { RusticError::with_source( ErrorKind::InputOutput, "Failed to read at offset `{offset}` from file at `{path}`", @@ -549,7 +594,50 @@ impl Cache { trace!("cache hit!"); - Ok(Some(vec.into())) + Ok(Some(data)) + } + + fn open_cached(&self, id: &Id, path: &Path) -> io::Result>> { + if let Some(file) = self.open_files.get(id) { + return Ok(Some(file)); + } + + match File::open(path) { + Ok(file) => Ok(Some(self.remember_open(id, file))), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) if is_too_many_open_files(&err) => { + self.open_files.clear(); + match File::open(path) { + Ok(file) => Ok(Some(self.remember_open(id, file))), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } + } + Err(err) => Err(err), + } + } + + fn remember_open(&self, id: &Id, file: File) -> Arc { + let file = Arc::new(file); + self.open_files.insert(*id, file.clone()); + file + } + + fn read_range(file: &File, offset: u32, length: u32) -> io::Result { + let mut vec = vec![0; length as usize]; + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + file.read_exact_at(&mut vec, u64::from(offset))?; + } + #[cfg(not(unix))] + { + use std::io::{Seek, SeekFrom}; + let mut file = file.try_clone()?; + file.seek(SeekFrom::Start(u64::from(offset)))?; + file.read_exact(&mut vec)?; + } + Ok(vec.into()) } /// Writes the given data to the given file. @@ -627,6 +715,8 @@ impl Cache { .attach_context("path", filename.display().to_string()) .ask_report() })?; + // Drop any FD pointing at the previous inode. + _ = self.open_files.remove(id); Ok(()) } @@ -643,6 +733,7 @@ impl Cache { /// * If the file could not be removed. pub fn remove(&self, tpe: FileType, id: &Id) -> RusticResult<()> { trace!("cache writing tpe: {tpe:?}, id: {id}"); + _ = self.open_files.remove(id); let filename = self.path(tpe, id); fs::remove_file(&filename).map_err(|err| { RusticError::with_source( @@ -658,3 +749,109 @@ impl Cache { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + fn new_cache() -> (tempfile::TempDir, Cache) { + let dir = tempfile::tempdir().unwrap(); + let cache = Cache::new(RepositoryId::default(), Some(dir.path().to_path_buf())).unwrap(); + (dir, cache) + } + + #[test] + fn read_partial_reuses_open_file() { + let (_dir, cache) = new_cache(); + let id = Id::random(); + let payload: Vec = (0..4096).map(|i| i as u8).collect(); + cache + .write_bytes(FileType::Pack, &id, &payload.clone().into()) + .unwrap(); + + for _ in 0..64 { + let got = cache + .read_partial(FileType::Pack, &id, 100, 50) + .unwrap() + .unwrap(); + assert_eq!(got.as_ref(), &payload[100..150]); + } + assert_eq!(cache.open_files.len(), 1); + } + + #[test] + fn write_bytes_invalidates_open_file() { + let (_dir, cache) = new_cache(); + let id = Id::random(); + cache + .write_bytes(FileType::Pack, &id, &vec![0_u8; 128].into()) + .unwrap(); + assert_eq!( + cache + .read_partial(FileType::Pack, &id, 0, 4) + .unwrap() + .unwrap() + .as_ref(), + &[0, 0, 0, 0] + ); + + cache + .write_bytes(FileType::Pack, &id, &vec![0xff_u8; 128].into()) + .unwrap(); + assert_eq!( + cache + .read_partial(FileType::Pack, &id, 0, 4) + .unwrap() + .unwrap() + .as_ref(), + &[0xff, 0xff, 0xff, 0xff] + ); + } + + #[test] + fn remove_closes_and_hides_file() { + let (_dir, cache) = new_cache(); + let id = Id::random(); + cache + .write_bytes(FileType::Pack, &id, &vec![1_u8; 16].into()) + .unwrap(); + _ = cache.read_partial(FileType::Pack, &id, 0, 4).unwrap(); + cache.remove(FileType::Pack, &id).unwrap(); + assert!( + cache + .read_partial(FileType::Pack, &id, 0, 4) + .unwrap() + .is_none() + ); + assert_eq!(cache.open_files.len(), 0); + } + + #[test] + fn concurrent_partial_reads() { + let (_dir, cache) = new_cache(); + let id = Id::random(); + let payload: Vec = (0..8192).map(|i| (i % 251) as u8).collect(); + cache + .write_bytes(FileType::Pack, &id, &payload.clone().into()) + .unwrap(); + thread::scope(|s| { + for t in 0..16 { + let cache = &cache; + let payload = &payload; + _ = s.spawn(move || { + let offset = u32::try_from(t * 16).unwrap(); + for _ in 0..100 { + let got = cache + .read_partial(FileType::Pack, &id, offset, 16) + .unwrap() + .unwrap(); + let start = usize::try_from(offset).unwrap(); + assert_eq!(got.as_ref(), &payload[start..start + 16]); + } + }); + } + }); + assert_eq!(cache.open_files.len(), 1); + } +} From 46746959baa61b2effeb1ce0cf4525408893d791 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 08:12:22 -0700 Subject: [PATCH 09/28] fix: stream-decode trees during prune used-blob walk Prune only needs file content ids and directory subtree ids. Deserialize a compact UsedBlobsTree instead of a full Vec, matching restic's streaming tree walk. Check and copy still load complete trees. --- crates/core/src/blob/tree.rs | 159 ++++++++++++------ crates/core/src/blob/tree/used_blobs.rs | 213 ++++++++++++++++++++++++ crates/core/src/commands/prune.rs | 25 +-- 3 files changed, 330 insertions(+), 67 deletions(-) create mode 100644 crates/core/src/blob/tree/used_blobs.rs diff --git a/crates/core/src/blob/tree.rs b/crates/core/src/blob/tree.rs index 19a7c42c8..0d8f10fa7 100644 --- a/crates/core/src/blob/tree.rs +++ b/crates/core/src/blob/tree.rs @@ -1,6 +1,7 @@ pub mod excludes; pub mod modify; pub mod rewrite; +mod used_blobs; use std::{ borrow::Cow, @@ -70,10 +71,52 @@ fn tree_loader_count() -> usize { .clamp(constants::MIN_TREE_LOADER, constants::MAX_TREE_LOADER) } -pub(crate) type TreeStreamItem = RusticResult<(PathBuf, Tree)>; type NodeStreamItem = RusticResult<(PathBuf, Node)>; impl_blobid!(TreeId, BlobType::Tree); +pub(crate) use used_blobs::UsedBlobsTree; + +/// A tree loaded by [`TreeStreamerOnce`]. +/// +/// Prune uses [`UsedBlobsTree`] so it does not allocate full [`Node`]s. +pub(crate) trait LoadedTree: Send + 'static { + fn load( + be: &BE, + index: &I, + id: TreeId, + ) -> RusticResult + where + Self: Sized; + + fn child_trees(&self, parent: &Path) -> Vec<(PathBuf, TreeId)>; +} + +fn read_tree_bytes( + be: &BE, + index: &I, + id: TreeId, +) -> RusticResult { + index + .get_tree(&id) + .ok_or_else(|| { + RusticError::new( + ErrorKind::Internal, + "Tree ID `{tree_id}` not found in index", + ) + .attach_context("tree_id", id.to_string()) + })? + .read_data(be) +} + +fn tree_json_error(err: serde_json::Error) -> Box { + RusticError::with_source( + ErrorKind::Internal, + "Failed to deserialize tree from JSON.", + err, + ) + .ask_report() +} + #[derive(Default, Serialize, Deserialize, Clone, Debug)] /// A [`Tree`] is a list of [`Node`]s pub struct Tree { @@ -152,27 +195,8 @@ impl Tree { index: &impl ReadGlobalIndex, id: TreeId, ) -> RusticResult { - let data = index - .get_tree(&id) - .ok_or_else(|| { - RusticError::new( - ErrorKind::Internal, - "Tree ID `{tree_id}` not found in index", - ) - .attach_context("tree_id", id.to_string()) - })? - .read_data(be)?; - - let tree = serde_json::from_slice(&data).map_err(|err| { - RusticError::with_source( - ErrorKind::Internal, - "Failed to deserialize tree from JSON.", - err, - ) - .ask_report() - })?; - - Ok(tree) + let data = read_tree_bytes(be, index, id)?; + serde_json::from_slice(&data).map_err(tree_json_error) } /// Creates a new node from a path. @@ -628,19 +652,61 @@ where } } +impl LoadedTree for Tree { + fn load( + be: &BE, + index: &I, + id: TreeId, + ) -> RusticResult { + Self::from_backend(be, index, id) + } + + fn child_trees(&self, parent: &Path) -> Vec<(PathBuf, TreeId)> { + self.nodes + .iter() + .filter_map(|node| { + let id = node.subtree?; + let mut path = parent.to_path_buf(); + path.push(node.name()); + Some((path, id)) + }) + .collect() + } +} + +impl LoadedTree for UsedBlobsTree { + fn load( + be: &BE, + index: &I, + id: TreeId, + ) -> RusticResult { + let data = read_tree_bytes(be, index, id)?; + used_blobs::parse_used_blobs_tree(&data).map_err(tree_json_error) + } + + fn child_trees(&self, _parent: &Path) -> Vec<(PathBuf, TreeId)> { + self.dir_trees + .iter() + .copied() + .map(|id| (PathBuf::new(), id)) + .collect() + } +} + /// [`TreeStreamerOnce`] recursively visits all trees and subtrees, but each tree ID only once /// /// # Type Parameters /// +/// * `T` - The loaded tree type. Defaults to a full [`Tree`]. /// * `P` - The progress indicator #[derive(Debug)] -pub struct TreeStreamerOnce { +pub struct TreeStreamer { /// The visited tree IDs visited: HashSet, /// The queue to send tree IDs to queue_in: Option>, /// The queue to receive trees from - queue_out: Receiver>, + queue_out: Receiver>, /// The progress indicator p: Progress, /// The number of trees that are not yet finished @@ -649,7 +715,10 @@ pub struct TreeStreamerOnce { finished_ids: usize, } -impl TreeStreamerOnce { +/// Recursively visits all trees and subtrees, but each tree ID only once. +pub type TreeStreamerOnce = TreeStreamer; + +impl TreeStreamer { /// Creates a new `TreeStreamerOnce`. /// /// # Type Parameters @@ -686,7 +755,7 @@ impl TreeStreamerOnce { let _join_handle = std::thread::spawn(move || { for (path, id, count) in in_rx { if out_tx - .send(Tree::from_backend(&be, &index, id).map(|tree| (path, tree, count))) + .send(T::load(&be, &index, id).map(|tree| (path, tree, count))) .is_err() { break; @@ -761,8 +830,8 @@ impl TreeStreamerOnce { } } -impl Iterator for TreeStreamerOnce { - type Item = TreeStreamItem; +impl Iterator for TreeStreamer { + type Item = RusticResult<(PathBuf, T)>; fn next(&mut self) -> Option { if self.counter.len() == self.finished_ids { @@ -785,25 +854,21 @@ impl Iterator for TreeStreamerOnce { Ok(Err(err)) => return Some(Err(err)), }; - for node in &tree.nodes { - if let Some(id) = node.subtree { - let mut path = path.clone(); - path.push(node.name()); - match self.add_pending(path.clone(), id, count) { - Ok(_) => {} - Err(err) => { - return Some(Err(err).map_err(|err| { - RusticError::with_source( - ErrorKind::Internal, - "Failed to add tree ID `{tree_id}` to pending queue (`{count}`).", - err, - ) - .attach_context("path", path.display().to_string()) - .attach_context("tree_id", id.to_string()) - .attach_context("count", count.to_string()) - .ask_report() - })); - } + for (child_path, id) in tree.child_trees(&path) { + match self.add_pending(child_path.clone(), id, count) { + Ok(_) => {} + Err(err) => { + return Some(Err(err).map_err(|err| { + RusticError::with_source( + ErrorKind::Internal, + "Failed to add tree ID `{tree_id}` to pending queue (`{count}`).", + err, + ) + .attach_context("path", child_path.display().to_string()) + .attach_context("tree_id", id.to_string()) + .attach_context("count", count.to_string()) + .ask_report() + })); } } } diff --git a/crates/core/src/blob/tree/used_blobs.rs b/crates/core/src/blob/tree/used_blobs.rs new file mode 100644 index 000000000..ededbccde --- /dev/null +++ b/crates/core/src/blob/tree/used_blobs.rs @@ -0,0 +1,213 @@ +//! Stream-decode restic trees for prune without materializing `Node`s. +//! +//! Prune only needs file content blob ids and directory subtree ids. Full +//! `Tree` deserialize also allocates names, metadata, and xattrs. + +use std::{borrow::Cow, fmt}; + +use serde::{ + Deserialize, Deserializer, + de::{self, DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}, +}; + +use crate::blob::{DataId, tree::TreeId}; + +/// Compact tree contents used by prune's used-blob walk. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct UsedBlobsTree { + pub file_blobs: Vec, + pub dir_trees: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum UsedBlobKind { + File, + Dir, + #[serde(other)] + Other, +} + +#[derive(Debug, Deserialize)] +struct UsedBlobNode { + #[serde(rename = "type")] + kind: UsedBlobKind, + #[serde(default)] + content: Option>, + #[serde(default)] + subtree: Option, +} + +impl<'de> Deserialize<'de> for UsedBlobsTree { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_map(UsedBlobsTreeVisitor) + } +} + +struct UsedBlobsTreeVisitor; + +impl<'de> Visitor<'de> for UsedBlobsTreeVisitor { + type Value = UsedBlobsTree; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a restic tree object") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut tree = UsedBlobsTree::default(); + while let Some(key) = map.next_key::>()? { + if key == "nodes" { + map.next_value_seed(NodesSeed(&mut tree))?; + } else { + let _: IgnoredAny = map.next_value()?; + } + } + Ok(tree) + } +} + +struct NodesSeed<'a>(&'a mut UsedBlobsTree); + +impl<'de> DeserializeSeed<'de> for NodesSeed<'_> { + type Value = (); + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for NodesSeed<'_> { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a nodes array or null") + } + + fn visit_unit(self) -> Result { + Ok(()) + } + + fn visit_none(self) -> Result { + Ok(()) + } + + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_seq(self) + } + + fn visit_seq>(self, mut seq: A) -> Result { + while let Some(node) = seq.next_element::()? { + match node.kind { + UsedBlobKind::File => { + if let Some(content) = node.content { + self.0.file_blobs.extend(content); + } + } + UsedBlobKind::Dir => { + if let Some(subtree) = node.subtree { + self.0.dir_trees.push(subtree); + } + } + UsedBlobKind::Other => {} + } + } + Ok(()) + } +} + +pub(crate) fn parse_used_blobs_tree(data: &[u8]) -> Result { + serde_json::from_slice(data) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::node::Node; + use crate::blob::tree::Tree; + + const FILE_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const TREE_ID: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + + #[test] + fn extracts_file_and_dir_ids_and_skips_the_rest() { + let json = format!( + r#"{{ + "nodes": [ + {{ + "name": "foo", + "type": "file", + "mtime": "2020-01-01T00:00:00+00:00", + "mode": 420, + "uid": 1000, + "user": "brad", + "inode": 1, + "size": 3, + "links": 1, + "extended_attributes": [{{"name": "user.foo", "value": "YQ=="}}], + "content": ["{FILE_ID}"] + }}, + {{ + "name": "bar", + "type": "dir", + "subtree": "{TREE_ID}" + }}, + {{ + "name": "link", + "type": "symlink", + "linktarget": "/tmp/x" + }} + ] + }}"# + ); + + let used = parse_used_blobs_tree(json.as_bytes()).unwrap(); + let full: Tree = serde_json::from_slice(json.as_bytes()).unwrap(); + + let full_files: Vec<_> = full + .nodes + .iter() + .filter(|n| matches!(n.node_type, crate::backend::node::NodeType::File)) + .flat_map(|n| n.content.iter().flatten().copied()) + .collect(); + let full_dirs: Vec<_> = full + .nodes + .iter() + .filter_map(|n| n.subtree) + .collect(); + + assert_eq!(used.file_blobs, full_files); + assert_eq!(used.dir_trees, full_dirs); + assert_eq!(used.file_blobs, vec![FILE_ID.parse::().unwrap()]); + assert_eq!(used.dir_trees, vec![TREE_ID.parse::().unwrap()]); + // Full deserialize kept metadata we did not allocate in the prune path. + let foo: &Node = &full.nodes[0]; + assert_eq!(foo.name, "foo"); + assert_eq!(foo.meta.extended_attributes.len(), 1); + } + + #[test] + fn null_or_missing_nodes_is_empty() { + assert_eq!( + parse_used_blobs_tree(br#"{"nodes":null}"#).unwrap(), + UsedBlobsTree::default() + ); + assert_eq!( + parse_used_blobs_tree(br#"{}"#).unwrap(), + UsedBlobsTree::default() + ); + assert_eq!( + parse_used_blobs_tree(br#"{"nodes":[]}"#).unwrap(), + UsedBlobsTree::default() + ); + } + + #[test] + fn ignores_unknown_tree_keys() { + let json = format!( + r#"{{"extra":1,"nodes":[{{"type":"file","content":["{FILE_ID}"]}}]}}"# + ); + let used = parse_used_blobs_tree(json.as_bytes()).unwrap(); + assert_eq!(used.file_blobs.len(), 1); + assert!(used.dir_trees.is_empty()); + } +} diff --git a/crates/core/src/commands/prune.rs b/crates/core/src/commands/prune.rs index 3229a76b3..96724ab80 100644 --- a/crates/core/src/commands/prune.rs +++ b/crates/core/src/commands/prune.rs @@ -22,12 +22,11 @@ use crate::{ backend::{ FileType, ReadBackend, decrypt::{DecryptReadBackend, DecryptWriteBackend}, - node::NodeType, }, blob::{ BlobId, BlobLocations, BlobType, BlobTypeMap, Initialize, packer::{BlobCopier, CopyPackBlobs, PackSizer}, - tree::TreeStreamerOnce, + tree::{TreeStreamer, UsedBlobsTree}, }, error::{ErrorKind, RusticError, RusticResult}, index::{ @@ -1608,25 +1607,11 @@ fn find_used_blobs( .collect(); let p = repo.progress_counter("finding used blobs..."); - let mut tree_streamer = TreeStreamerOnce::new(be, index, snap_trees, p)?; + let mut tree_streamer = TreeStreamer::::new(be, index, snap_trees, p)?; while let Some(item) = tree_streamer.next().transpose()? { - let (_, tree) = item; - for node in tree.nodes { - match node.node_type { - NodeType::File => { - ids.extend( - node.content - .iter() - .flatten() - .map(|id| (BlobId::from(**id), 0)), - ); - } - NodeType::Dir => { - _ = ids.insert(BlobId::from(*node.subtree.unwrap()), 0); - } - _ => {} // nothing to do - } - } + let (_, used) = item; + ids.extend(used.file_blobs.into_iter().map(|id| (BlobId::from(id), 0))); + ids.extend(used.dir_trees.into_iter().map(|id| (BlobId::from(id), 0))); } Ok(ids) From 721dc0345cef893361fd2a200a0ca8c2d4e558be Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 08:32:20 -0700 Subject: [PATCH 10/28] fix: walk unique trees depth-first TreeStreamer dumped every snapshot root into a FIFO, so loaders jumped across packs. Keep a LIFO backlog and only feed one job per loader so consecutive trees tend to hit the same cached packs, matching restic. --- crates/core/src/blob/tree.rs | 115 ++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/crates/core/src/blob/tree.rs b/crates/core/src/blob/tree.rs index 0d8f10fa7..b185ffe91 100644 --- a/crates/core/src/blob/tree.rs +++ b/crates/core/src/blob/tree.rs @@ -13,7 +13,7 @@ use std::{ str::{self, Utf8Error}, }; -use crossbeam_channel::{Receiver, Sender, bounded, unbounded}; +use crossbeam_channel::{Receiver, Sender, TrySendError, bounded}; use derive_setters::Setters; use ignore::Match; use ignore::overrides::Override; @@ -703,6 +703,8 @@ impl LoadedTree for UsedBlobsTree { pub struct TreeStreamer { /// The visited tree IDs visited: HashSet, + /// Depth-first backlog of tree IDs not yet sent to a loader. + backlog: Vec<(PathBuf, TreeId, usize)>, /// The queue to send tree IDs to queue_in: Option>, /// The queue to receive trees from @@ -745,7 +747,10 @@ impl TreeStreamer { let loaders = tree_loader_count(); let (out_tx, out_rx) = bounded(loaders.saturating_mul(4).max(32)); - let (in_tx, in_rx) = unbounded(); + // Bound the loader input so we do not dump every snapshot root at once. + // Combined with a LIFO backlog this keeps workers on recently discovered + // children (depth-first), which hits the same cached packs. + let (in_tx, in_rx) = bounded(loaders); for _ in 0..loaders { let be = be.clone(); @@ -767,6 +772,7 @@ impl TreeStreamer { let counter = vec![0; ids.len()]; let mut streamer = Self { visited: HashSet::new(), + backlog: Vec::new(), queue_in: Some(in_tx), queue_out: out_rx, p, @@ -775,58 +781,59 @@ impl TreeStreamer { }; for (count, id) in ids.into_iter().enumerate() { - if !streamer - .add_pending(PathBuf::new(), id, count) - .map_err(|err| { - RusticError::with_source( - ErrorKind::Internal, - "Failed to add tree ID `{tree_id}` to unbounded pending queue (`{count}`).", - err, - ) - .attach_context("tree_id", id.to_string()) - .attach_context("count", count.to_string()) - .ask_report() - })? - { + if !streamer.add_pending(PathBuf::new(), id, count) { streamer.p.inc(1); streamer.finished_ids += 1; } } + streamer.fill_queue().map_err(|err| { + RusticError::with_source( + ErrorKind::Internal, + "Failed to send tree IDs to loader queue.", + err, + ) + .ask_report() + })?; Ok(streamer) } - /// Adds a tree ID to the queue. - /// - /// # Arguments - /// - /// * `path` - The path of the tree. - /// * `id` - The ID of the tree. - /// * `count` - The index of the tree. + /// Pushes a tree ID onto the depth-first backlog if it has not been seen. /// /// # Returns /// - /// Whether the tree ID was added to the queue. - /// - /// # Errors - /// - /// * If sending the message fails. - fn add_pending(&mut self, path: PathBuf, id: TreeId, count: usize) -> TreeResult { + /// Whether the tree ID was added. + fn add_pending(&mut self, path: PathBuf, id: TreeId, count: usize) -> bool { if self.visited.insert(id) { - self.queue_in - .as_ref() - .unwrap() - .send((path, id, count)) - .map_err(|err| TreeErrorKind::Channel { - kind: "sending crossbeam message", - source: err.into(), - })?; - self.counter[count] += 1; - Ok(true) + self.backlog.push((path, id, count)); + true } else { - Ok(false) + false + } + } + + /// Sends backlog items to loaders, last-in first, until the input channel is full. + fn fill_queue(&mut self) -> TreeResult<()> { + let Some(tx) = self.queue_in.as_ref() else { + return Ok(()); + }; + while let Some(job) = self.backlog.pop() { + match tx.try_send(job) { + Ok(()) => {} + Err(TrySendError::Full(job)) => { + self.backlog.push(job); + break; + } + Err(TrySendError::Disconnected(_)) => { + return Err(TreeErrorKind::Channel { + kind: "sending crossbeam message", + source: "loader queue disconnected".into(), + }); + } + } } + Ok(()) } } @@ -854,23 +861,19 @@ impl Iterator for TreeStreamer { Ok(Err(err)) => return Some(Err(err)), }; - for (child_path, id) in tree.child_trees(&path) { - match self.add_pending(child_path.clone(), id, count) { - Ok(_) => {} - Err(err) => { - return Some(Err(err).map_err(|err| { - RusticError::with_source( - ErrorKind::Internal, - "Failed to add tree ID `{tree_id}` to pending queue (`{count}`).", - err, - ) - .attach_context("path", child_path.display().to_string()) - .attach_context("tree_id", id.to_string()) - .attach_context("count", count.to_string()) - .ask_report() - })); - } - } + // Push children last-to-first so the next pop is the first child (DFS). + for (child_path, id) in tree.child_trees(&path).into_iter().rev() { + _ = self.add_pending(child_path, id, count); + } + if let Err(err) = self.fill_queue() { + return Some(Err(err).map_err(|err| { + RusticError::with_source( + ErrorKind::Internal, + "Failed to send tree IDs to loader queue.", + err, + ) + .ask_report() + })); } self.counter[count] -= 1; From c65570c63dca7af74532f1489481673bc4dc7841 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 08:44:34 -0700 Subject: [PATCH 11/28] fix: list pack files during prune index and tree walk getting packs was a serial B2 prefix list after finding used blobs. Start that list in the background so it finishes during the tree walk. --- crates/core/src/commands/prune.rs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/core/src/commands/prune.rs b/crates/core/src/commands/prune.rs index 96724ab80..00644bc5e 100644 --- a/crates/core/src/commands/prune.rs +++ b/crates/core/src/commands/prune.rs @@ -703,6 +703,13 @@ impl PrunePlan { let mut index_files = Vec::new(); + // Pack listing is a full B2 prefix walk and does not depend on the + // index or used-blob set. Run it while we read the index and trees. + let pack_list = { + let be = be.clone(); + std::thread::spawn(move || be.list_with_size(FileType::Pack)) + }; + let p = repo.progress_counter("reading index..."); let mut index_collector = IndexCollector::new(IndexType::OnlyTrees); @@ -724,13 +731,20 @@ impl PrunePlan { (used_ids, total_size) }; - // list existing pack files + // list existing pack files (started before index/tree work) let p = repo.progress_spinner("getting packs from repository..."); - let existing_packs: BTreeMap<_, _> = be - .list_with_size(FileType::Pack)? - .into_iter() - .map(|(id, size)| (PackId::from(id), size)) - .collect(); + let existing_packs: BTreeMap<_, _> = match pack_list.join() { + Ok(listed) => listed? + .into_iter() + .map(|(id, size)| (PackId::from(id), size)) + .collect(), + Err(_) => { + return Err(RusticError::new( + ErrorKind::Internal, + "Pack listing thread panicked.", + )); + } + }; p.finish(); let mut pruner = Self::new(used_ids, existing_packs, index_files); From 5e7996f9f31441355579457967ed08217417398e Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 09:20:34 -0700 Subject: [PATCH 12/28] fix: start prune pack listing after the index is loaded Overlapping the B2 pack list with index GETs made reading index... take minutes on a cold-ish server cache. List packs only during the tree walk so getting packs still hides without stalling the index. --- crates/core/src/commands/prune.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/core/src/commands/prune.rs b/crates/core/src/commands/prune.rs index 00644bc5e..97510533a 100644 --- a/crates/core/src/commands/prune.rs +++ b/crates/core/src/commands/prune.rs @@ -703,13 +703,6 @@ impl PrunePlan { let mut index_files = Vec::new(); - // Pack listing is a full B2 prefix walk and does not depend on the - // index or used-blob set. Run it while we read the index and trees. - let pack_list = { - let be = be.clone(); - std::thread::spawn(move || be.list_with_size(FileType::Pack)) - }; - let p = repo.progress_counter("reading index..."); let mut index_collector = IndexCollector::new(IndexType::OnlyTrees); @@ -724,6 +717,14 @@ impl PrunePlan { } p.finish(); + // Pack listing does not need the used-blob set. Start it after the + // index so it does not steal B2 from index GETs, and overlap it with + // the tree walk instead. + let pack_list = { + let be = be.clone(); + std::thread::spawn(move || be.list_with_size(FileType::Pack)) + }; + let (used_ids, total_size) = { let index = GlobalIndex::new_from_index(index_collector.into_index()); let total_size = BlobTypeMap::init(|blob_type| index.total_size(blob_type)); @@ -731,7 +732,7 @@ impl PrunePlan { (used_ids, total_size) }; - // list existing pack files (started before index/tree work) + // list existing pack files (started before the tree walk) let p = repo.progress_spinner("getting packs from repository..."); let existing_packs: BTreeMap<_, _> = match pack_list.join() { Ok(listed) => listed? From b664070df40c461a292440e06d72b99ac1f268ca Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 09:42:23 -0700 Subject: [PATCH 13/28] fix: use fewer stream_list workers on a warm cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16–32 parallel index readers help B2 RTTs but on a warm local cache they thrash disk. Use 2–4 workers when at least 75% of listed index/snapshot files already exist in the cache. --- crates/core/src/backend.rs | 12 ++++++++++++ crates/core/src/backend/cache.rs | 17 +++++++++++++++++ crates/core/src/backend/decrypt.rs | 10 +++++++--- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/core/src/backend.rs b/crates/core/src/backend.rs index b1afb4d46..7fe299aa5 100644 --- a/crates/core/src/backend.rs +++ b/crates/core/src/backend.rs @@ -15,6 +15,7 @@ use std::{io::Read, ops::Deref, path::PathBuf, sync::Arc}; use bytes::{Buf, Bytes, buf::Reader}; use enum_map::Enum; use log::trace; +use rayon::current_num_threads; #[cfg(test)] use mockall::mock; @@ -105,6 +106,14 @@ pub trait ReadBackend: Send + Sync + 'static { /// * If the files could not be listed. fn list_with_size(&self, tpe: FileType) -> RusticResult>; + /// How many parallel readers to use for `stream_list` of these files. + /// + /// Remote backends keep extra workers for B2 RTTs. Cached backends use a + /// few workers when the files are already on disk so we do not thrash HDD. + fn prefetch_workers(&self, _tpe: FileType, _ids: &[Id]) -> usize { + (current_num_threads() + 16).clamp(16, 32) + } + /// Lists all files of the given type. /// /// # Arguments @@ -448,6 +457,9 @@ impl ReadBackend for Arc { fn list_with_size(&self, tpe: FileType) -> RusticResult> { self.deref().list_with_size(tpe) } + fn prefetch_workers(&self, tpe: FileType, ids: &[Id]) -> usize { + self.deref().prefetch_workers(tpe, ids) + } fn list(&self, tpe: FileType) -> RusticResult> { self.deref().list(tpe) } diff --git a/crates/core/src/backend/cache.rs b/crates/core/src/backend/cache.rs index cadc3c954..59e75eeee 100644 --- a/crates/core/src/backend/cache.rs +++ b/crates/core/src/backend/cache.rs @@ -89,6 +89,23 @@ impl ReadBackend for CachedBackend { self.be.location() } + fn prefetch_workers(&self, tpe: FileType, ids: &[Id]) -> usize { + if ids.is_empty() { + return 1; + } + let hits = ids + .iter() + .filter(|id| self.cache.path(tpe, id).is_file()) + .count(); + // Warm cache: few readers so we do not thrash local disk. Cold cache: + // extra workers for high-latency GETs. + if hits.saturating_mul(4) >= ids.len().saturating_mul(3) { + rayon::current_num_threads().clamp(2, 4) + } else { + (rayon::current_num_threads() + 16).clamp(16, 32) + } + } + /// Lists all files with their size of the given type. /// /// # Arguments diff --git a/crates/core/src/backend/decrypt.rs b/crates/core/src/backend/decrypt.rs index bdc1ec25c..71276d2ff 100644 --- a/crates/core/src/backend/decrypt.rs +++ b/crates/core/src/backend/decrypt.rs @@ -191,9 +191,9 @@ pub trait DecryptReadBackend: ReadBackend + Clone + 'static { fn stream_list(&self, list: Vec, p: &Progress) -> StreamResult { p.set_length(list.len() as u64); // Index/snapshot files are small; on B2 this is RTT-bound (one GET each). - // Restic uses `connections + GOMAXPROCS`. Keep extra workers so some can - // GET while others decrypt/parse, and buffer so send does not stall IO. - let workers = (rayon::current_num_threads() + 16).clamp(16, 32); + // Cached backends report fewer workers when the files are already local. + let ids: Vec<_> = list.iter().map(|id| **id).collect(); + let workers = self.prefetch_workers(F::TYPE, &ids); let (tx, rx) = bounded(workers.saturating_mul(2)); let be = self.clone(); let p = p.clone(); @@ -652,6 +652,10 @@ impl ReadBackend for DecryptBackend { self.be.list_with_size(tpe) } + fn prefetch_workers(&self, tpe: FileType, ids: &[Id]) -> usize { + self.be.prefetch_workers(tpe, ids) + } + fn read_full(&self, tpe: FileType, id: &Id) -> RusticResult { self.be.read_full(tpe, id) } From 188e5bc143b35f167fe6defcd3abd6004c3683ba Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 11:15:10 -0700 Subject: [PATCH 14/28] fix: use fewer tree loaders when pack files are cached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 32 loaders on a 16-vCPU QEMU host spent most of finding used blobs... in KVM PV spinlocks and musl malloc. Use 4–8 loaders when the cache already has pack files; keep 2×CPUs (8–32) for a cold remote cache. --- crates/core/src/backend.rs | 11 +++++++++++ crates/core/src/backend/cache.rs | 20 ++++++++++++++++++++ crates/core/src/backend/decrypt.rs | 4 ++++ crates/core/src/blob/tree.rs | 20 +------------------- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/crates/core/src/backend.rs b/crates/core/src/backend.rs index 7fe299aa5..5a95adb9d 100644 --- a/crates/core/src/backend.rs +++ b/crates/core/src/backend.rs @@ -114,6 +114,14 @@ pub trait ReadBackend: Send + Sync + 'static { (current_num_threads() + 16).clamp(16, 32) } + /// How many threads to spawn to load trees (`TreeStreamer`). + /// + /// Remote backends use `2 × CPUs` (8–32) so prune is not RTT-bound on B2. + /// Cached backends use fewer when pack files are already on disk. + fn tree_loader_count(&self) -> usize { + current_num_threads().saturating_mul(2).clamp(8, 32) + } + /// Lists all files of the given type. /// /// # Arguments @@ -460,6 +468,9 @@ impl ReadBackend for Arc { fn prefetch_workers(&self, tpe: FileType, ids: &[Id]) -> usize { self.deref().prefetch_workers(tpe, ids) } + fn tree_loader_count(&self) -> usize { + self.deref().tree_loader_count() + } fn list(&self, tpe: FileType) -> RusticResult> { self.deref().list(tpe) } diff --git a/crates/core/src/backend/cache.rs b/crates/core/src/backend/cache.rs index 59e75eeee..a4b21e2a3 100644 --- a/crates/core/src/backend/cache.rs +++ b/crates/core/src/backend/cache.rs @@ -106,6 +106,17 @@ impl ReadBackend for CachedBackend { } } + fn tree_loader_count(&self) -> usize { + // Warm pack cache: local preads. 2×CPUs loaders oversubscribe QEMU + // vCPUs (KVM PV spinlocks + musl malloc). Cold cache: keep extra + // loaders for B2 RTTs. + if self.cache.has_cached_packs() { + rayon::current_num_threads().clamp(4, 8) + } else { + self.be.tree_loader_count() + } + } + /// Lists all files with their size of the given type. /// /// # Arguments @@ -398,6 +409,15 @@ impl Cache { }) } + /// True if at least one pack file is already in this cache. + #[must_use] + pub fn has_cached_packs(&self) -> bool { + WalkDir::new(self.path.join(FileType::Pack.dirname())) + .into_iter() + .filter_map(Result::ok) + .any(|e| e.file_type().is_file()) + } + /// Returns the path to the location of this [`Cache`]. /// /// # Panics diff --git a/crates/core/src/backend/decrypt.rs b/crates/core/src/backend/decrypt.rs index 71276d2ff..bd6474313 100644 --- a/crates/core/src/backend/decrypt.rs +++ b/crates/core/src/backend/decrypt.rs @@ -656,6 +656,10 @@ impl ReadBackend for DecryptBackend { self.be.prefetch_workers(tpe, ids) } + fn tree_loader_count(&self) -> usize { + self.be.tree_loader_count() + } + fn read_full(&self, tpe: FileType, id: &Id) -> RusticResult { self.be.read_full(tpe, id) } diff --git a/crates/core/src/blob/tree.rs b/crates/core/src/blob/tree.rs index b185ffe91..bb99c57ad 100644 --- a/crates/core/src/blob/tree.rs +++ b/crates/core/src/blob/tree.rs @@ -17,7 +17,6 @@ use crossbeam_channel::{Receiver, Sender, TrySendError, bounded}; use derive_setters::Setters; use ignore::Match; use ignore::overrides::Override; -use rayon::current_num_threads; use serde::{Deserialize, Deserializer}; use serde_derive::Serialize; @@ -54,23 +53,6 @@ pub enum TreeErrorKind { pub(crate) type TreeResult = Result; -pub(super) mod constants { - /// Minimum / maximum tree-loader threads for `TreeStreamerOnce`. - /// - /// Four was too few on high-latency backends (B2): prune's "finding used - /// blobs..." walks every unique tree with a pack range GET. Restic uses - /// `connections + GOMAXPROCS` workers. We scale with Rayon (2× CPUs, - /// clamped) so a 4-core box gets 8 loaders, not 4. - pub(super) const MIN_TREE_LOADER: usize = 8; - pub(super) const MAX_TREE_LOADER: usize = 32; -} - -fn tree_loader_count() -> usize { - current_num_threads() - .saturating_mul(2) - .clamp(constants::MIN_TREE_LOADER, constants::MAX_TREE_LOADER) -} - type NodeStreamItem = RusticResult<(PathBuf, Node)>; impl_blobid!(TreeId, BlobType::Tree); @@ -745,7 +727,7 @@ impl TreeStreamer { ) -> RusticResult { p.set_length(ids.len() as u64); - let loaders = tree_loader_count(); + let loaders = be.tree_loader_count(); let (out_tx, out_rx) = bounded(loaders.saturating_mul(4).max(32)); // Bound the loader input so we do not dump every snapshot root at once. // Combined with a LIFO backlog this keeps workers on recently discovered From 4ef023f4da125db71ee7ea8131710361db47b9ab Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Sun, 6 Sep 2026 15:00:14 -0700 Subject: [PATCH 15/28] fix: build cache test payloads without lossy int casts `i as u8` trips clippy::cast_possible_truncation and cast_sign_loss, which CI runs with -D warnings. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TdgDzcDaQVR4uqEPMDKZsu --- crates/core/src/backend/cache.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/backend/cache.rs b/crates/core/src/backend/cache.rs index a4b21e2a3..075d857a8 100644 --- a/crates/core/src/backend/cache.rs +++ b/crates/core/src/backend/cache.rs @@ -802,7 +802,7 @@ mod tests { fn read_partial_reuses_open_file() { let (_dir, cache) = new_cache(); let id = Id::random(); - let payload: Vec = (0..4096).map(|i| i as u8).collect(); + let payload: Vec = (0..=u8::MAX).cycle().take(4096).collect(); cache .write_bytes(FileType::Pack, &id, &payload.clone().into()) .unwrap(); @@ -868,7 +868,7 @@ mod tests { fn concurrent_partial_reads() { let (_dir, cache) = new_cache(); let id = Id::random(); - let payload: Vec = (0..8192).map(|i| (i % 251) as u8).collect(); + let payload: Vec = (0..=250_u8).cycle().take(8192).collect(); cache .write_bytes(FileType::Pack, &id, &payload.clone().into()) .unwrap(); From 34d586c0d10ace17fdca9d808c92a3d3f36925d3 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Sun, 6 Sep 2026 15:12:00 -0700 Subject: [PATCH 16/28] fix: bound cached handles and synchronize portable range reads --- crates/core/Cargo.toml | 2 +- crates/core/src/backend/cache.rs | 138 +++++++++++++++++++----- crates/core/src/blob/tree/used_blobs.rs | 10 +- 3 files changed, 117 insertions(+), 33 deletions(-) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 691bfb2fb..f0dcce620 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -75,7 +75,7 @@ cached = { version = "2.0.2", default-features = false, features = ["proc_macro" dunce = "1.0.5" filetime = "0.2.27" ignore = "0.4.25" -nix = { version = "0.31.1", default-features = false, features = ["user", "fs"] } +nix = { version = "0.31.1", default-features = false, features = ["user", "fs", "resource"] } path-dedot = "4.0.1" walkdir = "2.5.0" diff --git a/crates/core/src/backend/cache.rs b/crates/core/src/backend/cache.rs index 075d857a8..bd5e0d985 100644 --- a/crates/core/src/backend/cache.rs +++ b/crates/core/src/backend/cache.rs @@ -28,7 +28,65 @@ mod constants { pub(super) const OPEN_FILE_CAPACITY: usize = 2048; } -type OpenFileCache = quick_cache::sync::Cache>; +type OpenFileCache = quick_cache::sync::Cache>; + +/// Keep most descriptors available for backend connections and other I/O. +fn open_file_capacity() -> usize { + #[cfg(unix)] + if let Ok((soft, _)) = + nix::sys::resource::getrlimit(nix::sys::resource::Resource::RLIMIT_NOFILE) + { + return usize::try_from(soft / 8) + .unwrap_or(usize::MAX) + .min(constants::OPEN_FILE_CAPACITY); + } + // Conservative fallback on platforms without a queryable descriptor limit. + 32 +} + +struct CachedFile { + file: File, + #[cfg(any(test, not(unix)))] + seek_lock: std::sync::Mutex<()>, +} + +impl CachedFile { + fn new(file: File) -> Self { + Self { + file, + #[cfg(any(test, not(unix)))] + seek_lock: std::sync::Mutex::new(()), + } + } + + fn read_range(&self, offset: u32, length: u32) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + let mut vec = vec![0; length as usize]; + self.file.read_exact_at(&mut vec, u64::from(offset))?; + Ok(vec.into()) + } + #[cfg(not(unix))] + self.read_range_seeking(offset, length) + } + + /// Cloned file handles share a cursor. Serialize the entire seek/read pair + /// on platforms without positional reads; never clone the cached handle. + #[cfg(any(test, not(unix)))] + fn read_range_seeking(&self, offset: u32, length: u32) -> io::Result { + use std::io::{Seek, SeekFrom}; + let _guard = self + .seek_lock + .lock() + .map_err(|_| io::Error::other("cache seek lock poisoned"))?; + let mut file = &self.file; + let mut vec = vec![0; length as usize]; + _ = file.seek(SeekFrom::Start(u64::from(offset)))?; + file.read_exact(&mut vec)?; + Ok(vec.into()) + } +} fn is_too_many_open_files(err: &io::Error) -> bool { #[cfg(unix)] @@ -405,7 +463,7 @@ impl Cache { Ok(Self { path, - open_files: Arc::new(OpenFileCache::new(constants::OPEN_FILE_CAPACITY)), + open_files: Arc::new(OpenFileCache::new(open_file_capacity())), }) } @@ -588,7 +646,7 @@ impl Cache { trace!("cache reading tpe: {tpe:?}, id: {id}, offset: {offset}"); if let Some(file) = self.open_files.get(id) { - match Self::read_range(&file, offset, length) { + match file.read_range(offset, length) { Ok(data) => { trace!("cache hit!"); return Ok(Some(data)); @@ -616,7 +674,7 @@ impl Cache { } }; - let data = Self::read_range(&file, offset, length).map_err(|err| { + let data = file.read_range(offset, length).map_err(|err| { RusticError::with_source( ErrorKind::InputOutput, "Failed to read at offset `{offset}` from file at `{path}`", @@ -634,7 +692,7 @@ impl Cache { Ok(Some(data)) } - fn open_cached(&self, id: &Id, path: &Path) -> io::Result>> { + fn open_cached(&self, id: &Id, path: &Path) -> io::Result>> { if let Some(file) = self.open_files.get(id) { return Ok(Some(file)); } @@ -654,29 +712,12 @@ impl Cache { } } - fn remember_open(&self, id: &Id, file: File) -> Arc { - let file = Arc::new(file); + fn remember_open(&self, id: &Id, file: File) -> Arc { + let file = Arc::new(CachedFile::new(file)); self.open_files.insert(*id, file.clone()); file } - fn read_range(file: &File, offset: u32, length: u32) -> io::Result { - let mut vec = vec![0; length as usize]; - #[cfg(unix)] - { - use std::os::unix::fs::FileExt; - file.read_exact_at(&mut vec, u64::from(offset))?; - } - #[cfg(not(unix))] - { - use std::io::{Seek, SeekFrom}; - let mut file = file.try_clone()?; - file.seek(SeekFrom::Start(u64::from(offset)))?; - file.read_exact(&mut vec)?; - } - Ok(vec.into()) - } - /// Writes the given data to the given file. /// /// # Arguments @@ -885,10 +926,59 @@ mod tests { .unwrap(); let start = usize::try_from(offset).unwrap(); assert_eq!(got.as_ref(), &payload[start..start + 16]); + // Exercise the non-Unix implementation on every test platform. + let file = cache.open_files.get(&id).unwrap(); + let got = file.read_range_seeking(offset, 16).unwrap(); + assert_eq!(got.as_ref(), &payload[start..start + 16]); } }); } }); assert_eq!(cache.open_files.len(), 1); } + + #[cfg(unix)] + #[test] + fn cache_respects_file_descriptor_limit() { + use nix::sys::resource::{Resource, getrlimit, setrlimit}; + + const CHILD: &str = "RUSTIC_CACHE_FD_LIMIT_TEST"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "backend::cache::tests::cache_respects_file_descriptor_limit", + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let (_, hard) = getrlimit(Resource::RLIMIT_NOFILE).unwrap(); + setrlimit(Resource::RLIMIT_NOFILE, 64.min(hard), hard).unwrap(); + let (_dir, cache) = new_cache(); + let ids: Vec<_> = (0..100).map(|_| Id::random()).collect(); + for id in &ids { + cache + .write_bytes(FileType::Pack, id, &vec![0_u8; 16].into()) + .unwrap(); + } + for id in &ids { + _ = cache + .read_partial(FileType::Pack, id, 0, 4) + .unwrap() + .unwrap(); + // Other backend/file operations must still have descriptor headroom. + let _other_files: Vec<_> = (0..16).map(|_| File::open("/dev/null").unwrap()).collect(); + } + } } diff --git a/crates/core/src/blob/tree/used_blobs.rs b/crates/core/src/blob/tree/used_blobs.rs index ededbccde..f638824c4 100644 --- a/crates/core/src/blob/tree/used_blobs.rs +++ b/crates/core/src/blob/tree/used_blobs.rs @@ -169,11 +169,7 @@ mod tests { .filter(|n| matches!(n.node_type, crate::backend::node::NodeType::File)) .flat_map(|n| n.content.iter().flatten().copied()) .collect(); - let full_dirs: Vec<_> = full - .nodes - .iter() - .filter_map(|n| n.subtree) - .collect(); + let full_dirs: Vec<_> = full.nodes.iter().filter_map(|n| n.subtree).collect(); assert_eq!(used.file_blobs, full_files); assert_eq!(used.dir_trees, full_dirs); @@ -203,9 +199,7 @@ mod tests { #[test] fn ignores_unknown_tree_keys() { - let json = format!( - r#"{{"extra":1,"nodes":[{{"type":"file","content":["{FILE_ID}"]}}]}}"# - ); + let json = format!(r#"{{"extra":1,"nodes":[{{"type":"file","content":["{FILE_ID}"]}}]}}"#); let used = parse_used_blobs_tree(json.as_bytes()).unwrap(); assert_eq!(used.file_blobs.len(), 1); assert!(used.dir_trees.is_empty()); From 3631eabce22a976872f7f2826c3aa11b50bb2c09 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Sun, 6 Sep 2026 16:39:03 -0700 Subject: [PATCH 17/28] fix: size cached pack handles from rlimit minus a reserve rlimit/8 mapped Darwin ulimit 8192 to 1024 open pack files and added about 5s to prune getting-packs. Keep the 2048 cap and a 64-FD reserve. --- crates/core/src/backend/cache.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/core/src/backend/cache.rs b/crates/core/src/backend/cache.rs index bd5e0d985..be58b284f 100644 --- a/crates/core/src/backend/cache.rs +++ b/crates/core/src/backend/cache.rs @@ -26,24 +26,33 @@ mod constants { /// Tree walking reads many blobs from the same packs. Opening the cache /// file per blob was ~65% of prune CPU in a Time Profiler trace. pub(super) const OPEN_FILE_CAPACITY: usize = 2048; + /// Descriptors left for sockets, index files, and other I/O. + pub(super) const OPEN_FILE_RESERVE: u64 = 64; } type OpenFileCache = quick_cache::sync::Cache>; -/// Keep most descriptors available for backend connections and other I/O. +/// Use up to [`constants::OPEN_FILE_CAPACITY`] cached pack FDs, leaving +/// [`constants::OPEN_FILE_RESERVE`] for other I/O. `rlimit/8` mapped a Darwin +/// 8192 soft limit to 1024 handles. fn open_file_capacity() -> usize { #[cfg(unix)] if let Ok((soft, _)) = nix::sys::resource::getrlimit(nix::sys::resource::Resource::RLIMIT_NOFILE) { - return usize::try_from(soft / 8) - .unwrap_or(usize::MAX) - .min(constants::OPEN_FILE_CAPACITY); + return open_file_capacity_from_soft_limit(soft); } // Conservative fallback on platforms without a queryable descriptor limit. 32 } +fn open_file_capacity_from_soft_limit(soft: u64) -> usize { + usize::try_from(soft.saturating_sub(constants::OPEN_FILE_RESERVE)) + .unwrap_or(usize::MAX) + .min(constants::OPEN_FILE_CAPACITY) + .max(1) +} + struct CachedFile { file: File, #[cfg(any(test, not(unix)))] @@ -937,6 +946,14 @@ mod tests { assert_eq!(cache.open_files.len(), 1); } + #[test] + fn open_file_capacity_uses_reserve_not_an_eighth() { + assert_eq!(open_file_capacity_from_soft_limit(8192), 2048); + assert_eq!(open_file_capacity_from_soft_limit(1024), 960); + assert_eq!(open_file_capacity_from_soft_limit(64), 1); + assert_eq!(open_file_capacity_from_soft_limit(0), 1); + } + #[cfg(unix)] #[test] fn cache_respects_file_descriptor_limit() { From a9716cd3ae4aa7f94b400396ad8a803eae3a0891 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Sun, 6 Sep 2026 17:51:33 -0700 Subject: [PATCH 18/28] fix: convert rlimit to u64 for 32-bit prune cache cap nix getrlimit returns rlim_t, which is u32 on armv7. --- crates/core/src/backend/cache.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/backend/cache.rs b/crates/core/src/backend/cache.rs index be58b284f..3a131b527 100644 --- a/crates/core/src/backend/cache.rs +++ b/crates/core/src/backend/cache.rs @@ -46,8 +46,8 @@ fn open_file_capacity() -> usize { 32 } -fn open_file_capacity_from_soft_limit(soft: u64) -> usize { - usize::try_from(soft.saturating_sub(constants::OPEN_FILE_RESERVE)) +fn open_file_capacity_from_soft_limit(soft: impl Into) -> usize { + usize::try_from(soft.into().saturating_sub(constants::OPEN_FILE_RESERVE)) .unwrap_or(usize::MAX) .min(constants::OPEN_FILE_CAPACITY) .max(1) From be686434f25341338d667b752d3ba2be3da64618 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 13:08:47 -0700 Subject: [PATCH 19/28] fix: reuse a zstd decompressor per thread for blob reads decode_all created a DCtx for every compressed tree blob. During prune finding used blobs that showed up as ZSTD_createDCtx, munmap, and ~60k page faults/s. Keep one bulk Decompressor in thread-local storage. --- crates/core/src/backend/decrypt.rs | 44 ++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/core/src/backend/decrypt.rs b/crates/core/src/backend/decrypt.rs index bd6474313..711e28bf8 100644 --- a/crates/core/src/backend/decrypt.rs +++ b/crates/core/src/backend/decrypt.rs @@ -1,10 +1,44 @@ -use std::{num::NonZeroU32, sync::Arc}; +use std::{cell::RefCell, num::NonZeroU32, sync::Arc}; use bytes::Bytes; use crossbeam_channel::{Receiver, bounded}; use rayon::{prelude::*, spawn}; use zstd::stream::{copy_encode, decode_all, encode_all}; +/// Decode zstd with a decompressor kept on this thread. +/// +/// `zstd::decode_all` builds a new `DCtx` per call. Tree walking does that for +/// every blob and showed up as `ZSTD_createDCtx` / `munmap` / page faults. +fn zstd_decompress(data: &[u8], uncompressed_len: usize) -> RusticResult> { + thread_local! { + static DECOMPRESSOR: RefCell>> = + const { RefCell::new(None) }; + } + + DECOMPRESSOR.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + *slot = Some(zstd::bulk::Decompressor::new().map_err(|err| { + RusticError::with_source( + ErrorKind::Internal, + "Failed to create zstd decompressor.", + err, + ) + })?); + } + slot.as_mut() + .expect("zstd decompressor is initialized") + .decompress(data, uncompressed_len) + .map_err(|err| { + RusticError::with_source( + ErrorKind::Internal, + "Failed to decode zstd compressed data. The data may be corrupted.", + err, + ) + }) + }) +} + pub use zstd::compression_level_range; use crate::{ @@ -75,13 +109,7 @@ pub trait DecryptReadBackend: ReadBackend + Clone + 'static { ) -> RusticResult { let mut data = self.decrypt(data)?; if let Some(length) = uncompressed_length { - data = decode_all(&*data).map_err(|err| { - RusticError::with_source( - ErrorKind::Internal, - "Failed to decode zstd compressed data. The data may be corrupted.", - err, - ) - })?; + data = zstd_decompress(&data, length.get() as usize)?; if data.len() != length.get() as usize { return Err(RusticError::new( From 7961821f57c7372185b3504a53de9597e879faf1 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 13:33:55 -0700 Subject: [PATCH 20/28] fix: decode prune tree blob ids with a hex nibble table hex::FromHex<&str> was ~6% of finding used blobs after zstd reuse. Parse the 64-char ids with a lookup table during UsedBlobsTree JSON decode instead. --- crates/core/src/blob/tree/used_blobs.rs | 188 +++++++++++++++++++++++- 1 file changed, 181 insertions(+), 7 deletions(-) diff --git a/crates/core/src/blob/tree/used_blobs.rs b/crates/core/src/blob/tree/used_blobs.rs index f638824c4..2ae9496ac 100644 --- a/crates/core/src/blob/tree/used_blobs.rs +++ b/crates/core/src/blob/tree/used_blobs.rs @@ -10,7 +10,168 @@ use serde::{ de::{self, DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}, }; -use crate::blob::{DataId, tree::TreeId}; +use crate::{ + Id, + blob::{DataId, tree::TreeId}, +}; + +/// Nibble lookup: 0–15 for hex digits, `0xFF` otherwise. +const fn hex_nibble_table() -> [u8; 256] { + let mut t = [0xff_u8; 256]; + let mut i: u8 = 0; + while i < 10 { + t[b'0' as usize + i as usize] = i; + i += 1; + } + i = 0; + while i < 6 { + t[b'a' as usize + i as usize] = 10 + i; + t[b'A' as usize + i as usize] = 10 + i; + i += 1; + } + t +} + +const HEX_NIBBLE: [u8; 256] = hex_nibble_table(); + +#[inline] +fn decode_hex32(src: &[u8]) -> Option<[u8; 32]> { + if src.len() != 64 { + return None; + } + let mut out = [0_u8; 32]; + let mut i = 0; + while i < 32 { + let hi = HEX_NIBBLE[src[i * 2] as usize]; + let lo = HEX_NIBBLE[src[i * 2 + 1] as usize]; + if (hi | lo) == 0xff { + return None; + } + out[i] = (hi << 4) | lo; + i += 1; + } + Some(out) +} + +fn parse_hex_id(s: &str) -> Option { + decode_hex32(s.as_bytes()).map(Id::new) +} + +struct HexDataIdVisitor; + +impl Visitor<'_> for HexDataIdVisitor { + type Value = DataId; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a 64-character hex blob id") + } + + fn visit_str(self, v: &str) -> Result { + parse_hex_id(v) + .map(DataId::from) + .ok_or_else(|| E::invalid_value(de::Unexpected::Str(v), &self)) + } +} + +struct HexTreeIdVisitor; + +impl Visitor<'_> for HexTreeIdVisitor { + type Value = TreeId; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a 64-character hex tree id") + } + + fn visit_str(self, v: &str) -> Result { + parse_hex_id(v) + .map(TreeId::from) + .ok_or_else(|| E::invalid_value(de::Unexpected::Str(v), &self)) + } +} + +fn deserialize_data_ids<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct SeqVisitor; + + impl<'de> Visitor<'de> for SeqVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an array of hex blob ids") + } + + fn visit_unit(self) -> Result { + Ok(Vec::new()) + } + + fn visit_none(self) -> Result { + Ok(Vec::new()) + } + + fn visit_some>( + self, + deserializer: D, + ) -> Result { + deserializer.deserialize_seq(self) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(id) = seq.next_element_seed(HexDataIdSeed)? { + out.push(id); + } + Ok(out) + } + } + + deserializer.deserialize_any(SeqVisitor) +} + +struct HexDataIdSeed; + +impl<'de> DeserializeSeed<'de> for HexDataIdSeed { + type Value = DataId; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_str(HexDataIdVisitor) + } +} + +fn deserialize_opt_tree_id<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct OptVisitor; + + impl<'de> Visitor<'de> for OptVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a hex tree id or null") + } + + fn visit_unit(self) -> Result { + Ok(None) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_some>( + self, + deserializer: D, + ) -> Result { + deserializer.deserialize_str(HexTreeIdVisitor).map(Some) + } + + fn visit_str(self, v: &str) -> Result { + HexTreeIdVisitor.visit_str(v).map(Some) + } + } + + deserializer.deserialize_any(OptVisitor) +} /// Compact tree contents used by prune's used-blob walk. #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -32,9 +193,9 @@ enum UsedBlobKind { struct UsedBlobNode { #[serde(rename = "type")] kind: UsedBlobKind, - #[serde(default)] - content: Option>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_data_ids")] + content: Vec, + #[serde(default, deserialize_with = "deserialize_opt_tree_id")] subtree: Option, } @@ -99,9 +260,7 @@ impl<'de> Visitor<'de> for NodesSeed<'_> { while let Some(node) = seq.next_element::()? { match node.kind { UsedBlobKind::File => { - if let Some(content) = node.content { - self.0.file_blobs.extend(content); - } + self.0.file_blobs.extend(node.content); } UsedBlobKind::Dir => { if let Some(subtree) = node.subtree { @@ -204,4 +363,19 @@ mod tests { assert_eq!(used.file_blobs.len(), 1); assert!(used.dir_trees.is_empty()); } + + #[test] + fn hex_ids_accept_uppercase_and_reject_garbage() { + let upper = format!( + r#"{{"nodes":[{{"type":"file","content":["{}"]}}]}}"#, + FILE_ID.to_uppercase() + ); + assert_eq!( + parse_used_blobs_tree(upper.as_bytes()).unwrap().file_blobs, + vec![FILE_ID.parse::().unwrap()] + ); + assert!( + parse_used_blobs_tree(br#"{"nodes":[{"type":"file","content":["zzzz"]}]}"#).is_err() + ); + } } From 186a925374236265611ba7b4865ae7dbd31aadb5 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 13:33:55 -0700 Subject: [PATCH 21/28] fix: record prune used blob ids from loader threads A single consumer HashMap::insert serialized the used-blob walk. Loaders now insert into a 16-way sharded map while they decode trees. --- crates/core/src/blob/tree.rs | 22 ++++++++-- crates/core/src/commands/prune.rs | 67 ++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/core/src/blob/tree.rs b/crates/core/src/blob/tree.rs index bb99c57ad..0913c2696 100644 --- a/crates/core/src/blob/tree.rs +++ b/crates/core/src/blob/tree.rs @@ -725,6 +725,21 @@ impl TreeStreamer { ids: Vec, p: Progress, ) -> RusticResult { + Self::new_with_on_load(be, index, ids, p, |_| {}) + } + + /// Like [`Self::new`], and runs `on_load` in each loader thread after a tree + /// is decoded so prune can record used blob ids without a single consumer. + pub fn new_with_on_load( + be: &BE, + index: &I, + ids: Vec, + p: Progress, + on_load: F, + ) -> RusticResult + where + F: Fn(&T) + Send + Sync + Clone + 'static, + { p.set_length(ids.len() as u64); let loaders = be.tree_loader_count(); @@ -739,12 +754,11 @@ impl TreeStreamer { let index = index.clone(); let in_rx = in_rx.clone(); let out_tx = out_tx.clone(); + let on_load = on_load.clone(); let _join_handle = std::thread::spawn(move || { for (path, id, count) in in_rx { - if out_tx - .send(T::load(&be, &index, id).map(|tree| (path, tree, count))) - .is_err() - { + let loaded = T::load(&be, &index, id).inspect(|tree| on_load(tree)); + if out_tx.send(loaded.map(|tree| (path, tree, count))).is_err() { break; } } diff --git a/crates/core/src/commands/prune.rs b/crates/core/src/commands/prune.rs index 97510533a..67bbf4e13 100644 --- a/crates/core/src/commands/prune.rs +++ b/crates/core/src/commands/prune.rs @@ -6,6 +6,7 @@ use std::{ cmp::Ordering, collections::{BTreeMap, BTreeSet, HashMap}, str::FromStr, + sync::{Arc, Mutex}, }; use bytesize::ByteSize; @@ -1583,6 +1584,47 @@ impl PackInfo { } } +const USED_ID_SHARDS: usize = 16; + +/// Used blob ids filled from tree-loader threads. +/// +/// A single `HashMap` on the streamer consumer serialized prune's used-blob +/// walk. Shard so loaders insert without that bottleneck. +struct ShardedUsedIds { + shards: [Mutex>; USED_ID_SHARDS], +} + +impl ShardedUsedIds { + fn new() -> Self { + Self { + shards: std::array::from_fn(|_| Mutex::new(HashMap::new())), + } + } + + fn insert(&self, id: BlobId) { + let i = (id.as_u32() as usize) & (USED_ID_SHARDS - 1); + _ = self.shards[i] + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, 0); + } + + fn take_hashmap(&self) -> HashMap { + let mut out = HashMap::new(); + for shard in &self.shards { + let mut map = shard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if out.is_empty() { + out = std::mem::take(&mut *map); + } else { + out.extend(std::mem::take(&mut *map)); + } + } + out + } +} + /// Find used blobs in repo and return a map of used ids. /// /// # Arguments @@ -1616,18 +1658,25 @@ fn find_used_blobs( .try_collect()?; p.finish(); - let mut ids: HashMap<_, _> = snap_trees - .iter() - .map(|id| (BlobId::from(**id), 0)) - .collect(); + let ids = Arc::new(ShardedUsedIds::new()); + for id in &snap_trees { + ids.insert(BlobId::from(**id)); + } let p = repo.progress_counter("finding used blobs..."); + let ids_loader = Arc::clone(&ids); - let mut tree_streamer = TreeStreamer::::new(be, index, snap_trees, p)?; + let mut tree_streamer = + TreeStreamer::::new_with_on_load(be, index, snap_trees, p, move |used| { + for id in &used.file_blobs { + ids_loader.insert(BlobId::from(*id)); + } + for id in &used.dir_trees { + ids_loader.insert(BlobId::from(*id)); + } + })?; while let Some(item) = tree_streamer.next().transpose()? { - let (_, used) = item; - ids.extend(used.file_blobs.into_iter().map(|id| (BlobId::from(id), 0))); - ids.extend(used.dir_trees.into_iter().map(|id| (BlobId::from(id), 0))); + let _ = item; } - Ok(ids) + Ok(ids.take_hashmap()) } From 19c41bddfac4e663a3f4b25c3d06133ae0eb3d1d Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 16:47:19 -0700 Subject: [PATCH 22/28] fix: stream-decode prune tree nodes without full node structs A derived UsedBlobNode still paid serde skip/parse of every field. Visit only type, content, and subtree and push ids straight into UsedBlobsTree. --- crates/core/src/blob/tree/used_blobs.rs | 83 +++++++++++++++++++------ 1 file changed, 63 insertions(+), 20 deletions(-) diff --git a/crates/core/src/blob/tree/used_blobs.rs b/crates/core/src/blob/tree/used_blobs.rs index 2ae9496ac..63a736a2d 100644 --- a/crates/core/src/blob/tree/used_blobs.rs +++ b/crates/core/src/blob/tree/used_blobs.rs @@ -180,25 +180,16 @@ pub(crate) struct UsedBlobsTree { pub dir_trees: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] #[serde(rename_all = "lowercase")] enum UsedBlobKind { File, Dir, + #[default] #[serde(other)] Other, } -#[derive(Debug, Deserialize)] -struct UsedBlobNode { - #[serde(rename = "type")] - kind: UsedBlobKind, - #[serde(default, deserialize_with = "deserialize_data_ids")] - content: Vec, - #[serde(default, deserialize_with = "deserialize_opt_tree_id")] - subtree: Option, -} - impl<'de> Deserialize<'de> for UsedBlobsTree { fn deserialize>(deserializer: D) -> Result { deserializer.deserialize_map(UsedBlobsTreeVisitor) @@ -257,23 +248,75 @@ impl<'de> Visitor<'de> for NodesSeed<'_> { } fn visit_seq>(self, mut seq: A) -> Result { - while let Some(node) = seq.next_element::()? { - match node.kind { - UsedBlobKind::File => { - self.0.file_blobs.extend(node.content); + while seq.next_element_seed(NodeSeed(self.0))?.is_some() {} + Ok(()) + } +} + +struct NodeSeed<'a>(&'a mut UsedBlobsTree); + +impl<'de> DeserializeSeed<'de> for NodeSeed<'_> { + type Value = (); + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_map(self) + } +} + +impl<'de> Visitor<'de> for NodeSeed<'_> { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a restic tree node object") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut kind = UsedBlobKind::Other; + let mut content = Vec::new(); + let mut subtree = None; + while let Some(key) = map.next_key::>()? { + match key.as_ref() { + "type" => kind = map.next_value()?, + "content" => content = map.next_value_seed(DataIdsSeed)?, + "subtree" => subtree = map.next_value_seed(OptTreeIdSeed)?, + _ => { + let _: IgnoredAny = map.next_value()?; } - UsedBlobKind::Dir => { - if let Some(subtree) = node.subtree { - self.0.dir_trees.push(subtree); - } + } + } + match kind { + UsedBlobKind::File => self.0.file_blobs.append(&mut content), + UsedBlobKind::Dir => { + if let Some(id) = subtree { + self.0.dir_trees.push(id); } - UsedBlobKind::Other => {} } + UsedBlobKind::Other => {} } Ok(()) } } +struct DataIdsSeed; + +impl<'de> DeserializeSeed<'de> for DataIdsSeed { + type Value = Vec; + + fn deserialize>(self, deserializer: D) -> Result { + deserialize_data_ids(deserializer) + } +} + +struct OptTreeIdSeed; + +impl<'de> DeserializeSeed<'de> for OptTreeIdSeed { + type Value = Option; + + fn deserialize>(self, deserializer: D) -> Result { + deserialize_opt_tree_id(deserializer) + } +} + pub(crate) fn parse_used_blobs_tree(data: &[u8]) -> Result { serde_json::from_slice(data) } From 9aa87408d8b28ed9d1724b90b10c5a24268192fc Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 16:47:19 -0700 Subject: [PATCH 23/28] fix: fill prune used blob ids in per-loader maps Sharded Mutex HashMaps still spent ~35% on insert and lock contention. Each tree loader now owns a HashMap and the maps are merged after the walk. --- crates/core/src/blob/tree.rs | 38 +++++++++++---- crates/core/src/commands/prune.rs | 79 ++++++++++++------------------- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/crates/core/src/blob/tree.rs b/crates/core/src/blob/tree.rs index 0913c2696..aeefca577 100644 --- a/crates/core/src/blob/tree.rs +++ b/crates/core/src/blob/tree.rs @@ -702,6 +702,24 @@ pub struct TreeStreamer { /// Recursively visits all trees and subtrees, but each tree ID only once. pub type TreeStreamerOnce = TreeStreamer; +fn ignore_loaded_tree(_: &T) {} + +/// Called from each tree-loader thread after a tree is decoded. +pub(crate) trait OnTreeLoad: Send + 'static { + fn on_load(&mut self, tree: &T); + fn finish(self) + where + Self: Sized, + { + } +} + +impl OnTreeLoad for F { + fn on_load(&mut self, tree: &T) { + self(tree); + } +} + impl TreeStreamer { /// Creates a new `TreeStreamerOnce`. /// @@ -725,20 +743,23 @@ impl TreeStreamer { ids: Vec, p: Progress, ) -> RusticResult { - Self::new_with_on_load(be, index, ids, p, |_| {}) + Self::new_with_on_load(be, index, ids, p, || ignore_loaded_tree) } - /// Like [`Self::new`], and runs `on_load` in each loader thread after a tree - /// is decoded so prune can record used blob ids without a single consumer. - pub fn new_with_on_load( + /// Like [`Self::new`], but each loader thread gets its own `on_load` from + /// `factory` so prune can fill a thread-local used-id map with no locks. + pub fn new_with_on_load( be: &BE, index: &I, ids: Vec, p: Progress, - on_load: F, + mut factory: F, ) -> RusticResult where - F: Fn(&T) + Send + Sync + Clone + 'static, + BE: DecryptReadBackend, + I: ReadGlobalIndex, + F: FnMut() -> H, + H: OnTreeLoad, { p.set_length(ids.len() as u64); @@ -754,14 +775,15 @@ impl TreeStreamer { let index = index.clone(); let in_rx = in_rx.clone(); let out_tx = out_tx.clone(); - let on_load = on_load.clone(); + let mut on_load = factory(); let _join_handle = std::thread::spawn(move || { for (path, id, count) in in_rx { - let loaded = T::load(&be, &index, id).inspect(|tree| on_load(tree)); + let loaded = T::load(&be, &index, id).inspect(|tree| on_load.on_load(tree)); if out_tx.send(loaded.map(|tree| (path, tree, count))).is_err() { break; } } + on_load.finish(); }); } diff --git a/crates/core/src/commands/prune.rs b/crates/core/src/commands/prune.rs index 67bbf4e13..95615390b 100644 --- a/crates/core/src/commands/prune.rs +++ b/crates/core/src/commands/prune.rs @@ -6,7 +6,7 @@ use std::{ cmp::Ordering, collections::{BTreeMap, BTreeSet, HashMap}, str::FromStr, - sync::{Arc, Mutex}, + sync::mpsc::{self, Sender}, }; use bytesize::ByteSize; @@ -27,7 +27,7 @@ use crate::{ blob::{ BlobId, BlobLocations, BlobType, BlobTypeMap, Initialize, packer::{BlobCopier, CopyPackBlobs, PackSizer}, - tree::{TreeStreamer, UsedBlobsTree}, + tree::{OnTreeLoad, TreeStreamer, UsedBlobsTree}, }, error::{ErrorKind, RusticError, RusticResult}, index::{ @@ -1584,44 +1584,24 @@ impl PackInfo { } } -const USED_ID_SHARDS: usize = 16; - -/// Used blob ids filled from tree-loader threads. -/// -/// A single `HashMap` on the streamer consumer serialized prune's used-blob -/// walk. Shard so loaders insert without that bottleneck. -struct ShardedUsedIds { - shards: [Mutex>; USED_ID_SHARDS], +/// Per-loader used-id map. Inserts take no lock; maps are merged after the walk. +struct UsedIdAcc { + map: HashMap, + tx: Sender>, } -impl ShardedUsedIds { - fn new() -> Self { - Self { - shards: std::array::from_fn(|_| Mutex::new(HashMap::new())), +impl OnTreeLoad for UsedIdAcc { + fn on_load(&mut self, tree: &UsedBlobsTree) { + for id in &tree.file_blobs { + _ = self.map.insert(BlobId::from(*id), 0); + } + for id in &tree.dir_trees { + _ = self.map.insert(BlobId::from(*id), 0); } } - fn insert(&self, id: BlobId) { - let i = (id.as_u32() as usize) & (USED_ID_SHARDS - 1); - _ = self.shards[i] - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(id, 0); - } - - fn take_hashmap(&self) -> HashMap { - let mut out = HashMap::new(); - for shard in &self.shards { - let mut map = shard - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if out.is_empty() { - out = std::mem::take(&mut *map); - } else { - out.extend(std::mem::take(&mut *map)); - } - } - out + fn finish(self) { + _ = self.tx.send(self.map); } } @@ -1658,25 +1638,28 @@ fn find_used_blobs( .try_collect()?; p.finish(); - let ids = Arc::new(ShardedUsedIds::new()); - for id in &snap_trees { - ids.insert(BlobId::from(**id)); - } + let mut ids: HashMap<_, _> = snap_trees + .iter() + .map(|id| (BlobId::from(**id), 0)) + .collect(); let p = repo.progress_counter("finding used blobs..."); - let ids_loader = Arc::clone(&ids); - + let (maps_tx, maps_rx) = mpsc::channel(); let mut tree_streamer = - TreeStreamer::::new_with_on_load(be, index, snap_trees, p, move |used| { - for id in &used.file_blobs { - ids_loader.insert(BlobId::from(*id)); - } - for id in &used.dir_trees { - ids_loader.insert(BlobId::from(*id)); + TreeStreamer::::new_with_on_load(be, index, snap_trees, p, { + let maps_tx = maps_tx.clone(); + move || UsedIdAcc { + map: HashMap::new(), + tx: maps_tx.clone(), } })?; + drop(maps_tx); while let Some(item) = tree_streamer.next().transpose()? { let _ = item; } + drop(tree_streamer); + while let Ok(map) = maps_rx.recv() { + ids.extend(map); + } - Ok(ids.take_hashmap()) + Ok(ids) } From 461977d4f07c133a587703231c7198d10a6c2272 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 17:25:08 -0700 Subject: [PATCH 24/28] fix: use FxHash for prune used-blob maps and tree visited set siphash HashMap insert of BlobId was ~27% of finding used blobs after locks were removed. FxHash is enough for in-memory blob ids. --- Cargo.lock | 1 + crates/core/Cargo.toml | 1 + crates/core/src/blob/tree.rs | 7 ++++--- crates/core/src/blob/tree/used_blobs.rs | 12 ++++++++++-- crates/core/src/commands/prune.rs | 21 ++++++++++++--------- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f15ee20e..298f2b919 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4360,6 +4360,7 @@ dependencies = [ "rayon", "rstest", "runtime-format", + "rustc-hash", "rustic_backend", "rustic_cdc", "rustic_testing", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index f0dcce620..8571e711c 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -53,6 +53,7 @@ log = { workspace = true } crossbeam-channel = "0.5.15" pariter = "0.6.0" rayon = "1.11.0" +rustc-hash = "2.1.1" # crypto aes256ctr_poly1305aes = { version = "0.2.1", features = ["std"] } # we need std here for error impls diff --git a/crates/core/src/blob/tree.rs b/crates/core/src/blob/tree.rs index aeefca577..abe015fd5 100644 --- a/crates/core/src/blob/tree.rs +++ b/crates/core/src/blob/tree.rs @@ -6,7 +6,7 @@ mod used_blobs; use std::{ borrow::Cow, cmp::Ordering, - collections::{BTreeMap, BinaryHeap, HashSet}, + collections::{BTreeMap, BinaryHeap}, ffi::OsStr, mem, path::{Component, Path, PathBuf, Prefix}, @@ -17,6 +17,7 @@ use crossbeam_channel::{Receiver, Sender, TrySendError, bounded}; use derive_setters::Setters; use ignore::Match; use ignore::overrides::Override; +use rustc_hash::FxHashSet; use serde::{Deserialize, Deserializer}; use serde_derive::Serialize; @@ -684,7 +685,7 @@ impl LoadedTree for UsedBlobsTree { #[derive(Debug)] pub struct TreeStreamer { /// The visited tree IDs - visited: HashSet, + visited: FxHashSet, /// Depth-first backlog of tree IDs not yet sent to a loader. backlog: Vec<(PathBuf, TreeId, usize)>, /// The queue to send tree IDs to @@ -789,7 +790,7 @@ impl TreeStreamer { let counter = vec![0; ids.len()]; let mut streamer = Self { - visited: HashSet::new(), + visited: FxHashSet::default(), backlog: Vec::new(), queue_in: Some(in_tx), queue_out: out_rx, diff --git a/crates/core/src/blob/tree/used_blobs.rs b/crates/core/src/blob/tree/used_blobs.rs index 63a736a2d..6479540b5 100644 --- a/crates/core/src/blob/tree/used_blobs.rs +++ b/crates/core/src/blob/tree/used_blobs.rs @@ -59,7 +59,7 @@ fn parse_hex_id(s: &str) -> Option { struct HexDataIdVisitor; -impl Visitor<'_> for HexDataIdVisitor { +impl<'de> Visitor<'de> for HexDataIdVisitor { type Value = DataId; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -71,11 +71,15 @@ impl Visitor<'_> for HexDataIdVisitor { .map(DataId::from) .ok_or_else(|| E::invalid_value(de::Unexpected::Str(v), &self)) } + + fn visit_borrowed_str(self, v: &'de str) -> Result { + self.visit_str(v) + } } struct HexTreeIdVisitor; -impl Visitor<'_> for HexTreeIdVisitor { +impl<'de> Visitor<'de> for HexTreeIdVisitor { type Value = TreeId; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -87,6 +91,10 @@ impl Visitor<'_> for HexTreeIdVisitor { .map(TreeId::from) .ok_or_else(|| E::invalid_value(de::Unexpected::Str(v), &self)) } + + fn visit_borrowed_str(self, v: &'de str) -> Result { + self.visit_str(v) + } } fn deserialize_data_ids<'de, D: Deserializer<'de>>( diff --git a/crates/core/src/commands/prune.rs b/crates/core/src/commands/prune.rs index 95615390b..ad1508cd5 100644 --- a/crates/core/src/commands/prune.rs +++ b/crates/core/src/commands/prune.rs @@ -4,7 +4,7 @@ /// accessors along with logging macros. Customize as you see fit. use std::{ cmp::Ordering, - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet}, str::FromStr, sync::mpsc::{self, Sender}, }; @@ -17,6 +17,7 @@ use itertools::Itertools; use jiff::{Span, Timestamp, Zoned}; use log::{info, warn}; use rayon::prelude::{IntoParallelIterator, ParallelIterator}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use crate::{ @@ -43,6 +44,8 @@ use crate::{ repository::{Open, Repository}, }; +type UsedIdMap = FxHashMap; + pub(super) mod constants { /// Minimum size of an index file to be considered for pruning pub(super) const MIN_INDEX_LEN: usize = 10_000; @@ -584,7 +587,7 @@ pub struct PrunePlan { /// The time the plan was created time: Zoned, /// The ids of the blobs which are used - used_ids: HashMap, + used_ids: UsedIdMap, /// The ids of the existing packs existing_packs: BTreeMap, /// The packs which should be repacked @@ -604,7 +607,7 @@ impl PrunePlan { /// * `existing_packs` - The ids of the existing packs /// * `index_files` - The index files fn new( - used_ids: HashMap, + used_ids: UsedIdMap, existing_packs: BTreeMap, index_files: Vec<(IndexId, IndexFile)>, ) -> Self { @@ -1508,7 +1511,7 @@ impl PackInfo { /// /// * `pack` - The `PrunePack` to create the `PackInfo` from /// * `used_ids` - The map of used ids - fn from_pack(pack: &PrunePack, used_ids: &mut HashMap) -> Self { + fn from_pack(pack: &PrunePack, used_ids: &mut UsedIdMap) -> Self { let mut pi = Self { blob_type: pack.blob_type, used_blobs: 0, @@ -1586,8 +1589,8 @@ impl PackInfo { /// Per-loader used-id map. Inserts take no lock; maps are merged after the walk. struct UsedIdAcc { - map: HashMap, - tx: Sender>, + map: UsedIdMap, + tx: Sender, } impl OnTreeLoad for UsedIdAcc { @@ -1621,7 +1624,7 @@ fn find_used_blobs( be: &impl DecryptReadBackend, index: &impl ReadGlobalIndex, ignore_snaps: &[SnapshotId], -) -> RusticResult> { +) -> RusticResult { let ignore_snaps: BTreeSet<_> = ignore_snaps.iter().collect(); let p = repo.progress_counter("reading snapshots..."); @@ -1638,7 +1641,7 @@ fn find_used_blobs( .try_collect()?; p.finish(); - let mut ids: HashMap<_, _> = snap_trees + let mut ids: UsedIdMap = snap_trees .iter() .map(|id| (BlobId::from(**id), 0)) .collect(); @@ -1648,7 +1651,7 @@ fn find_used_blobs( TreeStreamer::::new_with_on_load(be, index, snap_trees, p, { let maps_tx = maps_tx.clone(); move || UsedIdAcc { - map: HashMap::new(), + map: UsedIdMap::default(), tx: maps_tx.clone(), } })?; From 8d37cf52303a35849b5c739612c78adae687f55a Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Fri, 4 Sep 2026 18:09:16 -0700 Subject: [PATCH 25/28] fix: scan prune tree JSON without serde node field parse Unused node fields (names, mtime, xattrs) were still UTF-8-validated and skipped through serde. A dedicated restic-tree scanner only hex-decodes file content and dir subtree ids. --- crates/core/src/blob/tree/used_blobs.rs | 537 ++++++++++++++---------- 1 file changed, 321 insertions(+), 216 deletions(-) diff --git a/crates/core/src/blob/tree/used_blobs.rs b/crates/core/src/blob/tree/used_blobs.rs index 6479540b5..98a5185c1 100644 --- a/crates/core/src/blob/tree/used_blobs.rs +++ b/crates/core/src/blob/tree/used_blobs.rs @@ -1,14 +1,10 @@ //! Stream-decode restic trees for prune without materializing `Node`s. //! -//! Prune only needs file content blob ids and directory subtree ids. Full -//! `Tree` deserialize also allocates names, metadata, and xattrs. +//! Prune only needs file content blob ids and directory subtree ids. A +//! dedicated JSON scanner skips names, metadata, and xattrs without UTF-8 +//! validation or serde parse of unused fields. -use std::{borrow::Cow, fmt}; - -use serde::{ - Deserialize, Deserializer, - de::{self, DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}, -}; +use serde::de::Error as DeError; use crate::{ Id, @@ -53,280 +49,370 @@ fn decode_hex32(src: &[u8]) -> Option<[u8; 32]> { Some(out) } -fn parse_hex_id(s: &str) -> Option { - decode_hex32(s.as_bytes()).map(Id::new) -} - -struct HexDataIdVisitor; - -impl<'de> Visitor<'de> for HexDataIdVisitor { - type Value = DataId; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a 64-character hex blob id") - } - - fn visit_str(self, v: &str) -> Result { - parse_hex_id(v) - .map(DataId::from) - .ok_or_else(|| E::invalid_value(de::Unexpected::Str(v), &self)) - } +type ScanError = serde_json::Error; - fn visit_borrowed_str(self, v: &'de str) -> Result { - self.visit_str(v) - } +/// Compact tree contents used by prune's used-blob walk. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct UsedBlobsTree { + pub file_blobs: Vec, + pub dir_trees: Vec, } -struct HexTreeIdVisitor; +#[derive(Debug, Default, Clone, Copy)] +enum UsedBlobKind { + File, + Dir, + #[default] + Other, +} -impl<'de> Visitor<'de> for HexTreeIdVisitor { - type Value = TreeId; +struct Scan<'a> { + buf: &'a [u8], + pos: usize, +} - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a 64-character hex tree id") +impl<'a> Scan<'a> { + fn err(msg: &'static str) -> Result { + Err(DeError::custom(msg)) } - fn visit_str(self, v: &str) -> Result { - parse_hex_id(v) - .map(TreeId::from) - .ok_or_else(|| E::invalid_value(de::Unexpected::Str(v), &self)) + #[inline] + fn peek(&self) -> Option { + self.buf.get(self.pos).copied() } - fn visit_borrowed_str(self, v: &'de str) -> Result { - self.visit_str(v) + #[inline] + fn bump(&mut self) { + self.pos += 1; } -} -fn deserialize_data_ids<'de, D: Deserializer<'de>>( - deserializer: D, -) -> Result, D::Error> { - struct SeqVisitor; - - impl<'de> Visitor<'de> for SeqVisitor { - type Value = Vec; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("an array of hex blob ids") + fn skip_ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.bump(); } + } - fn visit_unit(self) -> Result { - Ok(Vec::new()) + #[inline] + fn eat(&mut self, c: u8) -> bool { + if self.peek() == Some(c) { + self.bump(); + true + } else { + false } + } - fn visit_none(self) -> Result { - Ok(Vec::new()) + fn expect(&mut self, c: u8) -> Result<(), ScanError> { + if self.eat(c) { + Ok(()) + } else { + Self::err("unexpected JSON token") } + } - fn visit_some>( - self, - deserializer: D, - ) -> Result { - deserializer.deserialize_seq(self) + fn skip_lit(&mut self, lit: &[u8]) -> Result<(), ScanError> { + let rest = self.buf.get(self.pos..).unwrap_or(&[]); + if rest.starts_with(lit) { + self.pos += lit.len(); + Ok(()) + } else { + Self::err("invalid JSON literal") } + } - fn visit_seq>(self, mut seq: A) -> Result { - let mut out = Vec::new(); - while let Some(id) = seq.next_element_seed(HexDataIdSeed)? { - out.push(id); - } - Ok(out) + fn try_null(&mut self) -> Result { + if self.peek() == Some(b'n') { + self.skip_lit(b"null")?; + Ok(true) + } else { + Ok(false) } } - deserializer.deserialize_any(SeqVisitor) -} - -struct HexDataIdSeed; - -impl<'de> DeserializeSeed<'de> for HexDataIdSeed { - type Value = DataId; - - fn deserialize>(self, deserializer: D) -> Result { - deserializer.deserialize_str(HexDataIdVisitor) + /// Skip the rest of a JSON string. `pos` is already past the opening quote. + fn skip_string_body(&mut self) -> Result<(), ScanError> { + let bytes = self.buf.get(self.pos..).unwrap_or(&[]); + let mut i = 0; + while i < bytes.len() { + match bytes[i..].iter().position(|&b| b == b'"' || b == b'\\') { + None => break, + Some(rel) => { + i += rel; + if bytes[i] == b'"' { + self.pos += i + 1; + return Ok(()); + } + if i + 1 >= bytes.len() { + return Self::err("unterminated string escape"); + } + i += 2; + } + } + } + Self::err("unterminated string") } -} - -fn deserialize_opt_tree_id<'de, D: Deserializer<'de>>( - deserializer: D, -) -> Result, D::Error> { - struct OptVisitor; - impl<'de> Visitor<'de> for OptVisitor { - type Value = Option; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a hex tree id or null") + fn skip_string(&mut self) -> Result<(), ScanError> { + if !self.eat(b'"') { + return Self::err("expected string"); } + self.skip_string_body() + } - fn visit_unit(self) -> Result { - Ok(None) + fn skip_digits(&mut self) -> bool { + let start = self.pos; + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.bump(); } + self.pos > start + } - fn visit_none(self) -> Result { - Ok(None) + fn skip_number(&mut self) -> Result<(), ScanError> { + let _ = self.eat(b'-'); + if !self.skip_digits() { + return Self::err("invalid number"); } - - fn visit_some>( - self, - deserializer: D, - ) -> Result { - deserializer.deserialize_str(HexTreeIdVisitor).map(Some) + if self.eat(b'.') && !self.skip_digits() { + return Self::err("invalid number"); } - - fn visit_str(self, v: &str) -> Result { - HexTreeIdVisitor.visit_str(v).map(Some) + if matches!(self.peek(), Some(b'e' | b'E')) { + self.bump(); + if matches!(self.peek(), Some(b'+' | b'-')) { + self.bump(); + } + if !self.skip_digits() { + return Self::err("invalid number"); + } } + Ok(()) } - deserializer.deserialize_any(OptVisitor) -} - -/// Compact tree contents used by prune's used-blob walk. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub(crate) struct UsedBlobsTree { - pub file_blobs: Vec, - pub dir_trees: Vec, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "lowercase")] -enum UsedBlobKind { - File, - Dir, - #[default] - #[serde(other)] - Other, -} - -impl<'de> Deserialize<'de> for UsedBlobsTree { - fn deserialize>(deserializer: D) -> Result { - deserializer.deserialize_map(UsedBlobsTreeVisitor) - } -} - -struct UsedBlobsTreeVisitor; - -impl<'de> Visitor<'de> for UsedBlobsTreeVisitor { - type Value = UsedBlobsTree; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a restic tree object") + fn skip_value(&mut self) -> Result<(), ScanError> { + self.skip_ws(); + match self.peek() { + Some(b'"') => self.skip_string(), + Some(b'{') => self.skip_comma_list(b'{', b'}', true), + Some(b'[') => self.skip_comma_list(b'[', b']', false), + Some(b't') => self.skip_lit(b"true"), + Some(b'f') => self.skip_lit(b"false"), + Some(b'n') => self.skip_lit(b"null"), + Some(b'-' | b'0'..=b'9') => self.skip_number(), + _ => Self::err("expected JSON value"), + } } - fn visit_map>(self, mut map: A) -> Result { - let mut tree = UsedBlobsTree::default(); - while let Some(key) = map.next_key::>()? { - if key == "nodes" { - map.next_value_seed(NodesSeed(&mut tree))?; - } else { - let _: IgnoredAny = map.next_value()?; + fn skip_comma_list(&mut self, open: u8, close: u8, object: bool) -> Result<(), ScanError> { + self.expect(open)?; + let mut first = true; + loop { + self.skip_ws(); + if self.eat(close) { + return Ok(()); } + if !first { + self.expect(b',')?; + self.skip_ws(); + if self.eat(close) { + return Self::err("trailing comma"); + } + } + first = false; + if object { + self.skip_string()?; + self.skip_ws(); + self.expect(b':')?; + } + self.skip_value()?; } - Ok(tree) - } -} - -struct NodesSeed<'a>(&'a mut UsedBlobsTree); - -impl<'de> DeserializeSeed<'de> for NodesSeed<'_> { - type Value = (); - - fn deserialize>(self, deserializer: D) -> Result { - deserializer.deserialize_any(self) } -} - -impl<'de> Visitor<'de> for NodesSeed<'_> { - type Value = (); - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a nodes array or null") + /// Object key as raw bytes. Escaped keys are skipped and returned empty. + fn parse_key(&mut self) -> Result<&'a [u8], ScanError> { + if !self.eat(b'"') { + return Self::err("expected object key"); + } + let start = self.pos; + while let Some(b) = self.peek() { + if b == b'\\' { + self.skip_string_body()?; + return Ok(b""); + } + if b == b'"' { + let key = &self.buf[start..self.pos]; + self.bump(); + return Ok(key); + } + self.bump(); + } + Self::err("unterminated string") } - fn visit_unit(self) -> Result { - Ok(()) + fn parse_kind(&mut self) -> Result { + self.skip_ws(); + if !self.eat(b'"') { + return Self::err("expected type string"); + } + let start = self.pos; + self.skip_string_body()?; + Ok(match &self.buf[start..self.pos - 1] { + b"file" => UsedBlobKind::File, + b"dir" => UsedBlobKind::Dir, + _ => UsedBlobKind::Other, + }) } - fn visit_none(self) -> Result { - Ok(()) + fn parse_hex_id(&mut self) -> Result { + self.skip_ws(); + if !self.eat(b'"') { + return Self::err("expected hex id string"); + } + let rest = self.buf.get(self.pos..).unwrap_or(&[]); + if rest.len() >= 65 + && rest[64] == b'"' + && let Some(bytes) = decode_hex32(&rest[..64]) + { + self.pos += 65; + return Ok(Id::new(bytes)); + } + self.skip_string_body()?; + Self::err("invalid hex blob id") } - fn visit_some>(self, deserializer: D) -> Result { - deserializer.deserialize_seq(self) + fn parse_content(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { + self.skip_ws(); + if self.try_null()? { + return Ok(()); + } + self.expect(b'[')?; + let mut first = true; + loop { + self.skip_ws(); + if self.eat(b']') { + return Ok(()); + } + if !first { + self.expect(b',')?; + self.skip_ws(); + if self.eat(b']') { + return Self::err("trailing comma"); + } + } + first = false; + tree.file_blobs.push(DataId::from(self.parse_hex_id()?)); + } } - fn visit_seq>(self, mut seq: A) -> Result { - while seq.next_element_seed(NodeSeed(self.0))?.is_some() {} + fn parse_subtree(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { + self.skip_ws(); + if self.try_null()? { + return Ok(()); + } + tree.dir_trees.push(TreeId::from(self.parse_hex_id()?)); Ok(()) } -} - -struct NodeSeed<'a>(&'a mut UsedBlobsTree); - -impl<'de> DeserializeSeed<'de> for NodeSeed<'_> { - type Value = (); - - fn deserialize>(self, deserializer: D) -> Result { - deserializer.deserialize_map(self) - } -} -impl<'de> Visitor<'de> for NodeSeed<'_> { - type Value = (); - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a restic tree node object") - } - - fn visit_map>(self, mut map: A) -> Result { + fn parse_node(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { + self.skip_ws(); + self.expect(b'{')?; + let files_at = tree.file_blobs.len(); + let dirs_at = tree.dir_trees.len(); let mut kind = UsedBlobKind::Other; - let mut content = Vec::new(); - let mut subtree = None; - while let Some(key) = map.next_key::>()? { - match key.as_ref() { - "type" => kind = map.next_value()?, - "content" => content = map.next_value_seed(DataIdsSeed)?, - "subtree" => subtree = map.next_value_seed(OptTreeIdSeed)?, - _ => { - let _: IgnoredAny = map.next_value()?; + let mut first = true; + loop { + self.skip_ws(); + if self.eat(b'}') { + break; + } + if !first { + self.expect(b',')?; + self.skip_ws(); + if self.eat(b'}') { + return Self::err("trailing comma"); } } + first = false; + let key = self.parse_key()?; + self.skip_ws(); + self.expect(b':')?; + match key { + b"type" => kind = self.parse_kind()?, + b"content" => self.parse_content(tree)?, + b"subtree" => self.parse_subtree(tree)?, + _ => self.skip_value()?, + } } match kind { - UsedBlobKind::File => self.0.file_blobs.append(&mut content), - UsedBlobKind::Dir => { - if let Some(id) = subtree { - self.0.dir_trees.push(id); - } + UsedBlobKind::File => tree.dir_trees.truncate(dirs_at), + UsedBlobKind::Dir => tree.file_blobs.truncate(files_at), + UsedBlobKind::Other => { + tree.file_blobs.truncate(files_at); + tree.dir_trees.truncate(dirs_at); } - UsedBlobKind::Other => {} } Ok(()) } -} - -struct DataIdsSeed; - -impl<'de> DeserializeSeed<'de> for DataIdsSeed { - type Value = Vec; - fn deserialize>(self, deserializer: D) -> Result { - deserialize_data_ids(deserializer) + fn parse_nodes(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { + self.skip_ws(); + if self.try_null()? { + return Ok(()); + } + self.expect(b'[')?; + let mut first = true; + loop { + self.skip_ws(); + if self.eat(b']') { + return Ok(()); + } + if !first { + self.expect(b',')?; + self.skip_ws(); + if self.eat(b']') { + return Self::err("trailing comma"); + } + } + first = false; + self.parse_node(tree)?; + } } -} - -struct OptTreeIdSeed; -impl<'de> DeserializeSeed<'de> for OptTreeIdSeed { - type Value = Option; - - fn deserialize>(self, deserializer: D) -> Result { - deserialize_opt_tree_id(deserializer) + fn parse_tree(&mut self) -> Result { + self.skip_ws(); + self.expect(b'{')?; + let mut tree = UsedBlobsTree::default(); + let mut first = true; + loop { + self.skip_ws(); + if self.eat(b'}') { + break; + } + if !first { + self.expect(b',')?; + self.skip_ws(); + if self.eat(b'}') { + return Self::err("trailing comma"); + } + } + first = false; + let key = self.parse_key()?; + self.skip_ws(); + self.expect(b':')?; + if key == b"nodes" { + self.parse_nodes(&mut tree)?; + } else { + self.skip_value()?; + } + } + self.skip_ws(); + if self.pos != self.buf.len() { + return Self::err("trailing JSON"); + } + Ok(tree) } } pub(crate) fn parse_used_blobs_tree(data: &[u8]) -> Result { - serde_json::from_slice(data) + Scan { buf: data, pos: 0 }.parse_tree() } #[cfg(test)] @@ -429,4 +515,23 @@ mod tests { parse_used_blobs_tree(br#"{"nodes":[{"type":"file","content":["zzzz"]}]}"#).is_err() ); } + + #[test] + fn skips_escaped_names_and_accepts_content_before_type() { + let json = + format!(r#"{{"nodes":[{{"name":"quo\"te","content":["{FILE_ID}"],"type":"file"}}]}}"#); + let used = parse_used_blobs_tree(json.as_bytes()).unwrap(); + assert_eq!(used.file_blobs, vec![FILE_ID.parse::().unwrap()]); + assert!(used.dir_trees.is_empty()); + } + + #[test] + fn file_content_is_ignored_on_dirs_and_other_types() { + let json = format!( + r#"{{"nodes":[{{"type":"dir","content":["{FILE_ID}"],"subtree":"{TREE_ID}"}},{{"type":"symlink","content":["{FILE_ID}"]}}]}}"# + ); + let used = parse_used_blobs_tree(json.as_bytes()).unwrap(); + assert!(used.file_blobs.is_empty()); + assert_eq!(used.dir_trees, vec![TREE_ID.parse::().unwrap()]); + } } From b29a5cd725c601e1dc32208010be8f01f456b37a Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Sun, 6 Sep 2026 15:13:37 -0700 Subject: [PATCH 26/28] fix: preserve escaped JSON references during prune --- crates/core/src/blob/tree/used_blobs.rs | 114 ++++++++++++++++++------ 1 file changed, 86 insertions(+), 28 deletions(-) diff --git a/crates/core/src/blob/tree/used_blobs.rs b/crates/core/src/blob/tree/used_blobs.rs index 98a5185c1..ce597dd5e 100644 --- a/crates/core/src/blob/tree/used_blobs.rs +++ b/crates/core/src/blob/tree/used_blobs.rs @@ -4,6 +4,8 @@ //! dedicated JSON scanner skips names, metadata, and xattrs without UTF-8 //! validation or serde parse of unused fields. +use std::borrow::Cow; + use serde::de::Error as DeError; use crate::{ @@ -226,38 +228,42 @@ impl<'a> Scan<'a> { } } - /// Object key as raw bytes. Escaped keys are skipped and returned empty. - fn parse_key(&mut self) -> Result<&'a [u8], ScanError> { - if !self.eat(b'"') { - return Self::err("expected object key"); - } + /// Borrow ordinary ASCII keys and type names; decode JSON escapes only + /// on the slow path so escaped live references keep their meaning. + fn parse_short_string(&mut self) -> Result, ScanError> { + self.expect(b'"')?; let start = self.pos; - while let Some(b) = self.peek() { - if b == b'\\' { - self.skip_string_body()?; - return Ok(b""); - } - if b == b'"' { - let key = &self.buf[start..self.pos]; - self.bump(); - return Ok(key); + let bytes = &self.buf[start..]; + for (i, byte) in bytes.iter().copied().enumerate() { + match byte { + b'"' => { + self.pos = start + i + 1; + return Ok(Cow::Borrowed(&self.buf[start..start + i])); + } + b'\\' | 0x80..=0xff => { + self.pos = start + i; + self.skip_string_body()?; + let decoded: String = serde_json::from_slice(&self.buf[start - 1..self.pos])?; + return Ok(Cow::Owned(decoded.into_bytes())); + } + 0..=0x1f => return Self::err("control character in JSON string"), + _ => {} } - self.bump(); } Self::err("unterminated string") } + fn parse_key(&mut self) -> Result, ScanError> { + self.parse_short_string() + } + fn parse_kind(&mut self) -> Result { self.skip_ws(); - if !self.eat(b'"') { - return Self::err("expected type string"); - } - let start = self.pos; - self.skip_string_body()?; - Ok(match &self.buf[start..self.pos - 1] { + Ok(match self.parse_short_string()?.as_ref() { b"file" => UsedBlobKind::File, b"dir" => UsedBlobKind::Dir, - _ => UsedBlobKind::Other, + b"symlink" | b"dev" | b"chardev" | b"fifo" | b"socket" => UsedBlobKind::Other, + _ => return Self::err("unknown node type"), }) } @@ -266,6 +272,7 @@ impl<'a> Scan<'a> { if !self.eat(b'"') { return Self::err("expected hex id string"); } + let start = self.pos - 1; let rest = self.buf.get(self.pos..).unwrap_or(&[]); if rest.len() >= 65 && rest[64] == b'"' @@ -275,7 +282,10 @@ impl<'a> Scan<'a> { return Ok(Id::new(bytes)); } self.skip_string_body()?; - Self::err("invalid hex blob id") + let decoded: String = serde_json::from_slice(&self.buf[start..self.pos])?; + decode_hex32(decoded.as_bytes()) + .map(Id::new) + .ok_or_else(|| DeError::custom("invalid hex blob id")) } fn parse_content(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { @@ -316,7 +326,7 @@ impl<'a> Scan<'a> { self.expect(b'{')?; let files_at = tree.file_blobs.len(); let dirs_at = tree.dir_trees.len(); - let mut kind = UsedBlobKind::Other; + let mut kind = None; let mut first = true; loop { self.skip_ws(); @@ -334,14 +344,19 @@ impl<'a> Scan<'a> { let key = self.parse_key()?; self.skip_ws(); self.expect(b':')?; - match key { - b"type" => kind = self.parse_kind()?, + match key.as_ref() { + b"type" => { + if kind.is_some() { + return Self::err("duplicate node type"); + } + kind = Some(self.parse_kind()?); + } b"content" => self.parse_content(tree)?, b"subtree" => self.parse_subtree(tree)?, _ => self.skip_value()?, } } - match kind { + match kind.ok_or_else(|| ::custom("missing node type"))? { UsedBlobKind::File => tree.dir_trees.truncate(dirs_at), UsedBlobKind::Dir => tree.file_blobs.truncate(files_at), UsedBlobKind::Other => { @@ -397,7 +412,7 @@ impl<'a> Scan<'a> { let key = self.parse_key()?; self.skip_ws(); self.expect(b':')?; - if key == b"nodes" { + if key.as_ref() == b"nodes" { self.parse_nodes(&mut tree)?; } else { self.skip_value()?; @@ -534,4 +549,47 @@ mod tests { assert!(used.file_blobs.is_empty()); assert_eq!(used.dir_trees, vec![TREE_ID.parse::().unwrap()]); } + + #[test] + fn escaped_live_references_match_full_tree() { + let json = format!( + r#"{{"nodes":[{{"name":"file","type":"file","content":["{FILE_ID}"]}},{{"name":"dir","type":"dir","subtree":"{TREE_ID}"}}]}}"# + ); + for (plain, escaped) in [ + (r#""nodes""#, r#""n\u006fdes""#), + (r#""type""#, r#""t\u0079pe""#), + (r#""content""#, r#""cont\u0065nt""#), + (r#""subtree""#, r#""subtr\u0065e""#), + (r#""file""#, r#""f\u0069le""#), + (r#""dir""#, r#""d\u0069r""#), + ("012345", r"\u003012345"), + ("fedcba", r"\u0066edcba"), + ] { + let escaped_json = json.replace(plain, escaped); + let full: Tree = serde_json::from_str(&escaped_json).unwrap(); + let used = parse_used_blobs_tree(escaped_json.as_bytes()).unwrap(); + let files: Vec<_> = full + .nodes + .iter() + .flat_map(|n| n.content.iter().flatten().copied()) + .collect(); + let dirs: Vec<_> = full.nodes.iter().filter_map(|n| n.subtree).collect(); + assert_eq!(used.file_blobs, files, "{escaped_json}"); + assert_eq!(used.dir_trees, dirs, "{escaped_json}"); + } + } + + #[test] + fn rejects_ambiguous_node_types_and_invalid_escapes() { + for json in [ + r#"{"nodes":[{"type":"future_file"}]}"#, + r#"{"nodes":[{"name":"missing type"}]}"#, + r#"{"nodes":[{"type":"file","type":"symlink"}]}"#, + r#"{"n\qodes":[]}"#, + r#"{"nodes":[{"type":"f\qile"}]}"#, + r#"{"nodes":[{"type":"file","content":["\q"]}]}"#, + ] { + assert!(parse_used_blobs_tree(json.as_bytes()).is_err(), "{json}"); + } + } } From 0d55a57d4f8ea702002706112f0a3be183149f89 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Sat, 19 Sep 2026 14:05:26 -0700 Subject: [PATCH 27/28] fix: parse prune trees with a serde struct of type/content/subtree --- crates/core/src/backend/cache.rs | 8 +- crates/core/src/blob/tree/used_blobs.rs | 483 ++++-------------------- 2 files changed, 72 insertions(+), 419 deletions(-) diff --git a/crates/core/src/backend/cache.rs b/crates/core/src/backend/cache.rs index 3a131b527..2f56cd417 100644 --- a/crates/core/src/backend/cache.rs +++ b/crates/core/src/backend/cache.rs @@ -948,10 +948,10 @@ mod tests { #[test] fn open_file_capacity_uses_reserve_not_an_eighth() { - assert_eq!(open_file_capacity_from_soft_limit(8192), 2048); - assert_eq!(open_file_capacity_from_soft_limit(1024), 960); - assert_eq!(open_file_capacity_from_soft_limit(64), 1); - assert_eq!(open_file_capacity_from_soft_limit(0), 1); + assert_eq!(open_file_capacity_from_soft_limit(8192_u64), 2048); + assert_eq!(open_file_capacity_from_soft_limit(1024_u64), 960); + assert_eq!(open_file_capacity_from_soft_limit(64_u64), 1); + assert_eq!(open_file_capacity_from_soft_limit(0_u64), 1); } #[cfg(unix)] diff --git a/crates/core/src/blob/tree/used_blobs.rs b/crates/core/src/blob/tree/used_blobs.rs index ce597dd5e..de6eefa15 100644 --- a/crates/core/src/blob/tree/used_blobs.rs +++ b/crates/core/src/blob/tree/used_blobs.rs @@ -1,57 +1,12 @@ -//! Stream-decode restic trees for prune without materializing `Node`s. +//! Deserialize restic trees for prune without materializing full `Node`s. //! //! Prune only needs file content blob ids and directory subtree ids. A -//! dedicated JSON scanner skips names, metadata, and xattrs without UTF-8 -//! validation or serde parse of unused fields. +//! dedicated serde struct keeps `type` / `content` / `subtree` and ignores +//! names, metadata, and xattrs. -use std::borrow::Cow; +use serde_derive::Deserialize; -use serde::de::Error as DeError; - -use crate::{ - Id, - blob::{DataId, tree::TreeId}, -}; - -/// Nibble lookup: 0–15 for hex digits, `0xFF` otherwise. -const fn hex_nibble_table() -> [u8; 256] { - let mut t = [0xff_u8; 256]; - let mut i: u8 = 0; - while i < 10 { - t[b'0' as usize + i as usize] = i; - i += 1; - } - i = 0; - while i < 6 { - t[b'a' as usize + i as usize] = 10 + i; - t[b'A' as usize + i as usize] = 10 + i; - i += 1; - } - t -} - -const HEX_NIBBLE: [u8; 256] = hex_nibble_table(); - -#[inline] -fn decode_hex32(src: &[u8]) -> Option<[u8; 32]> { - if src.len() != 64 { - return None; - } - let mut out = [0_u8; 32]; - let mut i = 0; - while i < 32 { - let hi = HEX_NIBBLE[src[i * 2] as usize]; - let lo = HEX_NIBBLE[src[i * 2 + 1] as usize]; - if (hi | lo) == 0xff { - return None; - } - out[i] = (hi << 4) | lo; - i += 1; - } - Some(out) -} - -type ScanError = serde_json::Error; +use crate::blob::{DataId, tree::TreeId}; /// Compact tree contents used by prune's used-blob walk. #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -60,374 +15,51 @@ pub(crate) struct UsedBlobsTree { pub dir_trees: Vec, } -#[derive(Debug, Default, Clone, Copy)] -enum UsedBlobKind { +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +enum PruneNodeKind { File, Dir, #[default] + #[serde(other)] Other, } -struct Scan<'a> { - buf: &'a [u8], - pos: usize, +#[derive(Debug, Deserialize)] +struct PruneNode { + #[serde(rename = "type", default)] + kind: PruneNodeKind, + #[serde(default)] + content: Option>, + #[serde(default)] + subtree: Option, } -impl<'a> Scan<'a> { - fn err(msg: &'static str) -> Result { - Err(DeError::custom(msg)) - } - - #[inline] - fn peek(&self) -> Option { - self.buf.get(self.pos).copied() - } - - #[inline] - fn bump(&mut self) { - self.pos += 1; - } - - fn skip_ws(&mut self) { - while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { - self.bump(); - } - } - - #[inline] - fn eat(&mut self, c: u8) -> bool { - if self.peek() == Some(c) { - self.bump(); - true - } else { - false - } - } - - fn expect(&mut self, c: u8) -> Result<(), ScanError> { - if self.eat(c) { - Ok(()) - } else { - Self::err("unexpected JSON token") - } - } - - fn skip_lit(&mut self, lit: &[u8]) -> Result<(), ScanError> { - let rest = self.buf.get(self.pos..).unwrap_or(&[]); - if rest.starts_with(lit) { - self.pos += lit.len(); - Ok(()) - } else { - Self::err("invalid JSON literal") - } - } - - fn try_null(&mut self) -> Result { - if self.peek() == Some(b'n') { - self.skip_lit(b"null")?; - Ok(true) - } else { - Ok(false) - } - } - - /// Skip the rest of a JSON string. `pos` is already past the opening quote. - fn skip_string_body(&mut self) -> Result<(), ScanError> { - let bytes = self.buf.get(self.pos..).unwrap_or(&[]); - let mut i = 0; - while i < bytes.len() { - match bytes[i..].iter().position(|&b| b == b'"' || b == b'\\') { - None => break, - Some(rel) => { - i += rel; - if bytes[i] == b'"' { - self.pos += i + 1; - return Ok(()); - } - if i + 1 >= bytes.len() { - return Self::err("unterminated string escape"); - } - i += 2; - } - } - } - Self::err("unterminated string") - } - - fn skip_string(&mut self) -> Result<(), ScanError> { - if !self.eat(b'"') { - return Self::err("expected string"); - } - self.skip_string_body() - } - - fn skip_digits(&mut self) -> bool { - let start = self.pos; - while matches!(self.peek(), Some(b'0'..=b'9')) { - self.bump(); - } - self.pos > start - } - - fn skip_number(&mut self) -> Result<(), ScanError> { - let _ = self.eat(b'-'); - if !self.skip_digits() { - return Self::err("invalid number"); - } - if self.eat(b'.') && !self.skip_digits() { - return Self::err("invalid number"); - } - if matches!(self.peek(), Some(b'e' | b'E')) { - self.bump(); - if matches!(self.peek(), Some(b'+' | b'-')) { - self.bump(); - } - if !self.skip_digits() { - return Self::err("invalid number"); - } - } - Ok(()) - } - - fn skip_value(&mut self) -> Result<(), ScanError> { - self.skip_ws(); - match self.peek() { - Some(b'"') => self.skip_string(), - Some(b'{') => self.skip_comma_list(b'{', b'}', true), - Some(b'[') => self.skip_comma_list(b'[', b']', false), - Some(b't') => self.skip_lit(b"true"), - Some(b'f') => self.skip_lit(b"false"), - Some(b'n') => self.skip_lit(b"null"), - Some(b'-' | b'0'..=b'9') => self.skip_number(), - _ => Self::err("expected JSON value"), - } - } - - fn skip_comma_list(&mut self, open: u8, close: u8, object: bool) -> Result<(), ScanError> { - self.expect(open)?; - let mut first = true; - loop { - self.skip_ws(); - if self.eat(close) { - return Ok(()); - } - if !first { - self.expect(b',')?; - self.skip_ws(); - if self.eat(close) { - return Self::err("trailing comma"); - } - } - first = false; - if object { - self.skip_string()?; - self.skip_ws(); - self.expect(b':')?; - } - self.skip_value()?; - } - } - - /// Borrow ordinary ASCII keys and type names; decode JSON escapes only - /// on the slow path so escaped live references keep their meaning. - fn parse_short_string(&mut self) -> Result, ScanError> { - self.expect(b'"')?; - let start = self.pos; - let bytes = &self.buf[start..]; - for (i, byte) in bytes.iter().copied().enumerate() { - match byte { - b'"' => { - self.pos = start + i + 1; - return Ok(Cow::Borrowed(&self.buf[start..start + i])); - } - b'\\' | 0x80..=0xff => { - self.pos = start + i; - self.skip_string_body()?; - let decoded: String = serde_json::from_slice(&self.buf[start - 1..self.pos])?; - return Ok(Cow::Owned(decoded.into_bytes())); - } - 0..=0x1f => return Self::err("control character in JSON string"), - _ => {} - } - } - Self::err("unterminated string") - } - - fn parse_key(&mut self) -> Result, ScanError> { - self.parse_short_string() - } - - fn parse_kind(&mut self) -> Result { - self.skip_ws(); - Ok(match self.parse_short_string()?.as_ref() { - b"file" => UsedBlobKind::File, - b"dir" => UsedBlobKind::Dir, - b"symlink" | b"dev" | b"chardev" | b"fifo" | b"socket" => UsedBlobKind::Other, - _ => return Self::err("unknown node type"), - }) - } - - fn parse_hex_id(&mut self) -> Result { - self.skip_ws(); - if !self.eat(b'"') { - return Self::err("expected hex id string"); - } - let start = self.pos - 1; - let rest = self.buf.get(self.pos..).unwrap_or(&[]); - if rest.len() >= 65 - && rest[64] == b'"' - && let Some(bytes) = decode_hex32(&rest[..64]) - { - self.pos += 65; - return Ok(Id::new(bytes)); - } - self.skip_string_body()?; - let decoded: String = serde_json::from_slice(&self.buf[start..self.pos])?; - decode_hex32(decoded.as_bytes()) - .map(Id::new) - .ok_or_else(|| DeError::custom("invalid hex blob id")) - } - - fn parse_content(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { - self.skip_ws(); - if self.try_null()? { - return Ok(()); - } - self.expect(b'[')?; - let mut first = true; - loop { - self.skip_ws(); - if self.eat(b']') { - return Ok(()); - } - if !first { - self.expect(b',')?; - self.skip_ws(); - if self.eat(b']') { - return Self::err("trailing comma"); - } - } - first = false; - tree.file_blobs.push(DataId::from(self.parse_hex_id()?)); - } - } - - fn parse_subtree(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { - self.skip_ws(); - if self.try_null()? { - return Ok(()); - } - tree.dir_trees.push(TreeId::from(self.parse_hex_id()?)); - Ok(()) - } - - fn parse_node(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { - self.skip_ws(); - self.expect(b'{')?; - let files_at = tree.file_blobs.len(); - let dirs_at = tree.dir_trees.len(); - let mut kind = None; - let mut first = true; - loop { - self.skip_ws(); - if self.eat(b'}') { - break; - } - if !first { - self.expect(b',')?; - self.skip_ws(); - if self.eat(b'}') { - return Self::err("trailing comma"); - } - } - first = false; - let key = self.parse_key()?; - self.skip_ws(); - self.expect(b':')?; - match key.as_ref() { - b"type" => { - if kind.is_some() { - return Self::err("duplicate node type"); - } - kind = Some(self.parse_kind()?); - } - b"content" => self.parse_content(tree)?, - b"subtree" => self.parse_subtree(tree)?, - _ => self.skip_value()?, - } - } - match kind.ok_or_else(|| ::custom("missing node type"))? { - UsedBlobKind::File => tree.dir_trees.truncate(dirs_at), - UsedBlobKind::Dir => tree.file_blobs.truncate(files_at), - UsedBlobKind::Other => { - tree.file_blobs.truncate(files_at); - tree.dir_trees.truncate(dirs_at); - } - } - Ok(()) - } +#[derive(Debug, Default, Deserialize)] +struct PruneTree { + #[serde(default, deserialize_with = "super::deserialize_null_default")] + nodes: Vec, +} - fn parse_nodes(&mut self, tree: &mut UsedBlobsTree) -> Result<(), ScanError> { - self.skip_ws(); - if self.try_null()? { - return Ok(()); - } - self.expect(b'[')?; - let mut first = true; - loop { - self.skip_ws(); - if self.eat(b']') { - return Ok(()); - } - if !first { - self.expect(b',')?; - self.skip_ws(); - if self.eat(b']') { - return Self::err("trailing comma"); +pub(crate) fn parse_used_blobs_tree(data: &[u8]) -> Result { + let parsed: PruneTree = serde_json::from_slice(data)?; + let mut tree = UsedBlobsTree::default(); + for node in parsed.nodes { + match node.kind { + PruneNodeKind::File => { + if let Some(content) = node.content { + tree.file_blobs.extend(content); } } - first = false; - self.parse_node(tree)?; - } - } - - fn parse_tree(&mut self) -> Result { - self.skip_ws(); - self.expect(b'{')?; - let mut tree = UsedBlobsTree::default(); - let mut first = true; - loop { - self.skip_ws(); - if self.eat(b'}') { - break; - } - if !first { - self.expect(b',')?; - self.skip_ws(); - if self.eat(b'}') { - return Self::err("trailing comma"); + PruneNodeKind::Dir => { + if let Some(id) = node.subtree { + tree.dir_trees.push(id); } } - first = false; - let key = self.parse_key()?; - self.skip_ws(); - self.expect(b':')?; - if key.as_ref() == b"nodes" { - self.parse_nodes(&mut tree)?; - } else { - self.skip_value()?; - } - } - self.skip_ws(); - if self.pos != self.buf.len() { - return Self::err("trailing JSON"); + PruneNodeKind::Other => {} } - Ok(tree) } -} - -pub(crate) fn parse_used_blobs_tree(data: &[u8]) -> Result { - Scan { buf: data, pos: 0 }.parse_tree() + Ok(tree) } #[cfg(test)] @@ -486,7 +118,6 @@ mod tests { assert_eq!(used.dir_trees, full_dirs); assert_eq!(used.file_blobs, vec![FILE_ID.parse::().unwrap()]); assert_eq!(used.dir_trees, vec![TREE_ID.parse::().unwrap()]); - // Full deserialize kept metadata we did not allocate in the prune path. let foo: &Node = &full.nodes[0]; assert_eq!(foo.name, "foo"); assert_eq!(foo.meta.extended_attributes.len(), 1); @@ -580,16 +211,38 @@ mod tests { } #[test] - fn rejects_ambiguous_node_types_and_invalid_escapes() { - for json in [ - r#"{"nodes":[{"type":"future_file"}]}"#, - r#"{"nodes":[{"name":"missing type"}]}"#, - r#"{"nodes":[{"type":"file","type":"symlink"}]}"#, - r#"{"n\qodes":[]}"#, - r#"{"nodes":[{"type":"f\qile"}]}"#, - r#"{"nodes":[{"type":"file","content":["\q"]}]}"#, - ] { - assert!(parse_used_blobs_tree(json.as_bytes()).is_err(), "{json}"); - } + fn unknown_or_missing_types_are_skipped() { + assert_eq!( + parse_used_blobs_tree(br#"{"nodes":[{"type":"future_file"}]}"#).unwrap(), + UsedBlobsTree::default() + ); + assert_eq!( + parse_used_blobs_tree(br#"{"nodes":[{"name":"missing type"}]}"#).unwrap(), + UsedBlobsTree::default() + ); + } + + #[test] + fn rejects_invalid_json() { + assert!(parse_used_blobs_tree(br#"{"n\qodes":[]}"#).is_err()); + assert!(parse_used_blobs_tree(br#"{"nodes":[{"type":"f\qile"}]}"#).is_err()); + assert!(parse_used_blobs_tree( + br#"{"nodes":[{"type":"file","content":["\q"]}]}"# + ) + .is_err()); + assert!(parse_used_blobs_tree( + br#"{"nodes":[{"type":"file","type":"symlink"}]}"# + ) + .is_err()); + } + + #[test] + fn skips_long_unescaped_names() { + let name = "n".repeat(80); + let json = format!( + r#"{{"nodes":[{{"name":"{name}","mtime":"2020-01-01T00:00:00+00:00","type":"file","content":["{FILE_ID}"]}}]}}"# + ); + let used = parse_used_blobs_tree(json.as_bytes()).unwrap(); + assert_eq!(used.file_blobs, vec![FILE_ID.parse::().unwrap()]); } } From 383772a70dc3b66e6e59fa7cb8baae1d88ed85c3 Mon Sep 17 00:00:00 2001 From: Brad Kollmyer Date: Sat, 5 Sep 2026 13:08:47 -0700 Subject: [PATCH 28/28] fix: reuse per-thread zstd output buffer for prune trees Prune tree walking allocated a new uncompressed Vec per blob, which showed up as kernel_init_pages (~12%) during finding used blobs. --- crates/core/src/backend/decrypt.rs | 107 ++++++++++++++++++++++++++--- crates/core/src/blob/tree.rs | 14 +++- crates/core/src/index.rs | 19 +++++ 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/crates/core/src/backend/decrypt.rs b/crates/core/src/backend/decrypt.rs index 711e28bf8..ed6b06140 100644 --- a/crates/core/src/backend/decrypt.rs +++ b/crates/core/src/backend/decrypt.rs @@ -5,20 +5,72 @@ use crossbeam_channel::{Receiver, bounded}; use rayon::{prelude::*, spawn}; use zstd::stream::{copy_encode, decode_all, encode_all}; +thread_local! { + static ZSTD: RefCell = const { + RefCell::new(ZstdTls { + decompressor: None, + buf: Vec::new(), + }) + }; +} + +struct ZstdTls { + decompressor: Option>, + buf: Vec, +} + +fn zstd_tls_decompressor( + slot: &mut ZstdTls, +) -> RusticResult<&mut zstd::bulk::Decompressor<'static>> { + if slot.decompressor.is_none() { + slot.decompressor = Some(zstd::bulk::Decompressor::new().map_err(|err| { + RusticError::with_source( + ErrorKind::Internal, + "Failed to create zstd decompressor.", + err, + ) + })?); + } + Ok(slot + .decompressor + .as_mut() + .expect("zstd decompressor is initialized")) +} + /// Decode zstd with a decompressor kept on this thread. /// /// `zstd::decode_all` builds a new `DCtx` per call. Tree walking does that for /// every blob and showed up as `ZSTD_createDCtx` / `munmap` / page faults. fn zstd_decompress(data: &[u8], uncompressed_len: usize) -> RusticResult> { - thread_local! { - static DECOMPRESSOR: RefCell>> = - const { RefCell::new(None) }; - } + ZSTD.with(|slot| { + let mut slot = slot.borrow_mut(); + zstd_tls_decompressor(&mut slot)? + .decompress(data, uncompressed_len) + .map_err(|err| { + RusticError::with_source( + ErrorKind::Internal, + "Failed to decode zstd compressed data. The data may be corrupted.", + err, + ) + }) + }) +} - DECOMPRESSOR.with(|slot| { +/// Decompress into a thread-local buffer and run `f` on the plaintext. +/// +/// Prune tree walking used to allocate a new uncompressed `Vec` per blob, +/// which showed up as `kernel_init_pages`. The buffer keeps capacity on this +/// thread so later trees reuse the same pages. +fn zstd_decompress_with( + data: &[u8], + uncompressed_len: usize, + f: impl FnOnce(&[u8]) -> RusticResult, +) -> RusticResult { + ZSTD.with(|slot| { let mut slot = slot.borrow_mut(); - if slot.is_none() { - *slot = Some(zstd::bulk::Decompressor::new().map_err(|err| { + let ZstdTls { decompressor, buf } = &mut *slot; + if decompressor.is_none() { + *decompressor = Some(zstd::bulk::Decompressor::new().map_err(|err| { RusticError::with_source( ErrorKind::Internal, "Failed to create zstd decompressor.", @@ -26,16 +78,23 @@ fn zstd_decompress(data: &[u8], uncompressed_len: usize) -> RusticResult ) })?); } - slot.as_mut() + buf.clear(); + if buf.capacity() < uncompressed_len { + buf.reserve(uncompressed_len); + } + let written = decompressor + .as_mut() .expect("zstd decompressor is initialized") - .decompress(data, uncompressed_len) + .decompress_to_buffer(data, buf) .map_err(|err| { RusticError::with_source( ErrorKind::Internal, "Failed to decode zstd compressed data. The data may be corrupted.", err, ) - }) + })?; + buf.truncate(written); + f(buf) }) } @@ -124,6 +183,34 @@ pub trait DecryptReadBackend: ReadBackend + Clone + 'static { Ok(data.into()) } + /// Decrypt and decompress `data`, then run `f` on the plaintext without + /// allocating a new uncompressed `Vec` on every call. + fn with_decoded_from_partial( + &self, + data: &[u8], + uncompressed_length: Option, + f: impl FnOnce(&[u8]) -> RusticResult, + ) -> RusticResult { + let decrypted = self.decrypt(data)?; + if let Some(length) = uncompressed_length { + let expected = length.get() as usize; + zstd_decompress_with(&decrypted, expected, |plain| { + if plain.len() != expected { + return Err(RusticError::new( + ErrorKind::Internal, + "Length of uncompressed data `{actual_length}` does not match the given length `{expected_length}`.", + ) + .attach_context("expected_length", length.get().to_string()) + .attach_context("actual_length", plain.len().to_string()) + .ask_report()); + } + f(plain) + }) + } else { + f(&decrypted) + } + } + /// Reads the given file with the given offset and length. /// /// # Arguments diff --git a/crates/core/src/blob/tree.rs b/crates/core/src/blob/tree.rs index abe015fd5..ac1804cb7 100644 --- a/crates/core/src/blob/tree.rs +++ b/crates/core/src/blob/tree.rs @@ -663,8 +663,18 @@ impl LoadedTree for UsedBlobsTree { index: &I, id: TreeId, ) -> RusticResult { - let data = read_tree_bytes(be, index, id)?; - used_blobs::parse_used_blobs_tree(&data).map_err(tree_json_error) + index + .get_tree(&id) + .ok_or_else(|| { + RusticError::new( + ErrorKind::Internal, + "Tree ID `{tree_id}` not found in index", + ) + .attach_context("tree_id", id.to_string()) + })? + .with_decoded(be, |data| { + used_blobs::parse_used_blobs_tree(data).map_err(tree_json_error) + }) } fn child_trees(&self, _parent: &Path) -> Vec<(PathBuf, TreeId)> { diff --git a/crates/core/src/index.rs b/crates/core/src/index.rs index 95f981c89..ff45ceb67 100644 --- a/crates/core/src/index.rs +++ b/crates/core/src/index.rs @@ -65,6 +65,25 @@ impl IndexEntry { Ok(data) } + /// Decrypt and decompress this blob, then run `f` on the plaintext. + /// + /// Prune tree walking uses this so zstd output can stay in a thread-local + /// buffer instead of a new `Vec`/`Bytes` per tree. + pub fn with_decoded(&self, be: &B, f: F) -> RusticResult + where + B: DecryptReadBackend, + F: FnOnce(&[u8]) -> RusticResult, + { + let cipher = be.read_partial( + FileType::Pack, + &self.pack, + self.blob_type.is_cacheable(), + self.location.offset, + self.location.length, + )?; + be.with_decoded_from_partial(&cipher, self.location.uncompressed_length, f) + } + /// Get the length of the data described by the [`IndexEntry`] #[must_use] pub const fn data_length(&self) -> u32 {