OXYGENHUB
# fbda43e7d8ed oxygen / crates / oxy-core / src / store.rs
43.6 KiB · Rust · last changed Store: write batches for snapshots, fetched bundles, Git imports and checkpoints; large batches become one pack with a single fsync (1 GiB commit 63 s to 8.8 s on SSD; spec 0.6r) 1 month ago source blame history raw
1
//! Object store (spec §13.1, §13.3, §13.4): loose objects, one file per
2
//! object under `objects/loose/<hh>/<rest>`, and packs, `pack-<id>.pack`
3
//! with a sorted `pack-<id>.idx`, holding zstd-compressed objects. Reads
4
//! check loose objects first, then every pack; every read re-hashes.
5
//!
6
//! Pack file: `OXYPACK1` then entries of
7
//! `type u8 | flags u8 (bit 0: zstd) | stored_len u32 | raw_len u32 | ref 32 | body`.
8
//! Index file: `OXYIDX01` then `count u32` then sorted entries of
9
//! `ref 32 | offset u64 | stored_len u32 | raw_len u32 | type u8 | flags u8`.
10
11
use crate::attest::Attest;
12
use crate::hash::{hash_object, header, ObjectRef, ObjectType};
13
use crate::identity::{Identity, Signature};
14
use crate::objects::{Change, ObjectError, StateObject};
15
use rand::RngCore;
16
use std::cell::RefCell;
17
use std::fs;
18
use std::io::{self, Read, Seek, SeekFrom, Write};
19
use std::path::{Path, PathBuf};
20
21
const PACK_MAGIC: &[u8; 8] = b"OXYPACK1";
22
const IDX_MAGIC: &[u8; 8] = b"OXYIDX01";
23
const IDX_ENTRY: usize = 32 + 8 + 4 + 4 + 1 + 1;
24
const FLAG_ZSTD: u8 = 1;
25
const ZSTD_LEVEL: i32 = 3;
26
/// Largest object a pack entry may claim; anything bigger is corruption,
27
/// and refusing it keeps a bad index from driving huge allocations.
28
const MAX_OBJECT_LEN: u32 = 1 << 30;
29
30
#[derive(Clone, Copy, Debug)]
31
struct PackEntry {
32
    offset: u64,
33
    stored_len: u32,
34
    raw_len: u32,
35
    ty: ObjectType,
36
    flags: u8,
37
}
38
39
/// One pack's index, fully in memory and sorted by ObjectRef.
40
#[derive(Debug)]
41
struct PackIndex {
42
    path: PathBuf,
43
    refs: Vec<ObjectRef>,
44
    entries: Vec<PackEntry>,
45
}
46
47
impl PackIndex {
48
    fn load(idx_path: &Path, pack_path: &Path) -> Result<PackIndex> {
49
        let data = fs::read(idx_path)?;
50
        let bad = |m: &str| StoreError::Corrupt(ObjectRef::ZERO, format!("{}: {m}", idx_path.display()));
51
        if data.len() < 12 || &data[..8] != IDX_MAGIC {
52
            return Err(bad("bad index header"));
53
        }
54
        let count = u32::from_le_bytes(data[8..12].try_into().unwrap()) as usize;
55
        if data.len() != 12 + count * IDX_ENTRY {
56
            return Err(bad("index length mismatch"));
57
        }
58
        let pack_len = fs::metadata(pack_path)?.len();
59
        let mut refs = Vec::with_capacity(count);
60
        let mut entries = Vec::with_capacity(count);
61
        for i in 0..count {
62
            let e = &data[12 + i * IDX_ENTRY..12 + (i + 1) * IDX_ENTRY];
63
            let r = ObjectRef::from_slice(&e[..32]).unwrap();
64
            if let Some(prev) = refs.last() {
65
                if r <= *prev {
66
                    return Err(bad("index not sorted"));
67
                }
68
            }
69
            refs.push(r);
70
            let entry = PackEntry {
71
                offset: u64::from_le_bytes(e[32..40].try_into().unwrap()),
72
                stored_len: u32::from_le_bytes(e[40..44].try_into().unwrap()),
73
                raw_len: u32::from_le_bytes(e[44..48].try_into().unwrap()),
74
                ty: ObjectType::ALL.get(e[48] as usize).copied().ok_or_else(|| bad("unknown object type"))?,
75
                flags: e[49],
76
            };
77
            if entry.stored_len > MAX_OBJECT_LEN || entry.raw_len > MAX_OBJECT_LEN || entry.offset.saturating_add(entry.stored_len as u64) > pack_len {
78
                return Err(bad("index entry points outside the pack"));
79
            }
80
            entries.push(entry);
81
        }
82
        Ok(PackIndex { path: pack_path.to_path_buf(), refs, entries })
83
    }
84
85
    fn find(&self, r: &ObjectRef) -> Option<PackEntry> {
86
        self.refs.binary_search(r).ok().map(|i| self.entries[i])
87
    }
88
89
    fn read(&self, r: &ObjectRef, e: PackEntry) -> Result<(ObjectType, Vec<u8>)> {
90
        let mut f = fs::File::open(&self.path)?;
91
        f.seek(SeekFrom::Start(e.offset))?;
92
        let mut stored = vec![0u8; e.stored_len as usize];
93
        f.read_exact(&mut stored)?;
94
        let body = if e.flags & FLAG_ZSTD != 0 {
95
            zstd::bulk::decompress(&stored, e.raw_len as usize).map_err(|err| StoreError::Corrupt(*r, format!("zstd: {err}")))?
96
        } else {
97
            stored
98
        };
99
        if body.len() != e.raw_len as usize || hash_object(e.ty, &body) != *r {
100
            return Err(StoreError::Corrupt(*r, "packed object does not match its reference".into()));
101
        }
102
        Ok((e.ty, body))
103
    }
104
}
105
106
fn type_index(ty: ObjectType) -> u8 {
107
    ObjectType::ALL.iter().position(|t| *t == ty).unwrap() as u8
108
}
109
110
/// What `pack_loose` did.
111
#[derive(Debug, Clone, Default, PartialEq, Eq)]
112
pub struct PackReport {
113
    pub objects: usize,
114
    pub raw_bytes: u64,
115
    pub packed_bytes: u64,
116
}
117
118
/// Packs tolerated before `merge_packs` folds them into one (spec §13.4).
119
pub const MAX_PACKS: usize = 16;
120
121
/// What `prune` did.
122
#[derive(Debug, Clone, Default, PartialEq, Eq)]
123
pub struct PruneReport {
124
    pub objects: usize,
125
    pub bytes: u64,
126
    /// Unreachable objects left alone because they are younger than the grace period.
127
    pub deferred: usize,
128
}
129
130
#[derive(Debug, thiserror::Error)]
131
pub enum StoreError {
132
    #[error("io: {0}")]
133
    Io(#[from] io::Error),
134
    #[error("corrupt object {0}: {1}")]
135
    Corrupt(ObjectRef, String),
136
    #[error("object {0}")]
137
    Object(#[from] ObjectError),
138
    #[error("object {0} is a {1}, expected {2}")]
139
    WrongType(ObjectRef, &'static str, &'static str),
140
    #[error("object {0} not found")]
141
    NotFound(ObjectRef),
142
}
143
144
pub type Result<T> = std::result::Result<T, StoreError>;
145
146
#[derive(Debug)]
147
pub struct Store {
148
    dir: PathBuf,
149
    packs: RefCell<Vec<PackIndex>>,
150
    /// An open write batch (spec §13.4): objects go here instead of to loose
151
    /// files, and are written out with one `fsync` when the batch finishes.
152
    pending: RefCell<Option<Pending>>,
153
    batch_depth: std::cell::Cell<u32>,
154
}
155
156
/// Objects written since `begin_batch`. Small batches stay in memory and
157
/// become loose objects; once `SPILL_OBJECTS` or `SPILL_BYTES` is exceeded
158
/// the batch streams into a pack file instead.
159
#[derive(Debug)]
160
struct Pending {
161
    buffered: Vec<(ObjectType, Vec<u8>)>,
162
    buffered_bytes: usize,
163
    lookup: std::collections::HashMap<ObjectRef, PendingEntry>,
164
    pack: Option<PendingPack>,
165
    report: PackReport,
166
}
167
168
#[derive(Debug, Clone, Copy)]
169
enum PendingEntry {
170
    Buffered(usize),
171
    Packed(PackEntry),
172
}
173
174
#[derive(Debug)]
175
struct PendingPack {
176
    id: String,
177
    tmp_path: PathBuf,
178
    writer: io::BufWriter<fs::File>,
179
    offset: u64,
180
    index: Vec<(ObjectRef, PackEntry)>,
181
}
182
183
const SPILL_OBJECTS: usize = 256;
184
const SPILL_BYTES: usize = 8 << 20;
185
186
/// Finishes the enclosing batch when dropped; call `finish` to see errors.
187
pub struct Batch<'a> {
188
    store: &'a Store,
189
    done: bool,
190
}
191
192
impl Batch<'_> {
193
    pub fn finish(mut self) -> Result<()> {
194
        self.done = true;
195
        self.store.finish_batch()
196
    }
197
}
198
199
impl Drop for Batch<'_> {
200
    fn drop(&mut self) {
201
        if !self.done {
202
            let _ = self.store.finish_batch();
203
        }
204
    }
