heddle-pack 0.15.0

Heddle's pack and delta storage format.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
// SPDX-License-Identifier: Apache-2.0
//! Pack file manager for coordinating multiple pack files.

use std::{
    collections::HashMap,
    fs,
    path::{Path, PathBuf},
    sync::{OnceLock, RwLock},
    time::SystemTime,
};

use tracing::{debug, instrument, trace};

use crate::{
    object::ContentHash,
    store::{
        Result,
        pack::{ObjectType, PackObjectId, PackReadTier, PackReader},
    },
};

/// Format-only coordinator for loaded pack and index files.
///
/// Object-domain indexes belong in a wrapper owned by the consuming crate.
pub struct PackManager {
    packs_dir: PathBuf,
    packs: Vec<CachedPack>,
    object_locations: RwLock<ObjectLocationIndex>,
    eager_object_locations: bool,
}

#[derive(Default)]
struct ObjectLocationIndex {
    locations: HashMap<PackObjectId, ObjectLocation>,
    complete: bool,
}

#[derive(Clone, Copy)]
struct ObjectLocation {
    pack_index: usize,
    tier: PackReadTier,
}

struct CachedPack {
    pack_path: PathBuf,
    index_path: PathBuf,
    reader: OnceLock<Option<PackReader<'static>>>,
}

impl CachedPack {
    fn discovered(pack_path: PathBuf, index_path: PathBuf) -> Self {
        Self {
            pack_path,
            index_path,
            reader: OnceLock::new(),
        }
    }

    fn validated(pack_path: PathBuf, index_path: PathBuf, reader: PackReader<'static>) -> Self {
        Self {
            pack_path,
            index_path,
            reader: OnceLock::from(Some(reader)),
        }
    }

    fn reader(&self) -> Option<&PackReader<'static>> {
        self.reader
            .get_or_init(
                || match PackReader::open_lazy(&self.pack_path, &self.index_path) {
                    Ok(reader) => Some(reader),
                    Err(error) => {
                        debug!(pack = ?self.pack_path, %error, "Failed to open pack");
                        None
                    }
                },
            )
            .as_ref()
    }

    fn verified_reader(&self) -> Option<&PackReader<'static>> {
        self.reader
            .get_or_init(
                || match PackReader::open(&self.pack_path, &self.index_path) {
                    Ok(reader) => Some(reader),
                    Err(error) => {
                        debug!(pack = ?self.pack_path, %error, "Failed to open pack");
                        None
                    }
                },
            )
            .as_ref()
    }
}

impl PackManager {
    pub fn new(packs_dir: PathBuf) -> Self {
        Self::new_with_index_mode(packs_dir, force_eager_pack_index())
    }

    fn new_with_index_mode(packs_dir: PathBuf, eager_object_locations: bool) -> Self {
        let packs = Self::load_packs(&packs_dir).unwrap_or_default();
        let object_locations = Self::initial_object_locations(&packs, eager_object_locations);
        Self {
            packs_dir,
            packs,
            object_locations: RwLock::new(object_locations),
            eager_object_locations,
        }
    }

    fn discover_pack_paths(packs_dir: &Path) -> Result<Vec<(PathBuf, PathBuf)>> {
        let mut packs = Vec::new();

        if !packs_dir.exists() {
            return Ok(packs);
        }

        for entry in fs::read_dir(packs_dir)? {
            let entry = entry?;
            let path = entry.path();

            if path.extension().map(|e| e == "pack").unwrap_or(false) {
                let index_path = path.with_extension("idx");
                if index_path.exists() {
                    packs.push((path, index_path));
                }
            }
        }

        // Pack names are content hashes, so lexical order says nothing about
        // recency. Keep the oldest pack first: point lookups walk this vector
        // backwards and current snapshot trees overwhelmingly live in the
        // newest incremental pack. The path is a deterministic tie-breaker
        // for filesystems with coarse timestamp precision.
        packs.sort_by(|left, right| {
            pack_modified(&left.0)
                .cmp(&pack_modified(&right.0))
                .then_with(|| left.0.cmp(&right.0))
        });

        debug!(count = packs.len(), "Discovered pack files");
        Ok(packs)
    }

    fn load_packs(packs_dir: &Path) -> Result<Vec<CachedPack>> {
        Ok(Self::discover_pack_paths(packs_dir)?
            .into_iter()
            .map(|(pack_path, index_path)| CachedPack::discovered(pack_path, index_path))
            .collect())
    }

    pub fn reload(&mut self) -> Result<()> {
        self.packs = Self::load_packs(&self.packs_dir)?;
        self.reset_object_locations();
        Ok(())
    }

    fn initial_object_locations(packs: &[CachedPack], eager: bool) -> ObjectLocationIndex {
        if !eager {
            return ObjectLocationIndex::default();
        }
        let mut locations = HashMap::new();
        for (pack_index, pack) in packs.iter().enumerate() {
            let Some(reader) = pack.verified_reader() else {
                continue;
            };
            let Ok(objects) = reader.indexed_read_tiers() else {
                continue;
            };
            for (id, tier) in objects {
                remember_location(&mut locations, id, pack_index, tier);
            }
        }
        ObjectLocationIndex {
            locations,
            complete: true,
        }
    }

