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};
9
10use tracing::{debug, instrument, trace};
11
12use crate::{
13    object::ContentHash,
14    store::{
15        Result,
16        pack::{ObjectType, PackObjectId, PackReader},
17    },
18};
19
20/// Format-only coordinator for loaded pack and index files.
21///
22/// Object-domain indexes belong in a wrapper owned by the consuming crate.
23pub struct PackManager {
24    packs_dir: PathBuf,
25    packs: Vec<CachedPack>,
26    object_locations: HashMap<PackObjectId, usize>,
27}
28
29struct CachedPack {
30    pack_path: PathBuf,
31    index_path: PathBuf,
32    reader: PackReader<'static>,
33}
34
35impl PackManager {
36    pub fn new(packs_dir: PathBuf) -> Self {
37        let packs = Self::load_packs(&packs_dir).unwrap_or_default();
38        let object_locations = Self::index_object_locations(&packs);
39        Self {
40            packs_dir,
41            packs,
42            object_locations,
43        }
44    }
45
46    fn discover_pack_paths(packs_dir: &Path) -> Result<Vec<(PathBuf, PathBuf)>> {
47        let mut packs = Vec::new();
48
49        if !packs_dir.exists() {
50            return Ok(packs);
51        }
52
53        for entry in fs::read_dir(packs_dir)? {
54            let entry = entry?;
55            let path = entry.path();
56
57            if path.extension().map(|e| e == "pack").unwrap_or(false) {
58                let index_path = path.with_extension("idx");
59                if index_path.exists() {
60                    packs.push((path, index_path));
61                }
62            }
63        }
64
65        packs.sort_by(|left, right| left.0.cmp(&right.0));
66
67        debug!(count = packs.len(), "Discovered pack files");
68        Ok(packs)
69    }
70
71    fn load_packs(packs_dir: &Path) -> Result<Vec<CachedPack>> {
72        let mut cached_packs = Vec::new();
73
74        for (pack_path, index_path) in Self::discover_pack_paths(packs_dir)? {
75            match PackReader::open(&pack_path, &index_path) {
76                Ok(reader) => cached_packs.push(CachedPack {
77                    pack_path,
78                    index_path,
79                    reader,
80                }),
81                Err(error) => {
82                    debug!("Failed to open pack {:?}: {}", pack_path, error);
83                }
84            }
85        }
86
87        Ok(cached_packs)
88    }
89
90    pub fn reload(&mut self) -> Result<()> {
91        let packs = Self::load_packs(&self.packs_dir)?;
92        let object_locations = Self::index_object_locations(&packs);
93        self.packs = packs;
94        self.object_locations = object_locations;
95        Ok(())
96    }
97
98    fn index_object_locations(packs: &[CachedPack]) -> HashMap<PackObjectId, usize> {
99        let mut locations = HashMap::new();
100        for (pack_index, pack) in packs.iter().enumerate() {
101            for id in pack.reader.list_ids() {
102                // Preserve the historical first-pack-wins lookup behavior for
103                // duplicate immutable objects.
104                locations.entry(id).or_insert(pack_index);
105            }
106        }
107        locations
108    }
109
110    /// Add a complete pack/index pair to the in-memory format index.
111    pub fn add_pack(&mut self, pack_path: PathBuf, index_path: PathBuf) -> Result<()> {
112        if self.packs.iter().any(|pack| pack.pack_path == pack_path) {
113            return Ok(());
114        }
115        let cached = CachedPack {
116            reader: PackReader::open(&pack_path, &index_path)?,
117            pack_path,
118            index_path,
119        };
120        let pack_index = self.packs.len();
121        for id in cached.reader.list_ids() {
122            self.object_locations.entry(id).or_insert(pack_index);
123        }
124        self.packs.push(cached);
125        Ok(())
126    }
127
128    /// Cheap check: does the packs directory hold more pack/index
129    /// pairs than we have loaded? Reuses `discover_pack_paths` so
130    /// half-installed packs (a `.pack` whose `.idx` sibling hasn't
131    /// landed yet) are filtered out — otherwise we'd loop forever
132    /// reloading a count we can never match.
133    pub fn needs_reload(&self) -> Result<bool> {
134        Ok(Self::discover_pack_paths(&self.packs_dir)?.len() > self.packs.len())
135    }
136
137    /// Reload the pack list only if the packs directory has more
138    /// pack/index pairs on disk than we know about in memory.
139    ///
140    /// Catches the multi-instance case: two `FsStore`s back the same
141    /// shared object dir (typical for lightweight thread worktrees,
142    /// where the worktree's repo opens its own store but points at
143    /// the main repo's `.heddle/`). When the worktree's store installs
144    /// a new pack, the main repo's already-open `pack_manager`
145    /// doesn't know about it; without this `get_blob`/`has_blob`
146    /// from the main repo would surface "object not found".
147    pub fn reload_if_disk_grew(&mut self) -> Result<bool> {
148        if !self.needs_reload()? {
149            return Ok(false);
150        }
151        debug!("PackManager: pack dir grew under us, reloading");
152        self.reload()?;
153        Ok(true)
154    }
155
156    pub fn get_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
157        let Some(pack_index) = self.object_locations.get(id).copied() else {
158            trace!("Object not found in any pack");
159            return Ok(None);
160        };
161        let object = self.packs[pack_index].reader.get_object(id)?;
162        if object.is_some() {
163            trace!("Found object in pack");
164        }
165        Ok(object)
166    }
167
168    #[instrument(skip(self), fields(hash = %hash.short()))]
169    pub fn get_hashed_object(&self, hash: &ContentHash) -> Result<Option<(ObjectType, Vec<u8>)>> {
170        self.get_object(&PackObjectId::Hash(*hash))
171    }
172
173    /// Zero-copy variant of `get_hashed_object`. Returns
174    /// [`bytes::Bytes`] views into the underlying pack mmap when
175    /// the entry is non-delta and stored uncompressed; falls back
176    /// to the standard decompress-into-Vec path otherwise.
177    pub fn get_hashed_object_bytes(
178        &self,
179        hash: &ContentHash,
180    ) -> Result<Option<(ObjectType, bytes::Bytes)>> {
181        let id = PackObjectId::Hash(*hash);
182        let Some(pack_index) = self.object_locations.get(&id).copied() else {
183            return Ok(None);
184        };
185        self.packs[pack_index].reader.get_object_bytes(&id)
186    }
187
188    pub fn has_object(&self, hash: &ContentHash) -> bool {
189        self.object_locations
190            .contains_key(&PackObjectId::Hash(*hash))
191    }
192
193    /// Look up the uncompressed size of `hash` across all loaded
194    /// packs without decompressing the payload. Returns `Ok(None)`
195    /// when the object isn't in any loaded pack.
196    pub fn get_hashed_object_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
197        let id = PackObjectId::Hash(*hash);
198        let Some(pack_index) = self.object_locations.get(&id).copied() else {
199            return Ok(None);
200        };
201        self.packs[pack_index].reader.get_hashed_object_size(hash)
202    }
203
204    pub fn has_object_id(&self, id: &PackObjectId) -> bool {
205        self.object_locations.contains_key(id)
206    }
207
208    /// List all object hashes across all packs.
209    pub fn list_all_hashes(&self) -> Result<Vec<ContentHash>> {
210        let mut hashes = Vec::new();
211        for pack in &self.packs {
212            hashes.extend(pack.reader.list_hashes());
213        }
214        Ok(hashes)
215    }
216
217    pub fn list_all_ids(&self) -> Result<Vec<PackObjectId>> {
218        let mut ids = Vec::new();
219        for pack in &self.packs {
220            ids.extend(pack.reader.list_ids());
221        }
222        Ok(ids)
223    }
224
225    /// Return paths of all pack files (for deletion during aggressive repack).
226    pub fn pack_file_paths(&self) -> Vec<(&Path, &Path)> {
227        self.packs
228            .iter()
229            .map(|pack| (pack.pack_path.as_path(), pack.index_path.as_path()))
230            .collect()
231    }
232
233    pub fn pack_count(&self) -> usize {
234        self.packs.len()
235    }
236
237    pub fn packs_dir(&self) -> &Path {
238        &self.packs_dir
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use heddle_format::compression::CompressionConfig;
245    use tempfile::TempDir;
246
247    use super::PackManager;
248    use crate::{
249        object::ContentHash,
250        store::pack::{ObjectType, PackBuilder},
251    };
252
253    fn write_pack(
254        root: &std::path::Path,
255        ordinal: usize,
256    ) -> (std::path::PathBuf, std::path::PathBuf, ContentHash) {
257        let payload = format!("pack-object-{ordinal}").into_bytes();
258        let object_id = ContentHash::compute(&payload);
259        let mut builder = PackBuilder::new(CompressionConfig {
260            max_delta_size: 0,
261            ..CompressionConfig::default()
262        });
263        builder.add(object_id, ObjectType::Blob, payload);
264        let (pack_data, index_data, _) = builder.build().unwrap();
265        let pack_path = root.join(format!("format-{ordinal:03}.pack"));
266        let index_path = root.join(format!("format-{ordinal:03}.idx"));
267        std::fs::write(&pack_path, pack_data).unwrap();
268        std::fs::write(&index_path, index_data).unwrap();
269        (pack_path, index_path, object_id)
270    }
271
272    #[test]
273    fn object_locator_tracks_incremental_and_reloaded_packs() {
274        let temp = TempDir::new().unwrap();
275        let mut manager = PackManager::new(temp.path().to_path_buf());
276        for ordinal in 0..8 {
277            let (pack_path, index_path, _) = write_pack(temp.path(), ordinal);
278            manager.add_pack(pack_path, index_path).unwrap();
279        }
280
281        let ids = manager.list_all_ids().unwrap();
282        assert_eq!(ids.len(), 8);
283        for id in &ids {
284            assert!(manager.has_object_id(id));
285            assert!(manager.get_object(id).unwrap().is_some());
286        }
287
288        let reloaded = PackManager::new(temp.path().to_path_buf());
289        assert_eq!(reloaded.pack_count(), 8);
290        for id in &ids {
291            assert!(reloaded.has_object_id(id));
292            assert!(reloaded.get_object(id).unwrap().is_some());
293        }
294    }
295}