205
}
206
207
impl Store {
208
    pub fn open(dir: impl Into<PathBuf>) -> io::Result<Store> {
209
        let dir = dir.into();
210
        fs::create_dir_all(dir.join("loose"))?;
211
        let store = Store { dir, packs: RefCell::new(Vec::new()), pending: RefCell::new(None), batch_depth: std::cell::Cell::new(0) };
212
        store.reload_packs().map_err(|e| io::Error::other(e.to_string()))?;
213
        Ok(store)
214
    }
215
216
    fn reload_packs(&self) -> Result<()> {
217
        let mut packs = Vec::new();
218
        let mut names: Vec<PathBuf> = fs::read_dir(&self.dir)?
219
            .filter_map(|e| e.ok().map(|e| e.path()))
220
            .filter(|p| p.extension().is_some_and(|x| x == "idx") && p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("pack-")))
221
            .collect();
222
        names.sort();
223
        for idx in names {
224
            let pack = idx.with_extension("pack");
225
            if pack.is_file() {
226
                packs.push(PackIndex::load(&idx, &pack)?);
227
            }
228
        }
229
        *self.packs.borrow_mut() = packs;
230
        Ok(())
231
    }
232
233
    pub fn pack_count(&self) -> usize {
234
        self.packs.borrow().len()
235
    }
236
237
    fn find_packed(&self, r: &ObjectRef) -> Option<(usize, PackEntry)> {
238
        self.packs.borrow().iter().enumerate().find_map(|(i, p)| p.find(r).map(|e| (i, e)))
239
    }
240
241
    /// Loose objects on disk, with their paths.
242
    fn loose_objects(&self) -> io::Result<Vec<(ObjectRef, PathBuf)>> {
243
        let mut out = Vec::new();
244
        let loose = self.dir.join("loose");
245
        for d in fs::read_dir(&loose)? {
246
            let d = d?;
247
            if !d.file_type()?.is_dir() {
248
                continue;
249
            }
250
            let prefix = d.file_name().to_string_lossy().to_string();
251
            for f in fs::read_dir(d.path())? {
252
                let f = f?;
253
                let name = f.file_name().to_string_lossy().to_string();
254
                if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) {
255
                    out.push((r, f.path()));
256
                }
257
            }
258
        }
259
        out.sort();
260
        Ok(out)
261
    }
262
263
    pub fn loose_count(&self) -> io::Result<usize> {
264
        Ok(self.loose_objects()?.len())
265
    }
266
267
    /// Move every loose object into a new pack (spec §13.4). Loose files are
268
    /// deleted only after the pack and its index are durable.
269
    pub fn pack_loose(&self) -> Result<PackReport> {
270
        let loose = self.loose_objects()?;
271
        self.write_pack(&loose.iter().map(|(r, _)| *r).collect::<Vec<_>>(), &loose.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(), &[])
272
    }
273
274
    /// Fold every pack into one when there are more than `MAX_PACKS`
275
    /// (spec §13.4). Returns how many packs were merged.
276
    pub fn merge_packs(&self) -> Result<usize> {
277
        let (refs, paths): (Vec<ObjectRef>, Vec<PathBuf>) = {
278
            let packs = self.packs.borrow();
279
            if packs.len() <= MAX_PACKS {
280
                return Ok(0);
281
            }
282
            (packs.iter().flat_map(|p| p.refs.iter().copied()).collect(), packs.iter().map(|p| p.path.clone()).collect())
283
        };
284
        let n = paths.len();
285
        self.write_pack(&refs, &[], &paths)?;
286
        Ok(n)
287
    }
288
289
    /// Delete every object not in `keep` (spec §21). Loose objects and packs
290
    /// younger than `grace` are left alone, since a concurrent writer may not
291
    /// have recorded its references yet. Packs holding unreachable objects
292
    /// are rewritten without them. Signature sidecars and seal-index entries
293
    /// of deleted objects go too.