    fn reset_object_locations(&mut self) {
        self.object_locations = RwLock::new(Self::initial_object_locations(
            &self.packs,
            self.eager_object_locations,
        ));
    }

    fn object_location(&self, id: &PackObjectId) -> Result<Option<usize>> {
        {
            let index = self
                .object_locations
                .read()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            if let Some(location) = index.locations.get(id) {
                return Ok(Some(location.pack_index));
            }
            if index.complete {
                return Ok(None);
            }
        }

        let mut index = self
            .object_locations
            .write()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        if !index.complete {
            for (pack_index, pack) in self.packs.iter().enumerate() {
                let Some(reader) = pack.reader() else {
                    continue;
                };
                let Ok(objects) = reader.indexed_read_tiers() else {
                    continue;
                };
                for (object_id, tier) in objects {
                    remember_location(&mut index.locations, object_id, pack_index, tier);
                }
            }
            index.complete = true;
        }
        Ok(index.locations.get(id).map(|location| location.pack_index))
    }

    /// Locate one object through each pack's sorted index without building the
    /// cross-pack map. Newer packs are checked first because incremental
    /// snapshots usually place the current state/tree chain in the newest pack.
    fn point_object_location(&self, id: &PackObjectId) -> Result<Option<usize>> {
        {
            let index = self
                .object_locations
                .read()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            if let Some(location) = index.locations.get(id) {
                return Ok(Some(location.pack_index));
            }
            if index.complete {
                return Ok(None);
            }
        }
        for (pack_index, pack) in self.packs.iter().enumerate().rev() {
            let Some(reader) = pack.reader() else {
                continue;
            };
            if reader.contains_object(id)? {
                return Ok(Some(pack_index));
            }
        }
        Ok(None)
    }

    /// Add a complete pack/index pair to the in-memory format index.
    pub fn add_pack(&mut self, pack_path: PathBuf, index_path: PathBuf) -> Result<()> {
        if self.packs.iter().any(|pack| pack.pack_path == pack_path) {
            return Ok(());
        }
        let reader = PackReader::open(&pack_path, &index_path)?;
        let objects = reader.indexed_read_tiers()?;
        let pack_index = self.packs.len();
        let cached = CachedPack::validated(pack_path, index_path, reader);
        self.packs.push(cached);
        let mut index = self
            .object_locations
            .write()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        if index.complete {
            for (id, tier) in objects {
                remember_location(&mut index.locations, id, pack_index, tier);
            }
        }
        Ok(())
    }

    /// Check whether the immutable pack set on disk differs from this snapshot.
    ///
    /// Comparing only counts misses the decisive repack transition (`one old`
    /// → `one replacement`). Exact path comparison lets another `FsStore`
    /// recover after an atomic cutover even when cardinality is unchanged.
    /// Half-installed packs remain filtered by `discover_pack_paths`.
    pub fn needs_reload(&self) -> Result<bool> {
        let discovered = Self::discover_pack_paths(&self.packs_dir)?;
        Ok(discovered.len() != self.packs.len()
            || discovered
                .iter()
                .zip(&self.packs)
                .any(|((pack, index), cached)| {
                    *pack != cached.pack_path || *index != cached.index_path
                }))
    }

    /// Reload the pack list when the immutable pack set changed on disk.
    ///
    /// Catches the multi-instance case: two `FsStore`s back the same
    /// shared object dir (typical for lightweight thread worktrees,
    /// where the worktree's repo opens its own store but points at
    /// the main repo's `.heddle/`). When the worktree's store installs
    /// a new pack, the main repo's already-open `pack_manager`
    /// doesn't know about it; without this `get_blob`/`has_blob`
    /// from the main repo would surface "object not found".
    pub fn reload_if_stale(&mut self) -> Result<bool> {
        if !self.needs_reload()? {
            return Ok(false);
        }
        debug!("PackManager: pack set changed under us, reloading");
        self.reload()?;
        Ok(true)
    }

