OXYGENHUB

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) 8f10a07a1dbe

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 00:04 UTC
Signature
✔ signed by Andy McCall
Hash
8f10a07a1dbe4c01e71db2a654e0371a60635577b0a8a0d5a69759e5dcf2d18a
Depends on
252e8cb23fbc 2863456a0f6d 2b872d074ddb 30ea9c1e70fa 4e88e774777a 614541c3cebd 79ab8528ff62 96f7610fa666 982864068a59 ba160f60219e c3069537d7c5 cdb5beba7f05 d02e545dc265 ff0af1ee299d
Depended on by
771661aa50f4 8156967b882c ef7d4ea6f33b 767ac6cd0545 092457020692 648189388f1a 58dfe0e134eb 9ae4d15a0e4b cd4eb55e3b3d b30a880fa409 a17fc622ca43 f9466021286a ce14f0d68b15 da652d6392d8 192fad1e46a2 6f8aeb996558 a9f686edf60b cc7a00565161 afaba5f1306d 0bdc3f91a39f 4dcb8c0916b7 75167672ebcd b7f102fcbb17
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
26 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/oxy-core/src/repo/checkpoint.rs +2 −0
…
49 unchanged lines
50 50
    /// Write a checkpoint of `state` (whose heads are `heads`) for `bookmark`.