294
    pub fn prune(&self, keep: &std::collections::HashSet<ObjectRef>, grace: std::time::Duration) -> Result<PruneReport> {
295
        let now = std::time::SystemTime::now();
296
        let old_enough = |m: &fs::Metadata| m.modified().map(|t| now.duration_since(t).unwrap_or_default() >= grace).unwrap_or(true);
297
        let mut report = PruneReport::default();
298
        // Loose objects.
299
        for (r, path) in self.loose_objects()? {
300
            if keep.contains(&r) {
301
                continue;
302
            }
303
            let meta = fs::metadata(&path)?;
304
            if !old_enough(&meta) {
305
                report.deferred += 1;
306
                continue;
307
            }
308
            report.bytes += meta.len();
309
            report.objects += 1;
310
            fs::remove_file(&path)?;
311
            let _ = fs::remove_dir(path.parent().unwrap());
312
            self.remove_sidecars(&r)?;
313
        }
314
        // Packs: rewrite those that hold unreachable objects.
315
        let mut keep_refs: Vec<ObjectRef> = Vec::new();
316
        let mut old_paths: Vec<PathBuf> = Vec::new();
317
        let mut rewrite = false;
318
        for p in self.packs.borrow().iter() {
319
            let meta = fs::metadata(&p.path)?;
320
            let unreachable = p.refs.iter().filter(|r| !keep.contains(r)).count();
321
            if unreachable == 0 {
322
                continue;
323
            }
324
            if !old_enough(&meta) {
325
                report.deferred += unreachable;
326
                continue;
327
            }
328
            rewrite = true;
329
            old_paths.push(p.path.clone());
330
            for (r, e) in p.refs.iter().zip(p.entries.iter()) {
331
                if keep.contains(r) {
332
                    keep_refs.push(*r);
333
                } else {
334
                    report.objects += 1;
335
                    report.bytes += e.stored_len as u64;
336
                }
337
            }
338
        }
339
        if rewrite {
340
            let dropped: Vec<ObjectRef> = {
341
                let packs = self.packs.borrow();
342
                packs.iter().filter(|p| old_paths.contains(&p.path)).flat_map(|p| p.refs.iter().copied()).filter(|r| !keep.contains(r)).collect()
343
            };
344
            self.write_pack(&keep_refs, &[], &old_paths)?;
345
            for r in dropped {
346
                self.remove_sidecars(&r)?;
347
            }
348
        }
349
        Ok(report)
350
    }
351
352
    fn remove_sidecars(&self, r: &ObjectRef) -> Result<()> {
353
        for path in [self.sig_path(r), self.seal_index_path(r)] {
354
            match fs::remove_file(&path) {
355
                Ok(()) => {
356
                    let _ = fs::remove_dir(path.parent().unwrap());
357
                }
358
                Err(e) if e.kind() == io::ErrorKind::NotFound => {}
359
                Err(e) => return Err(e.into()),
360
            }
361
        }
362
        Ok(())
363
    }
364
365
    /// Write `refs` into a new pack, then delete `loose_paths` and `old_packs`.
366
    /// Write the index for a complete, synced temporary pack file and move
367
    /// both into place; `index` must be sorted by ObjectRef.
368
    fn finish_pack_files(&self, id: &str, tmp_pack: &Path, index: &[(ObjectRef, PackEntry)]) -> Result<()> {
369
        let pack_path = self.dir.join(format!("pack-{id}.pack"));
370
        let idx_path = self.dir.join(format!("pack-{id}.idx"));
371
        let tmp_idx = self.dir.join(format!("tmp-{id}.idx"));
372
        {
373
            let mut f = io::BufWriter::new(fs::File::create(&tmp_idx)?);
374
            f.write_all(IDX_MAGIC)?;
375
            f.write_all(&(index.len() as u32).to_le_bytes())?;
376
            for (r, e) in index {
377
                f.write_all(&r.0)?;
378
                f.write_all(&e.offset.to_le_bytes())?;
379
                f.write_all(&e.stored_len.to_le_bytes())?;
380
                f.write_all(&e.raw_len.to_le_bytes())?;
381
                f.write_all(&[type_index(e.ty), e.flags])?;
382
            }
383
            f.flush()?;
384
            f.get_ref().sync_all()?;
385
        }
386
        fs::rename(tmp_pack, &pack_path)?;
387
        fs::rename(&tmp_idx, &idx_path)?;
388
        self.reload_packs()
389
    }
390
391
    fn write_pack(&self, refs: &[ObjectRef], loose_paths: &[PathBuf], old_packs: &[PathBuf]) -> Result<PackReport> {
392
        if refs.is_empty() {
393
            for p in old_packs {
394
                fs::remove_file(p)?;
395
                fs::remove_file(p.with_extension("idx"))?;
396
            }
397
            self.reload_packs()?;
398
            return Ok(PackReport::default());
399
        }
400
        let mut refs: Vec<ObjectRef> = refs.to_vec();
401
        refs.sort();
402
        refs.dedup();
403
        let mut nonce = [0u8; 8];
404
        rand::rngs::OsRng.fill_bytes(&mut nonce);
405
        let id = hex::encode(nonce);
406
        let tmp_pack = self.dir.join(format!("tmp-{id}.pack"));
407
        let mut report = PackReport::default();
408
        let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(refs.len());
409
        {
410
            let mut f = io::BufWriter::new(fs::File::create(&tmp_pack)?);
411
            f.write_all(PACK_MAGIC)?;
412
            let mut offset = PACK_MAGIC.len() as u64;
413
            for r in &refs {
414
                let (ty, body) = self.get(r)?;
415
                let (flags, stored) = if self.is_nocompress(r) {
416
                    (0, body.clone())
417
                } else {
418
                    let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
419
                    if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.clone()) }
420
                };
421
                let header_len = 1 + 1 + 4 + 4 + 32;
422
                f.write_all(&[type_index(ty), flags])?;
423
                f.write_all(&(stored.len() as u32).to_le_bytes())?;
424
                f.write_all(&(body.len() as u32).to_le_bytes())?;
425
                f.write_all(&r.0)?;
426
                f.write_all(&stored)?;
427
                index.push((*r, PackEntry { offset: offset + header_len, stored_len: stored.len() as u32, raw_len: body.len() as u32, ty, flags }));
428
                offset += header_len + stored.len() as u64;
429
                report.objects += 1;
430
                report.raw_bytes += body.len() as u64;
431
                report.packed_bytes += stored.len() as u64;
432
            }
433
            f.flush()?;
434
            f.get_ref().sync_all()?;
435
        }
436
        self.finish_pack_files(&id, &tmp_pack, &index)?;
