Skip to main content

heddle_pack/store/pack/
manager.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pack file manager for coordinating multiple pack files.
3
4use std::{
5    collections::HashMap,
6    fs,
7    path::{Path, PathBuf},
8    sync::{OnceLock, RwLock},
9    time::SystemTime,
10};
11
12use tracing::{debug, instrument, trace};
13
14use crate::{
15    object::ContentHash,
16    store::{
17        Result,
18        pack::{ObjectType, PackObjectId, PackReadTier, PackReader},
19    },
20};
21
22/// Format-only coordinator for loaded pack and index files.
23///
24/// Object-domain indexes belong in a wrapper owned by the consuming crate.
25pub struct PackManager {
26    packs_dir: PathBuf,
27    packs: Vec<CachedPack>,
28    object_locations: RwLock<ObjectLocationIndex>,
29    eager_object_locations: bool,
30}
31
32#[derive(Default)]
33struct ObjectLocationIndex {
34    locations: HashMap<PackObjectId, ObjectLocation>,
35    complete: bool,
36}
37
38#[derive(Clone, Copy)]
39struct ObjectLocation {
40    pack_index: usize,
41    tier: PackReadTier,
42}
43
44struct CachedPack {
45    pack_path: PathBuf,
46    index_path: PathBuf,
47    reader: OnceLock<Option<PackReader<'static>>>,
48}
49
50impl CachedPack {
51    fn discovered(pack_path: PathBuf, index_path: PathBuf) -> Self {
52        Self {
53            pack_path,
54            index_path,
55            reader: OnceLock::new(),
56        }
57    }
58
59    fn validated(pack_path: PathBuf, index_path: PathBuf, reader: PackReader<'static>) -> Self {
60        Self {
61            pack_path,
62            index_path,
63            reader: OnceLock::from(Some(reader)),
64        }
65    }
66
67    fn reader(&self) -> Option<&PackReader<'static>> {
68        self.reader
69            .get_or_init(
70                || match PackReader::open_lazy(&self.pack_path, &self.index_path) {
71                    Ok(reader) => Some(reader),
72                    Err(error) => {
73                        debug!(pack = ?self.pack_path, %error, "Failed to open pack");
74                        None
75                    }
76                },
77            )
78            .as_ref()
79    }
80
81    fn verified_reader(&self) -> Option<&PackReader<'static>> {
82        self.reader
83            .get_or_init(
84                || match PackReader::open(&self.pack_path, &self.index_path) {
85                    Ok(reader) => Some(reader),
86                    Err(error) => {
87                        debug!(pack = ?self.pack_path, %error, "Failed to open pack");
88                        None
89                    }
90                },
91            )
92            .as_ref()
93    }
94}
95
96impl PackManager {
97    pub fn new(packs_dir: PathBuf) -> Self {
98        Self::new_with_index_mode(packs_dir, force_eager_pack_index())
99    }
100
101    fn new_with_index_mode(packs_dir: PathBuf, eager_object_locations: bool) -> Self {
102        let packs = Self::load_packs(&packs_dir).unwrap_or_default();
103        let object_locations = Self::initial_object_locations(&packs, eager_object_locations);
104        Self {
105            packs_dir,
106            packs,
107            object_locations: RwLock::new(object_locations),
108            eager_object_locations,
109        }
110    }
111
112    fn discover_pack_paths(packs_dir: &Path) -> Result<Vec<(PathBuf, PathBuf)>> {
113        let mut packs = Vec::new();
114
115        if !packs_dir.exists() {
116            return Ok(packs);
117        }
118
119        for entry in fs::read_dir(packs_dir)? {
120            let entry = entry?;
121            let path = entry.path();
122
123            if path.extension().map(|e| e == "pack").unwrap_or(false) {
124                let index_path = path.with_extension("idx");
125                if index_path.exists() {
126                    packs.push((path, index_path));
127                }
128            }
129        }
130
131        // Pack names are content hashes, so lexical order says nothing about
132        // recency. Keep the oldest pack first: point lookups walk this vector
133        // backwards and current snapshot trees overwhelmingly live in the
134        // newest incremental pack. The path is a deterministic tie-breaker
135        // for filesystems with coarse timestamp precision.
136        packs.sort_by(|left, right| {
137            pack_modified(&left.0)
138                .cmp(&pack_modified(&right.0))
139                .then_with(|| left.0.cmp(&right.0))
140        });
141
142        debug!(count = packs.len(), "Discovered pack files");
143        Ok(packs)
144    }
145
146    fn load_packs(packs_dir: &Path) -> Result<Vec<CachedPack>> {
147        Ok(Self::discover_pack_paths(packs_dir)?
148            .into_iter()
149            .map(|(pack_path, index_path)| CachedPack::discovered(pack_path, index_path))
150            .collect())
151    }
152
153    pub fn reload(&mut self) -> Result<()> {
154        self.packs = Self::load_packs(&self.packs_dir)?;
155        self.reset_object_locations();
156        Ok(())
157    }
158
159    fn initial_object_locations(packs: &[CachedPack], eager: bool) -> ObjectLocationIndex {
160        if !eager {
161            return ObjectLocationIndex::default();
162        }
163        let mut locations = HashMap::new();
164        for (pack_index, pack) in packs.iter().enumerate() {
165            let Some(reader) = pack.verified_reader() else {
166                continue;
167            };
168            let Ok(objects) = reader.indexed_read_tiers() else {
169                continue;
170            };
171            for (id, tier) in objects {
172                remember_location(&mut locations, id, pack_index, tier);
173            }
174        }
175        ObjectLocationIndex {
176            locations,
177            complete: true,
178        }
179    }
180
181    fn reset_object_locations(&mut self) {
182        self.object_locations = RwLock::new(Self::initial_object_locations(
183            &self.packs,
184            self.eager_object_locations,
185        ));
186    }
187
188    fn object_location(&self, id: &PackObjectId) -> Result<Option<usize>> {
189        {
190            let index = self
191                .object_locations
192                .read()
193                .unwrap_or_else(std::sync::PoisonError::into_inner);
194            if let Some(location) = index.locations.get(id) {
195                return Ok(Some(location.pack_index));
196            }
197            if index.complete {
198                return Ok(None);
199            }
200        }
201
202        let mut index = self
203            .object_locations
204            .write()
205            .unwrap_or_else(std::sync::PoisonError::into_inner);
206        if !index.complete {
207            for (pack_index, pack) in self.packs.iter().enumerate() {
208                let Some(reader) = pack.reader() else {
209                    continue;
210                };
211                let Ok(objects) = reader.indexed_read_tiers() else {
212                    continue;
213                };
214                for (object_id, tier) in objects {
215                    remember_location(&mut index.locations, object_id, pack_index, tier);
216                }
217            }
218            index.complete = true;
219        }
220        Ok(index.locations.get(id).map(|location| location.pack_index))
221    }
222
223    /// Locate one object through each pack's sorted index without building the
224    /// cross-pack map. Newer packs are checked first because incremental
225    /// snapshots usually place the current state/tree chain in the newest pack.
226    fn point_object_location(&self, id: &PackObjectId) -> Result<Option<usize>> {
227        {
228            let index = self
229                .object_locations
230                .read()
231                .unwrap_or_else(std::sync::PoisonError::into_inner);
232            if let Some(location) = index.locations.get(id) {
233                return Ok(Some(location.pack_index));
234            }
235            if index.complete {
236                return Ok(None);
237            }
238        }
239        for (pack_index, pack) in self.packs.iter().enumerate().rev() {
240            let Some(reader) = pack.reader() else {
241                continue;
242            };
243            if reader.contains_object(id)? {
244                return Ok(Some(pack_index));
245            }
246        }
247        Ok(None)
248    }
249
250    /// Add a complete pack/index pair to the in-memory format index.
251    pub fn add_pack(&mut self, pack_path: PathBuf, index_path: PathBuf) -> Result<()> {
252        if self.packs.iter().any(|pack| pack.pack_path == pack_path) {
253            return Ok(());
254        }
255        let reader = PackReader::open(&pack_path, &index_path)?;
256        let objects = reader.indexed_read_tiers()?;
257        let pack_index = self.packs.len();
258        let cached = CachedPack::validated(pack_path, index_path, reader);
259        self.packs.push(cached);
260        let mut index = self
261            .object_locations
262            .write()
263            .unwrap_or_else(std::sync::PoisonError::into_inner);
264        if index.complete {
265            for (id, tier) in objects {
266                remember_location(&mut index.locations, id, pack_index, tier);
267            }
268        }
269        Ok(())
270    }
271
272    /// Check whether the immutable pack set on disk differs from this snapshot.
273    ///
274    /// Comparing only counts misses the decisive repack transition (`one old`
275    /// → `one replacement`). Exact path comparison lets another `FsStore`
276    /// recover after an atomic cutover even when cardinality is unchanged.
277    /// Half-installed packs remain filtered by `discover_pack_paths`.
278    pub fn needs_reload(&self) -> Result<bool> {
279        let discovered = Self::discover_pack_paths(&self.packs_dir)?;
280        Ok(discovered.len() != self.packs.len()
281            || discovered
282                .iter()
283                .zip(&self.packs)
284                .any(|((pack, index), cached)| {
285                    *pack != cached.pack_path || *index != cached.index_path
286                }))
287    }
288
289    /// Reload the pack list when the immutable pack set changed on disk.
290    ///
291    /// Catches the multi-instance case: two `FsStore`s back the same
292    /// shared object dir (typical for lightweight thread worktrees,
293    /// where the worktree's repo opens its own store but points at
294    /// the main repo's `.heddle/`). When the worktree's store installs
295    /// a new pack, the main repo's already-open `pack_manager`
296    /// doesn't know about it; without this `get_blob`/`has_blob`
297    /// from the main repo would surface "object not found".
298    pub fn reload_if_stale(&mut self) -> Result<bool> {
299        if !self.needs_reload()? {
300            return Ok(false);
301        }
302        debug!("PackManager: pack set changed under us, reloading");
303        self.reload()?;
304        Ok(true)
305    }
306
307    pub fn get_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
308        let Some(pack_index) = self.point_object_location(id)? else {
309            trace!("Object not found in any pack");
310            return Ok(None);
311        };
312        let Some(reader) = self.packs[pack_index].reader() else {
313            return Ok(None);
314        };
315        let object = reader.get_object(id)?;
316        if object.is_some() {
317            trace!("Found object in pack");
318        }
319        Ok(object)
320    }
321
322    /// Return the physical tier that will serve `id`.
323    ///
324    /// When both layouts contain the same immutable object, lookup always
325    /// selects the hot random-access record before a solid frame.
326    pub fn object_read_tier(&self, id: &PackObjectId) -> Result<Option<PackReadTier>> {
327        let _ = self.object_location(id)?;
328        let index = self
329            .object_locations
330            .read()
331            .unwrap_or_else(std::sync::PoisonError::into_inner);
332        Ok(index.locations.get(id).map(|location| location.tier))
333    }
334
335    /// Read `id` from one specific discovered pack without building the
336    /// cross-pack location index. The record identity remains validated by
337    /// [`PackReader`]; object-domain consumers must validate decoded content.
338    pub fn get_object_from_pack(
339        &self,
340        pack_path: &Path,
341        id: &PackObjectId,
342    ) -> Result<Option<(ObjectType, Vec<u8>)>> {
343        let Some(pack) = self.packs.iter().find(|pack| pack.pack_path == pack_path) else {
344            return Ok(None);
345        };
346        let Some(reader) = pack.reader() else {
347            return Ok(None);
348        };
349        reader.get_object(id)
350    }
351
352    /// List the identities in one specific pack without building the
353    /// cross-pack location index.
354    pub fn list_ids_from_pack(&self, pack_path: &Path) -> Result<Vec<PackObjectId>> {
355        let Some(pack) = self.packs.iter().find(|pack| pack.pack_path == pack_path) else {
356            return Ok(Vec::new());
357        };
358        let Some(reader) = pack.reader() else {
359            return Ok(Vec::new());
360        };
361        reader.list_ids()
362    }
363
364    #[instrument(skip(self), fields(hash = %hash.short()))]
365    pub fn get_hashed_object(&self, hash: &ContentHash) -> Result<Option<(ObjectType, Vec<u8>)>> {
366        self.get_object(&PackObjectId::Hash(*hash))
367    }
368
369    /// Look up the logical object type without decoding the object payload.
370    pub fn get_hashed_object_type(&self, hash: &ContentHash) -> Result<Option<ObjectType>> {
371        let id = PackObjectId::Hash(*hash);
372        let Some(pack_index) = self.point_object_location(&id)? else {
373            return Ok(None);
374        };
375        let Some(reader) = self.packs[pack_index].reader() else {
376            return Ok(None);
377        };
378        reader.get_hashed_object_type(hash)
379    }
380
381    /// Zero-copy variant of `get_hashed_object`. Returns
382    /// [`bytes::Bytes`] views into the underlying pack mmap when
383    /// the entry is non-delta and stored uncompressed; falls back
384    /// to the standard decompress-into-Vec path otherwise.
385    pub fn get_hashed_object_bytes(
386        &self,
387        hash: &ContentHash,
388    ) -> Result<Option<(ObjectType, bytes::Bytes)>> {
389        let id = PackObjectId::Hash(*hash);
390        let Some(pack_index) = self.point_object_location(&id)? else {
391            return Ok(None);
392        };
393        let Some(reader) = self.packs[pack_index].reader() else {
394            return Ok(None);
395        };
396        reader.get_object_bytes(&id)
397    }
398
399    pub fn has_object(&self, hash: &ContentHash) -> bool {
400        self.point_object_location(&PackObjectId::Hash(*hash))
401            .is_ok_and(|location| location.is_some())
402    }
403
404    /// Look up the uncompressed size of `hash` across all loaded
405    /// packs without decompressing the payload. Returns `Ok(None)`
406    /// when the object isn't in any loaded pack.
407    pub fn get_hashed_object_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
408        let id = PackObjectId::Hash(*hash);
409        let Some(pack_index) = self.point_object_location(&id)? else {
410            return Ok(None);
411        };
412        let Some(reader) = self.packs[pack_index].reader() else {
413            return Ok(None);
414        };
415        reader.get_hashed_object_size(hash)
416    }
417
418    pub fn has_object_id(&self, id: &PackObjectId) -> bool {
419        self.object_location(id)
420            .is_ok_and(|location| location.is_some())
421    }
422
423    /// List all object hashes across all packs.
424    pub fn list_all_hashes(&self) -> Result<Vec<ContentHash>> {
425        let mut hashes = Vec::new();
426        for pack in &self.packs {
427            if let Some(reader) = pack.reader() {
428                hashes.extend(reader.list_hashes()?);
429            }
430        }
431        Ok(hashes)
432    }
433
434    pub fn list_all_ids(&self) -> Result<Vec<PackObjectId>> {
435        let mut ids = Vec::new();
436        for pack in &self.packs {
437            if let Some(reader) = pack.reader() {
438                ids.extend(reader.list_ids()?);
439            }
440        }
441        Ok(ids)
442    }
443
444    /// Return paths of all pack files (for deletion during aggressive repack).
445    pub fn pack_file_paths(&self) -> Vec<(&Path, &Path)> {
446        self.packs
447            .iter()
448            .map(|pack| (pack.pack_path.as_path(), pack.index_path.as_path()))
449            .collect()
450    }
451
452    pub fn pack_count(&self) -> usize {
453        self.packs.len()
454    }
455
456    pub fn packs_dir(&self) -> &Path {
457        &self.packs_dir
458    }
459}
460
461fn pack_modified(path: &Path) -> SystemTime {
462    fs::metadata(path)
463        .and_then(|metadata| metadata.modified())
464        .unwrap_or(SystemTime::UNIX_EPOCH)
465}
466
467fn remember_location(
468    locations: &mut HashMap<PackObjectId, ObjectLocation>,
469    id: PackObjectId,
470    pack_index: usize,
471    tier: PackReadTier,
472) {
473    let candidate = ObjectLocation { pack_index, tier };
474    match locations.get_mut(&id) {
475        Some(existing)
476            if existing.tier == PackReadTier::SolidFrame && tier == PackReadTier::Hot =>
477        {
478            *existing = candidate;
479        }
480        Some(_) => {}
481        None => {
482            locations.insert(id, candidate);
483        }
484    }
485}
486
487fn force_eager_pack_index() -> bool {
488    std::env::var("HEDDLE_PERF_FORCE_EAGER_PACK_INDEX")
489        .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"))
490}
491
492#[cfg(test)]
493#[path = "manager_tests.rs"]
494mod tests;