OXYGENHUB

Fewer waits on the disk: batched loose writes, the commit in one batch, fan-outs kept (refs #41) b30a880fa409

A batch's loose objects are written together, synced in parallel, renamed
into place and each fan-out directory synced once (put_loose_many, with
its crash steps). A commit runs in one batch, made durable before the
operation that names it. Packing keeps emptied fan-out directories, and a
missing one has all 256 created with one sync of loose, which alone took
130 ms on a busy disk. Serial syncs per commit fall from 18 to about 7.

Old and new builds alternating on the Linux tree, five commits after a
status each: median 0.39 s before, 0.34 s after (Git 0.15 s).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 21:57 UTC
Signature
✔ signed by Andy McCall
Hash
b30a880fa40954c9943ef9c0813c16208a6dc557127248e6e566b55d8ca52b2d
Depends on
1d360c91a289 4dcb8c0916b7 6caacfff1d4f 75167672ebcd 8f10a07a1dbe 982864068a59 ba160f60219e d02e545dc265 e226a6cc2f71 ff0af1ee299d
Depended on by
7505194116e7 648189388f1a 236f35c0baba cd4eb55e3b3d
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.10.0 v0.11.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
8 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/oxy-core/src/repo/snapshot.rs +14 −0
…
802 unchanged lines
803 803
        extra_deps: &[ChangeHash],
804 804
        selection: &Selection,
805 805
    ) -> Result<ChangeHash> {
806 +
        // The commit's objects are written together, made durable at once
807 +
        // before the operation that names them (spec §13.6).
808 +
        let batch = self.store.begin_batch()?;
809 +
        let out = self.commit_selection_batched(message, extra_deps, selection);
810 +
        batch.finish()?;
811 +
        out
812 +
    }
813 +
814 +
    fn commit_selection_batched(
815 +
        &self,
816 +
        message: &str,
817 +
        extra_deps: &[ChangeHash],
818 +
        selection: &Selection,
819 +
    ) -> Result<ChangeHash> {
806 820
        let _wc = self.lock_working_copy()?;
807 821
        let mut snap = match self.snapshot_from_cache()? {
808 822
            Some(snap) => snap,
…
2048 unchanged lines
modified crates/oxy-core/src/store.rs +82 −12
…
429 unchanged lines
430 430
            }
431 431
            report.bytes += meta.len();
432 432
            report.objects += 1;
433 +
            // The fan-out directory stays: recreating it costs a sync of
434 +
            // `loose` on the next write.
433 435
            fs::remove_file(&path)?;
434 -
            let _ = fs::remove_dir(path.parent().unwrap());
435 436
            self.remove_sidecars(&r)?;
436 437
        }
437 438
        // Packs: rewrite those that hold unreachable objects.
…
184 unchanged lines
622 623
            fs::remove_file(p.with_extension("idx"))?;
623 624
        }
624 625
        self.reload_packs()?;
626 +
        // Fan-out directories stay, emptied: see `ensure_fanouts`.
625 627
        for path in loose_paths {
626 628
            fs::remove_file(path)?;
627 -
            if let Some(dir) = path.parent() {
628 -
                let _ = fs::remove_dir(dir);
629 -
            }
630 629
        }
631 630
        Ok(report)
632 631
    }