437
        for p in old_packs {
438
            fs::remove_file(p)?;
439
            fs::remove_file(p.with_extension("idx"))?;
440
        }
441
        self.reload_packs()?;
442
        for path in loose_paths {
443
            fs::remove_file(path)?;
444
            if let Some(dir) = path.parent() {
445
                let _ = fs::remove_dir(dir);
446
            }
447
        }
448
        Ok(report)
449
    }
450
451
    pub fn dir(&self) -> &Path {
452
        &self.dir
453
    }
454
455
    fn path_for(&self, r: &ObjectRef) -> PathBuf {
456
        let h = r.to_hex();
457
        self.dir.join("loose").join(&h[..2]).join(&h[2..])
458
    }
459
460
    pub fn has(&self, r: &ObjectRef) -> bool {
461
        self.path_for(r).is_file() || self.find_packed(r).is_some() || self.pending.borrow().as_ref().is_some_and(|p| p.lookup.contains_key(r))
462
    }
463
464
    /// Start a write batch: until the matching `finish`, `put` collects
465
    /// objects and writes them out together, with one `fsync` for a large
466
    /// batch (a pack) instead of one per object. Batches nest; only the
467
    /// outermost does the work. Objects are readable while pending.
468
    pub fn begin_batch(&self) -> Result<Batch<'_>> {
469
        let depth = self.batch_depth.get();
470
        if depth == 0 {
471
            *self.pending.borrow_mut() = Some(Pending { buffered: vec![], buffered_bytes: 0, lookup: Default::default(), pack: None, report: PackReport::default() });
472
        }
473
        self.batch_depth.set(depth + 1);
474
        Ok(Batch { store: self, done: false })
475
    }
476
477
    fn finish_batch(&self) -> Result<()> {
478
        let depth = self.batch_depth.get();
479
        if depth == 0 {
480
            return Ok(());
481
        }
482
        self.batch_depth.set(depth - 1);
483
        if depth > 1 {
484
            return Ok(());
485
        }
486
        let Some(pending) = self.pending.borrow_mut().take() else { return Ok(()) };
487
        match pending.pack {
488
            None => {
489
                for (ty, body) in &pending.buffered {
490
                    self.put_loose(*ty, body)?;
491
                }
492
                Ok(())
493
            }
494
            Some(mut pack) => {
495
                pack.writer.flush()?;
496
                pack.writer.get_ref().sync_all()?;
497
                drop(pack.writer);
498
                pack.index.sort_by_key(|(r, _)| *r);
499
                self.finish_pack_files(&pack.id, &pack.tmp_path, &pack.index)?;
500
                Ok(())
501
            }
502
        }
503
    }
504
505
    /// Whether a batch is open and holds `r`.
506
    fn pending_get(&self, r: &ObjectRef) -> Option<Result<(ObjectType, Vec<u8>)>> {
507
        let mut guard = self.pending.borrow_mut();
508
        let pending = guard.as_mut()?;
509
        let entry = *pending.lookup.get(r)?;
510
        Some(match entry {
511
            PendingEntry::Buffered(i) => {
512
                let (ty, body) = &pending.buffered[i];
513
                Ok((*ty, body.clone()))
514
            }
515
            PendingEntry::Packed(e) => {
516
                let Some(pack) = pending.pack.as_mut() else { return Some(Err(StoreError::NotFound(*r))) };
517
                match pack.writer.flush() {
518
                    Ok(()) => PackIndex { path: pack.tmp_path.clone(), refs: vec![], entries: vec![] }.read(r, e),
519
                    Err(err) => Err(err.into()),
520
                }
521
            }
522
        })
523
    }
524
525
    /// Append one object to the pending pack file.
526
    fn pending_pack_write(&self, pack: &mut PendingPack, r: &ObjectRef, ty: ObjectType, body: &[u8], report: &mut PackReport) -> Result<PackEntry> {
527
        let (flags, stored) = if self.is_nocompress(r) {
528
            (0, body.to_vec())
529
        } else {
530
            let compressed = zstd::bulk::compress(body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
531
            if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.to_vec()) }
532
        };
533
        let header_len = 1 + 1 + 4 + 4 + 32;
534
        pack.writer.write_all(&[type_index(ty), flags])?;
535
        pack.writer.write_all(&(stored.len() as u32).to_le_bytes())?;
536
        pack.writer.write_all(&(body.len() as u32).to_le_bytes())?;
537
        pack.writer.write_all(&r.0)?;
538
        pack.writer.write_all(&stored)?;
539
        let entry = PackEntry { offset: pack.offset + header_len, stored_len: stored.len() as u32, raw_len: body.len() as u32, ty, flags };
540
        pack.offset += header_len + stored.len() as u64;
541
        pack.index.push((*r, entry));
542
        report.objects += 1;
543
        report.raw_bytes += body.len() as u64;
544
        report.packed_bytes += stored.len() as u64;
545
        Ok(entry)
546
    }
547
548
    fn put_pending(&self, r: ObjectRef, ty: ObjectType, body: &[u8]) -> Result<()> {
549
        let mut guard = self.pending.borrow_mut();
550
        let pending = guard.as_mut().expect("batch open");
551
        if pending.lookup.contains_key(&r) {
552
            return Ok(());
553
        }
554
        if pending.pack.is_none() && pending.buffered.len() < SPILL_OBJECTS && pending.buffered_bytes + body.len() <= SPILL_BYTES {
555
            pending.buffered.push((ty, body.to_vec()));
556
            pending.buffered_bytes += body.len();
557
            pending.lookup.insert(r, PendingEntry::Buffered(pending.buffered.len() - 1));
558
            return Ok(());
559
        }
560
        if pending.pack.is_none() {
561
            // Spill: open the pack and move the buffered objects into it.
562
            let mut nonce = [0u8; 8];
563
            rand::rngs::OsRng.fill_bytes(&mut nonce);
564
            let id = hex::encode(nonce);
565
            let tmp_path = self.dir.join(format!("tmp-{id}.pack"));
566
            let mut writer = io::BufWriter::new(fs::File::create(&tmp_path)?);
567
            writer.write_all(PACK_MAGIC)?;
568
            let mut pack = PendingPack { id, tmp_path, writer, offset: PACK_MAGIC.len() as u64, index: vec![] };
569
            let buffered = std::mem::take(&mut pending.buffered);
570
            pending.buffered_bytes = 0;
571
            let mut report = std::mem::take(&mut pending.report);
572
            for (bty, bbody) in &buffered {
573
                let br = hash_object(*bty, bbody);
574
                let e = self.pending_pack_write(&mut pack, &br, *bty, bbody, &mut report)?;
575
                pending.lookup.insert(br, PendingEntry::Packed(e));
576
            }
577
            pending.report = report;
578
            pending.pack = Some(pack);
579
        }
580
        let mut report = std::mem::take(&mut pending.report);
581
        let pack = pending.pack.as_mut().unwrap();
582
        let e = self.pending_pack_write(pack, &r, ty, body, &mut report)?;
583
        pending.report = report;
584
        pending.lookup.insert(r, PendingEntry::Packed(e));
585
        Ok(())
586
    }