51 51
    pub(crate) fn write_checkpoint(&self, bookmark: &str, state: &State, heads: &[ChangeHash]) -> Result<(ObjectRef, usize)> {
52 52
        let payload = state.checkpoint_payload();
53 +
        let batch = self.store.begin_batch()?;
53 54
        let blob = self.store_blob(&payload)?;
54 55
        let cp = Checkpoint { heads: StateObject::new(heads.to_vec()).heads, count: state.len() as u64, payload: blob };
55 56
        let r = self.store.put(ObjectType::Checkpoint, &cp.encode())?;
57 +
        batch.finish()?;
56 58
        fs::create_dir_all(self.oxy.join("checkpoints"))?;
57 59
        write_atomic(&self.checkpoint_pointer(bookmark), r.to_hex().as_bytes())?;
58 60
        Ok((r, payload.len()))
…
96 unchanged lines
modified crates/oxy-core/src/repo/git.rs +7 −0
…
429 unchanged lines
430 430
    /// Import the commits of `branch` that the bookmark does not have yet,
431 431
    /// then move the bookmark to the union of its heads and theirs.
432 432
    fn import_git_commits(&self, git_dir: &Path, branch: &str, command: &str) -> Result<GitImportReport> {
433 +
        let batch = self.store.begin_batch()?;
434 +
        let result = self.import_git_commits_batched(git_dir, branch, command);
435 +
        batch.finish()?;
436 +
        result
437 +
    }
438 +
439 +
    fn import_git_commits_batched(&self, git_dir: &Path, branch: &str, command: &str) -> Result<GitImportReport> {
433 440
        let commits = list_commits(git_dir, branch)?;
434 441
        let mut cat = CatFile::start(git_dir)?;
435 442
        let mut report = GitImportReport::default();
…
208 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +7 −0
…
124 unchanged lines
125 125
    /// Snapshot the working copy into the working-copy change (spec §12.3).
126 126
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
127 127
        let _lock = self.lock_snapshot()?;
128 +
        let batch = self.store.begin_batch()?;
129 +
        let result = self.snapshot_locked(command);
130 +
        batch.finish()?;
131 +
        result
132 +
    }
133 +
134 +
    fn snapshot_locked(&self, command: &str) -> Result<SnapshotResult> {
128 135
        let (op_ref, op) = self.head_op()?;
129 136
        let (old_hash, old_wc) = self.working_copy()?;
130 137
        let base = self.load_state(&old_wc.deps)?;
…
653 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +7 −0
…
280 unchanged lines
281 281
    }
282 282
283 283
    fn import_bundle_inner(&self, bundle: &Bundle, strict: bool, shallow: bool) -> Result<Vec<ChangeHash>> {
284 +
        let batch = self.store.begin_batch()?;
285 +
        let result = self.import_bundle_batched(bundle, strict, shallow);
286 +
        batch.finish()?;
287 +
        result
288 +
    }
289 +
290 +
    fn import_bundle_batched(&self, bundle: &Bundle, strict: bool, shallow: bool) -> Result<Vec<ChangeHash>> {
284 291
        for (ty, body) in &bundle.objects {
285 292
            match ty {
286 293
                ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State | ObjectType::Checkpoint => {
…
678 unchanged lines
modified crates/oxy-core/src/store.rs +282 −25
…
146 unchanged lines
147 147
pub struct Store {
148 148
    dir: PathBuf,
149 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,
150 166
}
151 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 +
152 207
impl Store {
153 208
    pub fn open(dir: impl Into<PathBuf>) -> io::Result<Store> {
154 209
        let dir = dir.into();
155 210
        fs::create_dir_all(dir.join("loose"))?;
156 -
        let store = Store { dir, packs: RefCell::new(Vec::new()) };
211 +
        let store = Store { dir, packs: RefCell::new(Vec::new()), pending: RefCell::new(None), batch_depth: std::cell::Cell::new(0) };
157 212
        store.reload_packs().map_err(|e| io::Error::other(e.to_string()))?;
158 213
        Ok(store)
159 214
    }
…
148 unchanged lines
308 363
    }
309 364
310 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 +
311 391
    fn write_pack(&self, refs: &[ObjectRef], loose_paths: &[PathBuf], old_packs: &[PathBuf]) -> Result<PackReport> {
312 392
        if refs.is_empty() {
313 393
            for p in old_packs {
…
9 unchanged lines
323 403
        let mut nonce = [0u8; 8];
324 404
        rand::rngs::OsRng.fill_bytes(&mut nonce);
325 405
        let id = hex::encode(nonce);
326 -
        let pack_path = self.dir.join(format!("pack-{id}.pack"));
327 -
        let idx_path = self.dir.join(format!("pack-{id}.idx"));
328 406
        let tmp_pack = self.dir.join(format!("tmp-{id}.pack"));
329 -
        let tmp_idx = self.dir.join(format!("tmp-{id}.idx"));
330 407
        let mut report = PackReport::default();
331 408
        let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(refs.len());
332 409
        {
…
23 unchanged lines
356 433
            f.flush()?;
357 434
            f.get_ref().sync_all()?;
358 435
        }
359 -
        {
360 -
            let mut f = io::BufWriter::new(fs::File::create(&tmp_idx)?);
361 -
            f.write_all(IDX_MAGIC)?;
362 -
            f.write_all(&(index.len() as u32).to_le_bytes())?;
363 -
            for (r, e) in &index {
364 -
                f.write_all(&r.0)?;
365 -
                f.write_all(&e.offset.to_le_bytes())?;
366 -
                f.write_all(&e.stored_len.to_le_bytes())?;
367 -
                f.write_all(&e.raw_len.to_le_bytes())?;
368 -
                f.write_all(&[type_index(e.ty), e.flags])?;
369 -
            }
370 -
            f.flush()?;
371 -
            f.get_ref().sync_all()?;
372 -
        }
373 -
        fs::rename(&tmp_pack, &pack_path)?;
374 -
        fs::rename(&tmp_idx, &idx_path)?;
436 +
        self.finish_pack_files(&id, &tmp_pack, &index)?;
375 437
        for p in old_packs {
376 438
            fs::remove_file(p)?;
377 439
            fs::remove_file(p.with_extension("idx"))?;
…
18 unchanged lines
396 458
    }
397 459
398 460
    pub fn has(&self, r: &ObjectRef) -> bool {
399 -
        self.path_for(r).is_file() || self.find_packed(r).is_some()
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))
400 462
    }
401 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 +
402 588
    /// Store `body` as an object of type `ty`; idempotent.
403 589
    pub fn put(&self, ty: ObjectType, body: &[u8]) -> Result<ObjectRef> {
404 590
        let r = hash_object(ty, body);
…
1 unchanged lines
406 592
        if path.is_file() || self.find_packed(&r).is_some() {
407 593
            return Ok(r);
408 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 +
        }
409 610
        fs::create_dir_all(path.parent().unwrap())?;
410 611
        let mut nonce = [0u8; 8];
411 612
        rand::rngs::OsRng.fill_bytes(&mut nonce);
…
8 unchanged lines
420 621
        Ok(r)
421 622
    }
422 623
423 -
    /// Read and verify an object, loose or packed.
624 +
    /// Read and verify an object, loose, packed, or pending in a batch.
424 625
    pub fn get(&self, r: &ObjectRef) -> Result<(ObjectType, Vec<u8>)> {
425 626
        let path = self.path_for(r);
426 627
        let data = match fs::read(&path) {
…
1 unchanged lines
428 629
            Err(e) if e.kind() == io::ErrorKind::NotFound => {
429 630
                return match self.find_packed(r) {
430 631
                    Some((i, e)) => self.packs.borrow()[i].read(r, e),
431 -
                    None => Err(StoreError::NotFound(*r)),
632 +
                    None => self.pending_get(r).unwrap_or(Err(StoreError::NotFound(*r))),
432 633
                };
433 634
            }
434 635
            Err(e) => return Err(e.into()),
…
38 unchanged lines
473 674
474 675
    /// Store a chunk that packs will keep uncompressed (`nocompress`, spec §13.3).
475 676
    pub fn put_chunk_nocompress(&self, bytes: &[u8]) -> Result<ObjectRef> {
476 -
        let r = self.put(ObjectType::Chunk, bytes)?;
677 +
        let r = hash_object(ObjectType::Chunk, bytes);
477 678
        let dir = self.dir.join("nocompress");
478 679
        fs::create_dir_all(&dir)?;
479 680
        let _ = fs::File::create(dir.join(r.to_hex()));
480 -
        Ok(r)
681 +
        self.put(ObjectType::Chunk, bytes)
481 682
    }
482 683
483 684
    fn is_nocompress(&self, r: &ObjectRef) -> bool {
…
348 unchanged lines
832 1033
            assert_eq!(hash_object(ty, &body), *r);
833 1034
        }
834 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 +
    }
835 1092
}
modified docs/plan.md +2 −1
…
30 unchanged lines
31 31
- [x] Push and serve the chain of policies since the receiver's serial, so several offline rotations or updates publish in one push or fetch. (Done in iteration 12: `.oxy/policies/` index, `refs` history, bundle policies.)
32 32
- [x] Rename detection on Git import (similarity ≥ 50%); `oxy git fetch`/`push` against a Git remote via a mirror. (Done: detection lives in the change builder, so working-copy renames are tracked too; fetch/push through `.oxy/git/<key>/mirror.git` with fast-forward chaining; spec 0.6p.)
33 33
- [x] Hardening: property tests for the CBOR decoder and pack reader against random and mutated inputs; a concurrent-push test against the server; a 1 GB single-file benchmark for chunking and blobs. (Done: random/mutated CBOR never panics and accepted input is canonical; 300 rounds of pack/index corruption fail cleanly, with new index bounds checks; 8 concurrent HTTP pushes serialise with pull-and-retry; `bench_one_gibibyte_file` is an ignored release-mode test, run with `OXY_BENCH_DIR` on a real disk.)
34 -
- [ ] Performance follow-up from the 1 GiB benchmark: commit takes 5.6 s on tmpfs but 63 s on the SSD, and clone 49 s, because every object is written with its own fsync (13,715 chunks). Write committed and fetched objects straight into a pack with one fsync, or batch loose writes.
34 +
- [x] Performance follow-up from the 1 GiB benchmark: commit takes 5.6 s on tmpfs but 63 s on the SSD, and clone 49 s, because every object is written with its own fsync (13,715 chunks). Write committed and fetched objects straight into a pack with one fsync, or batch loose writes. (Done: store write batches; small batches stay loose, large ones spill to one pack. SSD: commit 63 s → 8.8 s, clone 49 s → 15.9 s; spec 0.6r.)
35 35
- [ ] Performance follow-up: `oxy status` re-hashes a clean 1 GiB working copy (2.4 s). Record size and mtime in the manifest and skip hashing files whose stat is unchanged (spec allows a stat cache).
36 36
- [ ] Documentation: `docs/guide.md` (user guide by task) and `docs/protocol.md` (for other implementers, derived from spec §16 and the wire encodings).
37 37
- [ ] Spec review pass: resolve or re-word every "MAY ... until" allowance introduced during implementation, and close what can be closed in §27.
…
1 unchanged lines
39 39
## Log
40 40
41 41
- 2026-09-26 23:01 plan created.
42 +
- 2026-09-27 08:40 iteration 15: batched object writes (commit 8.8 s, clone 15.9 s for 1 GiB on SSD). 111 tests.
42 43
- 2026-09-27 08:05 iteration 14: hardening (CBOR and pack fuzz-style tests, concurrent pushes, 1 GiB benchmark: write 0.7 s, commit 63 s on SSD / 5.6 s on tmpfs, status 2.4 s, clone 49 s, read back 1.0 s, store 1025.6 MiB, 13,715 objects). 110 tests, 1 ignored benchmark.
43 44
- 2026-09-27 07:20 iteration 13: rename detection (snapshots and Git import) and oxy git fetch/push via a mirror (spec 0.6p). 108 tests.
44 45
- 2026-09-27 06:30 iteration 12: identity lifecycle and multi-signer policy updates; policy chains on fetch and push (spec 0.6n). 106 tests.
…
11 unchanged lines
modified docs/spec.md +5 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6q |
5 +
| **Status** | Draft 0.6r |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
501 unchanged lines
510 510
511 511
### 13.4 Packing
512 512
513 -
`oxy gc` (and an automatic trigger when loose object count exceeds 4096) writes loose objects into a new pack and merges packs when there are more than 16. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile.
513 +
`oxy gc` (and an automatic trigger when loose object count exceeds 4096) writes loose objects into a new pack and merges packs when there are more than 16. Writers SHOULD batch: a snapshot, a fetched bundle, a Git import, or a checkpoint is one write batch, and a batch that grows beyond a small threshold (256 objects or 8 MiB) is written as a single pack with one `fsync` rather than as loose objects, each with its own; smaller batches stay loose so ordinary commits do not multiply packs. Objects of an open batch MUST be readable by the writer before the batch completes. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile.
514 514
515 515
The pack and index formats are fixed so that tools can read them without the implementation:
516 516
…
330 unchanged lines
847 847
848 848
---
849 849
850 -
*Changelog.* Draft 0.6q (2026-09-27): pack index validation rules (§13.1).
850 +
*Changelog.* Draft 0.6r (2026-09-27): batched object writes (§13.4).
851 +
852 +
Draft 0.6q (2026-09-27): pack index validation rules (§13.1).
851 853
852 854
Draft 0.6p (2026-09-27): `oxy git fetch` and `oxy git push` against a Git remote through a mirror, with incremental import seeded by imported and exported commits (§23).
853 855
…
47 unchanged lines