…
75 unchanged lines
708 707
    fn write_pending(&self, pending: Pending) -> Result<()> {
709 708
        match pending.pack {
710 709
            None => {
711 -
                for (ty, body) in &pending.buffered {
712 -
                    self.put_loose(*ty, body)?;
713 -
                }
714 -
                Ok(())
710 +
                let objects: Vec<(ObjectType, &[u8])> = pending
711 +
                    .buffered
712 +
                    .iter()
713 +
                    .map(|(ty, body)| (*ty, body.as_slice()))
714 +
                    .collect();
715 +
                self.put_loose_many(&objects)
715 716
            }
716 717
            Some(mut pack) => {
717 718
                pack.writer.flush()?;
…
220 unchanged lines
938 939
        if path.is_file() {
939 940
            return Ok(r);
940 941
        }
941 -
        let fanout = path.parent().unwrap();
942 -
        if !fanout.is_dir() {
943 -
            fs::create_dir_all(fanout)?;
944 -
            crate::durable::sync_dir(&self.dir.join("loose"))?;
942 +
        if !path.parent().unwrap().is_dir() {
943 +
            self.ensure_fanouts()?;
945 944
        }
946 945
        let mut nonce = [0u8; 8];
947 946
        rand::rngs::OsRng.fill_bytes(&mut nonce);
…
10 unchanged lines
958 957
        }
959 958
        crate::durable::rename(&tmp, &path)?;
960 959
        Ok(r)
960 +
    }
961 +
962 +
    /// Create every fan-out directory of `loose` (`00` to `ff`) and sync
963 +
    /// `loose` once. Syncing it is slow on a busy disk (130 ms seen), and
964 +
    /// creating the directories one at a time paid that on most writes
965 +
    /// after a gc had emptied them; they are kept from then on.
966 +
    fn ensure_fanouts(&self) -> io::Result<()> {
967 +
        let loose = self.dir.join("loose");
968 +
        for b in 0..=255u8 {
969 +
            match fs::create_dir(loose.join(format!("{b:02x}"))) {
970 +
                Ok(()) => {}
971 +
                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
972 +
                Err(e) => return Err(e),
973 +
            }
974 +
        }
975 +
        crate::durable::sync_dir(&loose)
976 +
    }
977 +
978 +
    /// Write several loose objects with as few waits on the disk as the
979 +
    /// guarantees allow: every file written, then all synced together, then
980 +
    /// each renamed into place and each directory synced once. Durable when
981 +
    /// this returns, as `put_loose` one at a time would be (spec §13.6).
982 +
    fn put_loose_many(&self, objects: &[(ObjectType, &[u8])]) -> Result<()> {
983 +
        use rayon::prelude::*;
984 +
        if objects.len() <= 1 {
985 +
            for (ty, body) in objects {
986 +
                self.put_loose(*ty, body)?;
987 +
            }
988 +
            return Ok(());
989 +
        }
990 +
        let mut seen = std::collections::HashSet::new();
991 +
        let todo: Vec<(PathBuf, PathBuf, ObjectType, &[u8])> = objects
992 +
            .iter()
993 +
            .filter_map(|(ty, body)| {
994 +
                let r = hash_object(*ty, body);
995 +
                let path = self.path_for(&r);
996 +
                (seen.insert(r) && !path.is_file()).then(|| {
997 +
                    let mut nonce = [0u8; 8];
998 +
                    rand::rngs::OsRng.fill_bytes(&mut nonce);
999 +
                    let tmp = self
1000 +
                        .dir
1001 +
                        .join("loose")
1002 +
                        .join(format!("tmp-{}", hex::encode(nonce)));
1003 +
                    (path, tmp, *ty, *body)
1004 +
                })
1005 +
            })
1006 +
            .collect();
1007 +
        if todo.is_empty() {
1008 +
            return Ok(());
1009 +
        }
1010 +
        let fanouts: std::collections::BTreeSet<&Path> =
1011 +
            todo.iter().map(|(p, ..)| p.parent().unwrap()).collect();
1012 +
        if fanouts.iter().any(|f| !f.is_dir()) {
1013 +
            self.ensure_fanouts()?;
1014 +
        }
1015 +
        crate::durable::step("write loose objects")?;
1016 +
        todo.par_iter()
1017 +
            .try_for_each(|(_, tmp, ty, body)| -> io::Result<()> {
1018 +
                let mut f = fs::File::create(tmp)?;
1019 +
                f.write_all(&header(*ty, body.len()))?;
1020 +
                f.write_all(body)?;
1021 +
                f.sync_all()
1022 +
            })?;
1023 +
        crate::durable::step("rename loose objects")?;
1024 +
        for (path, tmp, ..) in &todo {
1025 +
            fs::rename(tmp, path)?;
1026 +
        }
1027 +
        fanouts
1028 +
            .par_iter()
1029 +
            .try_for_each(|d| crate::durable::sync_dir(d))?;
1030 +
        Ok(())
961 1031
    }
962 1032
963 1033
    /// Read and verify an object, loose, packed, or pending in a batch.
…
1013 unchanged lines
modified docs/benchmarks.md +10 −0
…
816 unchanged lines
817 817
remains is mostly durability: 18 fsyncs take 210 ms of the commit (one
818 818
alone 120 ms). Git does not sync loose objects by default.
819 819
820 +
Fewer waits on the disk (same evening): a batch's loose objects are
821 +
written together, synced in parallel, renamed and their directories
822 +
synced once each; a commit runs in one batch; packing keeps the emptied
823 +
fan-out directories, and a missing one has all 256 created with one sync
824 +
of `loose` (that sync alone took 130 ms on a busy disk). Serial syncs per
825 +
commit fall from 18 to about 7; each still waits on the same ext4 journal
826 +
commit, so the gain is modest. Old and new builds alternating, five
827 +
commits after a status each: median 0.39 s before, 0.34 s after (Git
828 +
0.14 to 0.16 s).
829 +