587
588
    /// Store `body` as an object of type `ty`; idempotent.
589
    pub fn put(&self, ty: ObjectType, body: &[u8]) -> Result<ObjectRef> {
590
        let r = hash_object(ty, body);
591
        let path = self.path_for(&r);
592
        if path.is_file() || self.find_packed(&r).is_some() {
593
            return Ok(r);
594
        }
595
        if self.pending.borrow().is_some() {
596
            self.put_pending(r, ty, body)?;
597
            return Ok(r);
598
        }
599
        self.put_loose(ty, body)?;
600
        Ok(r)
601
    }
602
603
    /// Write one loose object atomically with its own `fsync`.
604
    fn put_loose(&self, ty: ObjectType, body: &[u8]) -> Result<ObjectRef> {
605
        let r = hash_object(ty, body);
606
        let path = self.path_for(&r);
607
        if path.is_file() {
608
            return Ok(r);
609
        }
610
        fs::create_dir_all(path.parent().unwrap())?;
611
        let mut nonce = [0u8; 8];
612
        rand::rngs::OsRng.fill_bytes(&mut nonce);
613
        let tmp = self.dir.join("loose").join(format!("tmp-{}", hex::encode(nonce)));
614
        {
615
            let mut f = fs::File::create(&tmp)?;
616
            f.write_all(&header(ty, body.len()))?;
617
            f.write_all(body)?;
618
            f.sync_all()?;
619
        }
620
        fs::rename(&tmp, &path)?;
621
        Ok(r)
622
    }
623
624
    /// Read and verify an object, loose, packed, or pending in a batch.
625
    pub fn get(&self, r: &ObjectRef) -> Result<(ObjectType, Vec<u8>)> {
626
        let path = self.path_for(r);
627
        let data = match fs::read(&path) {
628
            Ok(d) => d,
629
            Err(e) if e.kind() == io::ErrorKind::NotFound => {
630
                return match self.find_packed(r) {
631
                    Some((i, e)) => self.packs.borrow()[i].read(r, e),
632
                    None => self.pending_get(r).unwrap_or(Err(StoreError::NotFound(*r))),
633
                };
634
            }
635
            Err(e) => return Err(e.into()),
636
        };
637
        let nul = data.iter().position(|&b| b == 0).ok_or_else(|| StoreError::Corrupt(*r, "no header".into()))?;
638
        let head = std::str::from_utf8(&data[..nul]).map_err(|_| StoreError::Corrupt(*r, "header not UTF-8".into()))?;
639
        let mut parts = head.split(' ');
640
        let (Some("oxy1"), Some(ty), Some(len), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
641
            return Err(StoreError::Corrupt(*r, format!("bad header {head:?}")));
642
        };
643
        let ty = ObjectType::from_name(ty).ok_or_else(|| StoreError::Corrupt(*r, format!("unknown type {ty}")))?;
644
        let len: usize = len.parse().map_err(|_| StoreError::Corrupt(*r, "bad length".into()))?;
645
        let body = &data[nul + 1..];
646
        if body.len() != len {
647
            return Err(StoreError::Corrupt(*r, format!("length {} != header {len}", body.len())));
648
        }
649
        if hash_object(ty, body) != *r {
650
            return Err(StoreError::Corrupt(*r, "hash mismatch".into()));
651
        }
652
        Ok((ty, body.to_vec()))
653
    }
654
655
    pub fn get_typed(&self, r: &ObjectRef, want: ObjectType) -> Result<Vec<u8>> {
656
        let (ty, body) = self.get(r)?;
657
        if ty != want {
658
            return Err(StoreError::WrongType(*r, ty.name(), want.name()));
659
        }
660
        Ok(body)
661
    }
662
663
    pub fn put_change(&self, c: &Change) -> Result<ObjectRef> {
664
        self.put(ObjectType::Change, &c.encode())
665
    }
666
667
    pub fn get_change(&self, r: &ObjectRef) -> Result<Change> {
668
        Ok(Change::decode(&self.get_typed(r, ObjectType::Change)?)?)
669
    }
670
671
    pub fn put_chunk(&self, bytes: &[u8]) -> Result<ObjectRef> {
672
        self.put(ObjectType::Chunk, bytes)
673
    }
674
675
    /// Store a chunk that packs will keep uncompressed (`nocompress`, spec §13.3).
676
    pub fn put_chunk_nocompress(&self, bytes: &[u8]) -> Result<ObjectRef> {
677
        let r = hash_object(ObjectType::Chunk, bytes);
678
        let dir = self.dir.join("nocompress");
679
        fs::create_dir_all(&dir)?;
680
        let _ = fs::File::create(dir.join(r.to_hex()));
681
        self.put(ObjectType::Chunk, bytes)
682
    }
683
684
    fn is_nocompress(&self, r: &ObjectRef) -> bool {
685
        self.dir.join("nocompress").join(r.to_hex()).exists()
686
    }
687
688
    pub fn put_state(&self, st: &StateObject) -> Result<ObjectRef> {
689
        self.put(ObjectType::State, &st.encode())
690
    }
691
692
    pub fn get_state(&self, r: &ObjectRef) -> Result<StateObject> {
693
        Ok(StateObject::decode(&self.get_typed(r, ObjectType::State)?)?)
694
    }
695
696
    pub fn put_attest(&self, a: &Attest) -> Result<ObjectRef> {
697
        self.put(ObjectType::Attest, &a.encode())
698
    }
699
700
    pub fn get_attest(&self, r: &ObjectRef) -> Result<Attest> {
701
        Ok(Attest::decode(&self.get_typed(r, ObjectType::Attest)?)?)
702
    }
703
704
    fn seal_index_path(&self, change: &ObjectRef) -> PathBuf {
705
        let h = change.to_hex();
706
        self.dir.join("seals").join(&h[..2]).join(&h[2..])
707
    }
708
709
    /// Record that seal attestation `attest` covers `change`.