    pub fn get_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
        let Some(pack_index) = self.point_object_location(id)? else {
            trace!("Object not found in any pack");
            return Ok(None);
        };
        let Some(reader) = self.packs[pack_index].reader() else {
            return Ok(None);
        };
        let object = reader.get_object(id)?;
        if object.is_some() {
            trace!("Found object in pack");
        }
        Ok(object)
    }

    /// Return the physical tier that will serve `id`.
    ///
    /// When both layouts contain the same immutable object, lookup always
    /// selects the hot random-access record before a solid frame.
    pub fn object_read_tier(&self, id: &PackObjectId) -> Result<Option<PackReadTier>> {
        let _ = self.object_location(id)?;
        let index = self
            .object_locations
            .read()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        Ok(index.locations.get(id).map(|location| location.tier))
    }

    /// Read `id` from one specific discovered pack without building the
    /// cross-pack location index. The record identity remains validated by
    /// [`PackReader`]; object-domain consumers must validate decoded content.
    pub fn get_object_from_pack(
        &self,
        pack_path: &Path,
        id: &PackObjectId,
    ) -> Result<Option<(ObjectType, Vec<u8>)>> {
        let Some(pack) = self.packs.iter().find(|pack| pack.pack_path == pack_path) else {
            return Ok(None);
        };
        let Some(reader) = pack.reader() else {
            return Ok(None);
        };
        reader.get_object(id)
    }

    /// List the identities in one specific pack without building the
    /// cross-pack location index.
    pub fn list_ids_from_pack(&self, pack_path: &Path) -> Result<Vec<PackObjectId>> {
        let Some(pack) = self.packs.iter().find(|pack| pack.pack_path == pack_path) else {
            return Ok(Vec::new());
        };
        let Some(reader) = pack.reader() else {
            return Ok(Vec::new());
        };
        reader.list_ids()
    }

    #[instrument(skip(self), fields(hash = %hash.short()))]
    pub fn get_hashed_object(&self, hash: &ContentHash) -> Result<Option<(ObjectType, Vec<u8>)>> {
        self.get_object(&PackObjectId::Hash(*hash))
    }

    /// Look up the logical object type without decoding the object payload.
    pub fn get_hashed_object_type(&self, hash: &ContentHash) -> Result<Option<ObjectType>> {
        let id = PackObjectId::Hash(*hash);
        let Some(pack_index) = self.point_object_location(&id)? else {
            return Ok(None);
        };
        let Some(reader) = self.packs[pack_index].reader() else {
            return Ok(None);
        };
        reader.get_hashed_object_type(hash)
    }

    /// Zero-copy variant of `get_hashed_object`. Returns
    /// [`bytes::Bytes`] views into the underlying pack mmap when
    /// the entry is non-delta and stored uncompressed; falls back
    /// to the standard decompress-into-Vec path otherwise.
    pub fn get_hashed_object_bytes(
        &self,
        hash: &ContentHash,
    ) -> Result<Option<(ObjectType, bytes::Bytes)>> {
        let id = PackObjectId::Hash(*hash);
        let Some(pack_index) = self.point_object_location(&id)? else {
            return Ok(None);
        };
        let Some(reader) = self.packs[pack_index].reader() else {
            return Ok(None);
        };
        reader.get_object_bytes(&id)
    }

    pub fn has_object(&self, hash: &ContentHash) -> bool {
        self.point_object_location(&PackObjectId::Hash(*hash))
            .is_ok_and(|location| location.is_some())
    }

    /// Look up the uncompressed size of `hash` across all loaded
    /// packs without decompressing the payload. Returns `Ok(None)`
    /// when the object isn't in any loaded pack.
    pub fn get_hashed_object_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
        let id = PackObjectId::Hash(*hash);
        let Some(pack_index) = self.point_object_location(&id)? else {
            return Ok(None);
        };
        let Some(reader) = self.packs[pack_index].reader() else {
            return Ok(None);
        };
        reader.get_hashed_object_size(hash)
    }

    pub fn has_object_id(&self, id: &PackObjectId) -> bool {
        self.object_location(id)
            .is_ok_and(|location| location.is_some())
    }

    /// List all object hashes across all packs.
    pub fn list_all_hashes(&self) -> Result<Vec<ContentHash>> {
        let mut hashes = Vec::new();
        for pack in &self.packs {
            if let Some(reader) = pack.reader() {
                hashes.extend(reader.list_hashes()?);
            }
        }
        Ok(hashes)
    }

    pub fn list_all_ids(&self) -> Result<Vec<PackObjectId>> {
        let mut ids = Vec::new();
        for pack in &self.packs {
            if let Some(reader) = pack.reader() {
                ids.extend(reader.list_ids()?);
            }
        }
        Ok(ids)
    }

    /// Return paths of all pack files (for deletion during aggressive repack).
    pub fn pack_file_paths(&self) -> Vec<(&Path, &Path)> {
        self.packs
            .iter()
            .map(|pack| (pack.pack_path.as_path(), pack.index_path.as_path()))
            .collect()
    }

    pub fn pack_count(&self) -> usize {
        self.packs.len()
    }

    pub fn packs_dir(&self) -> &Path {
        &self.packs_dir
    }
}

fn pack_modified(path: &Path) -> SystemTime {
    fs::metadata(path)
        .and_then(|metadata| metadata.modified())
        .unwrap_or(SystemTime::UNIX_EPOCH)
}

fn remember_location(
    locations: &mut HashMap<PackObjectId, ObjectLocation>,
    id: PackObjectId,
    pack_index: usize,
    tier: PackReadTier,
) {
    let candidate = ObjectLocation { pack_index, tier };
    match locations.get_mut(&id) {
        Some(existing)
            if existing.tier == PackReadTier::SolidFrame && tier == PackReadTier::Hot =>
        {
            *existing = candidate;
        }
        Some(_) => {}
        None => {
            locations.insert(id, candidate);
        }
    }
}

fn force_eager_pack_index() -> bool {
    std::env::var("HEDDLE_PERF_FORCE_EAGER_PACK_INDEX")
        .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"))
}

#[cfg(test)]
#[path = "manager_tests.rs"]
mod tests;