710
    pub fn index_seal(&self, change: &ObjectRef, attest: &ObjectRef) -> Result<()> {
711
        let mut seals = self.seals_for(change)?;
712
        if seals.contains(attest) {
713
            return Ok(());
714
        }
715
        seals.push(*attest);
716
        seals.sort();
717
        let path = self.seal_index_path(change);
718
        fs::create_dir_all(path.parent().unwrap())?;
719
        let text: String = seals.iter().map(|s| format!("{}\n", s.to_hex())).collect();
720
        let tmp = path.with_extension("tmp");
721
        fs::write(&tmp, text)?;
722
        fs::rename(&tmp, &path)?;
723
        Ok(())
724
    }
725
726
    /// Seal attestations known to cover `change`.
727
    pub fn seals_for(&self, change: &ObjectRef) -> Result<Vec<ObjectRef>> {
728
        match fs::read_to_string(self.seal_index_path(change)) {
729
            Ok(t) => Ok(t.lines().filter_map(ObjectRef::from_hex).collect()),
730
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]),
731
            Err(e) => Err(e.into()),
732
        }
733
    }
734
735
    pub fn put_identity(&self, id: &Identity) -> Result<ObjectRef> {
736
        self.put(ObjectType::Identity, &id.encode())
737
    }
738
739
    pub fn get_identity(&self, r: &ObjectRef) -> Result<Identity> {
740
        Ok(Identity::decode(&self.get_typed(r, ObjectType::Identity)?)?)
741
    }
742
743
    fn sig_path(&self, r: &ObjectRef) -> PathBuf {
744
        let h = r.to_hex();
745
        self.dir.join("sigs").join(&h[..2]).join(&h[2..])
746
    }
747
748
    /// Signatures stored alongside object `r` (spec §5.4). Empty if none.
749
    pub fn signatures(&self, r: &ObjectRef) -> Result<Vec<Signature>> {
750
        match fs::read(self.sig_path(r)) {
751
            Ok(b) => Ok(Signature::decode_list(&b)?),
752
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]),
753
            Err(e) => Err(e.into()),
754
        }
755
    }
756
757
    /// Attach a signature to object `r`, replacing any earlier one by the same key.
758
    pub fn put_signature(&self, r: &ObjectRef, sig: &Signature) -> Result<()> {
759
        let mut sigs = self.signatures(r)?;
760
        sigs.retain(|s| s.key != sig.key);
761
        sigs.push(sig.clone());
762
        sigs.sort_by_key(|s| s.key);
763
        let path = self.sig_path(r);
764
        fs::create_dir_all(path.parent().unwrap())?;
765
        let mut nonce = [0u8; 8];
766
        rand::rngs::OsRng.fill_bytes(&mut nonce);
767
        let tmp = self.dir.join(format!("sigs-tmp-{}", hex::encode(nonce)));
768
        {
769
            let mut f = fs::File::create(&tmp)?;
770
            f.write_all(&Signature::encode_list(&sigs))?;
771
            f.sync_all()?;
772
        }
773
        fs::rename(&tmp, &path)?;
774
        Ok(())
775
    }
776
777
    /// Every object reference in the store, loose and packed (slow; for gc and tests).
778
    pub fn list(&self) -> io::Result<Vec<ObjectRef>> {
779
        let mut out: Vec<ObjectRef> = self.loose_objects()?.into_iter().map(|(r, _)| r).collect();
780
        for p in self.packs.borrow().iter() {
781
            out.extend(p.refs.iter().copied());
782
        }
783
        out.sort();
784
        out.dedup();
785
        Ok(out)
786
    }
787
}
788
789
#[cfg(test)]
790
mod tests {
791
    use super::*;
792
793
    #[test]
794
    fn put_get_verify() {
795
        let dir = tempfile::tempdir().unwrap();
796
        let s = Store::open(dir.path().join("objects")).unwrap();
797
        let r = s.put(ObjectType::Chunk, b"hello").unwrap();
798
        assert!(s.has(&r));
799
        assert_eq!(s.get(&r).unwrap(), (ObjectType::Chunk, b"hello".to_vec()));
800
        assert_eq!(s.put(ObjectType::Chunk, b"hello").unwrap(), r);
801
        assert_eq!(s.list().unwrap(), vec![r]);
802
        assert!(matches!(s.get_typed(&r, ObjectType::Blob), Err(StoreError::WrongType(..))));
803
        assert!(matches!(s.get(&ObjectRef([9; 32])), Err(StoreError::NotFound(_))));
804
805
        // Tamper: flip a byte, read must fail.
806
        let path = s.path_for(&r);
807
        let mut data = fs::read(&path).unwrap();
808
        let last = data.len() - 1;
809
        data[last] ^= 1;
810
        fs::write(&path, data).unwrap();
811
        assert!(matches!(s.get(&r), Err(StoreError::Corrupt(..))));
812
    }
813
814
    #[test]
815
    fn packing_keeps_every_object_readable() {
816
        let dir = tempfile::tempdir().unwrap();
817
        let s = Store::open(dir.path()).unwrap();
818
        let mut refs = Vec::new();
819
        for i in 0..300u32 {
820
            let body = format!("object {i} ").repeat(1 + (i % 7) as usize);
821
            refs.push((s.put(ObjectType::Chunk, body.as_bytes()).unwrap(), body));
822
        }
823
        let c = crate::objects::tests::sample_change();
824
        let cr = s.put_change(&c).unwrap();
825
        assert_eq!(s.loose_count().unwrap(), 301);
826
        let report = s.pack_loose().unwrap();
827
        assert_eq!(report.objects, 301);
828
        assert!(report.packed_bytes < report.raw_bytes, "{report:?}");
829
        assert_eq!(s.loose_count().unwrap(), 0);
830
        assert_eq!(s.pack_count(), 1);
831
        for (r, body) in &refs {
832
            assert!(s.has(r));
833
            assert_eq!(s.get(r).unwrap(), (ObjectType::Chunk, body.as_bytes().to_vec()));
834
        }
835
        assert_eq!(s.get_change(&cr).unwrap(), c);
836
        assert_eq!(s.list().unwrap().len(), 301);
837
        // Re-putting a packed object is a no-op; new objects go loose and a
838
        // second pack coexists with the first.
839
        assert_eq!(s.put(ObjectType::Chunk, refs[0].1.as_bytes()).unwrap(), refs[0].0);
840
        assert_eq!(s.loose_count().unwrap(), 0);
841
        let extra = s.put(ObjectType::Chunk, b"after packing").unwrap();
842
        s.pack_loose().unwrap();
843
        assert_eq!(s.pack_count(), 2);
844
        assert_eq!(s.get(&extra).unwrap().1, b"after packing");
845
        // A reopened store sees the packs.
846
        let s2 = Store::open(dir.path()).unwrap();
847
        assert_eq!(s2.pack_count(), 2);
848
        assert_eq!(s2.get(&refs[5].0).unwrap().1, refs[5].1.as_bytes());
849
        assert!(matches!(s2.get(&ObjectRef([9; 32])), Err(StoreError::NotFound(_))));
850
        // Corrupting an object's body inside the pack is detected on read.
851
        let target = refs[5].0;
852
        let (pack_path, offset) = {
853
            let packs = s2.packs.borrow();
854
            let (i, e) = s2.find_packed(&target).unwrap();
855
            (packs[i].path.clone(), e.offset as usize)
856
        };
857
        let mut data = fs::read(&pack_path).unwrap();
858
        data[offset] ^= 0xff;
859
        fs::write(&pack_path, data).unwrap();
860
        let s3 = Store::open(dir.path()).unwrap();
861
        assert!(matches!(s3.get(&target), Err(StoreError::Corrupt(..))));
862
        assert_eq!(s3.get(&refs[6].0).unwrap().1, refs[6].1.as_bytes(), "other objects are unaffected");
863
    }
864
865
    #[test]
866
    fn nocompress_chunks_pack_raw() {
867
        let dir = tempfile::tempdir().unwrap();
868
        let s = Store::open(dir.path()).unwrap();
869
        let body = vec![b'a'; 10_000];
870
        let raw = s.put_chunk_nocompress(&body).unwrap();
871
        let compressible = s.put(ObjectType::Chunk, &vec![b'b'; 10_000]).unwrap();
872
        s.pack_loose().unwrap();
873
        let packs = s.packs.borrow();
874
        let (i, e) = s.find_packed(&raw).unwrap();
875
        assert_eq!((e.flags, e.stored_len as usize), (0, 10_000));
876
        let (_, c) = s.find_packed(&compressible).unwrap();
877
        assert_eq!(c.flags, FLAG_ZSTD);
878
        assert!(c.stored_len < 10_000);
879
        assert_eq!(packs[i].read(&raw, e).unwrap().1, body);
880
    }
881
882
    #[test]
883
    fn many_packs_merge_into_one() {
884
        let dir = tempfile::tempdir().unwrap();
885
        let s = Store::open(dir.path()).unwrap();
886
        let mut refs = Vec::new();
887
        for i in 0..(MAX_PACKS + 2) {
888
            refs.push(s.put(ObjectType::Chunk, format!("pack {i}").as_bytes()).unwrap());
889
            s.pack_loose().unwrap();
890
        }
891
        assert_eq!(s.pack_count(), MAX_PACKS + 2);
892
        assert_eq!(s.merge_packs().unwrap(), MAX_PACKS + 2);
893
        assert_eq!(s.pack_count(), 1);
894
        assert_eq!(s.merge_packs().unwrap(), 0);
895
        for (i, r) in refs.iter().enumerate() {
896
            assert_eq!(s.get(r).unwrap().1, format!("pack {i}").as_bytes());
897
        }
898
        assert_eq!(Store::open(dir.path()).unwrap().pack_count(), 1);
899
    }
900
901
    #[test]
902
    fn prune_removes_unreachable_loose_and_packed_objects() {
903
        use std::collections::HashSet;
904
        let dir = tempfile::tempdir().unwrap();
905
        let s = Store::open(dir.path()).unwrap();
906
        let keep_a = s.put(ObjectType::Chunk, b"keep a").unwrap();
907
        let drop_a = s.put(ObjectType::Chunk, b"drop a").unwrap();
908
        s.pack_loose().unwrap();
909
        let keep_b = s.put(ObjectType::Chunk, b"keep b").unwrap();
910
        let drop_b = s.put(ObjectType::Chunk, b"drop b").unwrap();
911
        let sk = crate::identity::SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] };
912
        let id = sk.identity("a", "b", 0);
913
        let key = s.put_identity(&id).unwrap();
914
        s.put_signature(&drop_b, &sk.sign(crate::identity::Domain::Change, &key, &drop_b)).unwrap();
915
        s.index_seal(&drop_a, &ObjectRef([3; 32])).unwrap();
916
        let keep: HashSet<ObjectRef> = [keep_a, keep_b, key].into_iter().collect();
917
        // Everything is too young under a grace period.
918
        let r = s.prune(&keep, std::time::Duration::from_secs(3600)).unwrap();
919
        assert_eq!((r.objects, r.deferred), (0, 2));
920
        let r = s.prune(&keep, std::time::Duration::ZERO).unwrap();
921
        assert_eq!(r.objects, 2, "{r:?}");
922
        assert!(s.has(&keep_a) && s.has(&keep_b) && s.has(&key));
923
        assert!(!s.has(&drop_a) && !s.has(&drop_b));
924
        assert!(s.signatures(&drop_b).unwrap().is_empty());
925
        assert!(s.seals_for(&drop_a).unwrap().is_empty());
926
        assert_eq!(s.pack_count(), 1);
927
        assert_eq!(s.get(&keep_a).unwrap().1, b"keep a");
928
        assert_eq!(s.list().unwrap().len(), 3);
929
    }
930
931
    #[test]
932
    fn signatures_are_stored_beside_objects() {
933
        use crate::identity::{Domain, SecretKey};
934
        let dir = tempfile::tempdir().unwrap();
935
        let s = Store::open(dir.path()).unwrap();
936
        let r = s.put(ObjectType::Chunk, b"x").unwrap();
937
        assert!(s.signatures(&r).unwrap().is_empty());
938
        let sk = SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] };
939
        let id = sk.identity("a", "b", 0);
940
        let key = s.put_identity(&id).unwrap();
941
        assert_eq!(s.get_identity(&key).unwrap(), id);
942
        let sig = sk.sign(Domain::Change, &key, &r);
943
        s.put_signature(&r, &sig).unwrap();
944
        s.put_signature(&r, &sig).unwrap();
945
        assert_eq!(s.signatures(&r).unwrap(), vec![sig]);
946
        // The object itself is untouched by its signatures.
947
        assert_eq!(s.get(&r).unwrap().1, b"x");
948
    }
949
950
    #[test]
951
    fn change_round_trip_through_store() {
952
        let dir = tempfile::tempdir().unwrap();
953
        let s = Store::open(dir.path()).unwrap();
954
        let c = crate::objects::tests::sample_change();
955
        let r = s.put_change(&c).unwrap();
956
        assert_eq!(r, c.hash());
957
        assert_eq!(s.get_change(&r).unwrap(), c);
958
    }
959
960
    fn xorshift(state: &mut u64) -> u64 {
961
        *state ^= *state << 13;
962
        *state ^= *state >> 7;
963
        *state ^= *state << 17;
964
        *state
965
    }
966
967
    /// Random corruption of pack and index files never panics: reads fail
968
    /// with an error or the store refuses to open, and untouched objects
969
    /// stay readable when the index survives.
970
    #[test]
971
    fn corrupted_packs_and_indexes_fail_cleanly() {
972
        let dir = tempfile::tempdir().unwrap();
973
        let objects = dir.path().join("objects");
974
        let s = Store::open(&objects).unwrap();
975
        let mut refs = Vec::new();
976
        let mut seed = 0x9e3779b97f4a7c15u64;
977
        for i in 0..40u32 {
978
            let len = (xorshift(&mut seed) % 5000) as usize;
979
            let mut body = vec![0u8; len];
980
            for b in body.iter_mut() {
981
                *b = if i % 2 == 0 { (xorshift(&mut seed) & 0xff) as u8 } else { b'a' + (i % 26) as u8 };
982
            }
983
            refs.push(s.put(ObjectType::Chunk, &body).unwrap());
984
        }
985
        s.pack_loose().unwrap();
986
        let pack = fs::read_dir(&objects).unwrap().filter_map(|e| e.ok().map(|e| e.path())).find(|p| p.extension().is_some_and(|x| x == "pack")).unwrap();
987
        let idx = pack.with_extension("idx");
988
        let pristine_pack = fs::read(&pack).unwrap();
989
        let pristine_idx = fs::read(&idx).unwrap();
990
991
        for round in 0..300u32 {
992
            let (path, pristine) = if round % 2 == 0 { (&pack, &pristine_pack) } else { (&idx, &pristine_idx) };
993
            let mut data = pristine.clone();
994
            match xorshift(&mut seed) % 4 {
995
                0 => {
996
                    let at = (xorshift(&mut seed) as usize) % data.len();
997
                    data[at] ^= 1 << (xorshift(&mut seed) % 8);
998
                }
999
                1 => {
1000
                    let at = (xorshift(&mut seed) as usize) % data.len();
1001
                    data[at] = (xorshift(&mut seed) & 0xff) as u8;
1002
                }
1003
                2 => {
1004
                    let keep = (xorshift(&mut seed) as usize) % data.len();
1005
                    data.truncate(keep);
1006
                }
1007
                _ => {
1008
                    let at = (xorshift(&mut seed) as usize) % data.len();
1009
                    let n = ((xorshift(&mut seed) % 8) + 1) as usize;
1010
                    for k in 0..n.min(data.len() - at) {
1011
                        data[at + k] = 0xff;
1012
                    }
1013
                }
1014
            }
1015
            fs::write(path, &data).unwrap();
1016
            // Opening may refuse a broken index; a store that opens must
1017
            // either return each object intact or report it corrupt/missing.
1018
            if let Ok(s2) = Store::open(&objects) {
1019
                for r in &refs {
1020
                    match s2.get(r) {
1021
                        Ok((ty, body)) => assert_eq!(hash_object(ty, &body), *r, "a returned object always matches its reference"),
1022
                        Err(StoreError::Corrupt(..)) | Err(StoreError::NotFound(_)) | Err(StoreError::Io(_)) => {}
1023
                        Err(e) => panic!("unexpected error {e}"),
1024
                    }
1025
                }
1026
            }
1027
            fs::write(path, pristine).unwrap();
1028
        }
1029
        // Restored files read back in full.
1030
        let s3 = Store::open(&objects).unwrap();
1031
        for r in &refs {
1032
            let (ty, body) = s3.get(r).unwrap();
1033
            assert_eq!(hash_object(ty, &body), *r);
1034
        }
1035
    }
1036
1037
    /// A small batch becomes loose objects; a large one becomes a single
1038
    /// pack. Objects are readable while pending, and batches nest.
1039
    #[test]
1040
    fn batches_write_loose_or_one_pack() {
1041
        let dir = tempfile::tempdir().unwrap();
1042
        let s = Store::open(dir.path().join("objects")).unwrap();
1043
        let outer = s.begin_batch().unwrap();
1044
        let a = s.put(ObjectType::Chunk, b"small a").unwrap();
1045
        {
1046
            let inner = s.begin_batch().unwrap();
1047
            let b = s.put(ObjectType::Chunk, b"small b").unwrap();
1048
            assert!(s.has(&b));
1049
            assert_eq!(s.get(&b).unwrap().1, b"small b");
1050
            inner.finish().unwrap();
1051
            assert_eq!(s.loose_count().unwrap(), 0, "the inner batch defers to the outer one");
1052
        }
1053
        assert_eq!(s.get(&a).unwrap().1, b"small a");
1054
        assert_eq!(s.put(ObjectType::Chunk, b"small a").unwrap(), a, "idempotent while pending");
1055
        outer.finish().unwrap();
1056
        assert_eq!(s.loose_count().unwrap(), 2);
1057
        assert_eq!(s.pack_count(), 0);
1058
        assert_eq!(s.get(&a).unwrap().1, b"small a");
1059
1060
        // Large: spills into one pack, readable during and after.
1061
        let big = s.begin_batch().unwrap();
1062
        let mut refs = Vec::new();
1063
        let mut seed = 7u64;
1064
        for i in 0..300u32 {
1065
            let mut body = vec![b'z'; 40_000];
1066
            if i % 3 == 0 {
1067
                for b in body.iter_mut() {
1068
                    *b = (xorshift(&mut seed) & 0xff) as u8;
1069
                }
1070
            }
1071
            body[..4].copy_from_slice(&i.to_le_bytes());
1072
            let r = if i % 5 == 0 { s.put_chunk_nocompress(&body).unwrap() } else { s.put(ObjectType::Chunk, &body).unwrap() };
1073
            assert_eq!(s.get(&r).unwrap().1, body, "readable while pending (object {i})");
1074
            refs.push((r, body));
1075
        }
1076
        assert!(s.has(&refs[0].0));
1077
        big.finish().unwrap();
1078
        assert_eq!(s.pack_count(), 1);
1079
        assert_eq!(s.loose_count().unwrap(), 2, "nothing from the big batch is loose");
1080
        assert!(fs::read_dir(dir.path().join("objects")).unwrap().flatten().all(|e| !e.file_name().to_string_lossy().starts_with("tmp-")));
1081
        let s2 = Store::open(dir.path().join("objects")).unwrap();
1082
        for (r, body) in &refs {
1083
            assert_eq!(s2.get(r).unwrap().1, *body);
1084
        }
1085
        // A dropped batch still writes its objects.
1086
        {
1087
            let _b = s2.begin_batch().unwrap();
1088
            s2.put(ObjectType::Chunk, b"dropped").unwrap();
1089
        }
1090
        assert_eq!(s2.get(&hash_object(ObjectType::Chunk, b"dropped")).unwrap().1, b"dropped");
1091
    }
1092
}