OXYGENHUB

Snapshots use every core e7ddd552b619

Rendering the base state's files, building file graphs from a
checkpoint, and reading working-copy files are independent per file and
now run in parallel (rayon). The walk still visits the tree in order;
only the reads fan out, and a cached blob the store has lost is still
read back from disk afterwards.

At 10 000 files a clean status goes from about 800 ms to 460-580 ms:
materializing from 190 to 75 ms, reading the working copy from 130 to
80 ms, loading the base from 320 to 180-280 ms.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 07:40 UTC
Signature
✔ signed by Andy McCall
Hash
e7ddd552b6199f06be9b66003a428db39cbdb77a1b3afd88756e7f11a6b5e622
Depends on
07eb99be126e 252e8cb23fbc 2b5ee371060e 37c299cec358 75036530b621 75167672ebcd 982864068a59 9ac1fbe211b4 cdb5beba7f05 ff0af1ee299d
Depended on by
c2e1d09047db c3d2ef6104c2 5b4c51307b14 e96a430c78f3 f57bf6702628 36e51aa806c6 1966f5db15ee 76740263089c 992467d31d26 40a40bb7b798 2306a4d24779 8b276e0106d1 cc7a00565161 e8d38e47e9d7 afaba5f1306d 696bbf29d3eb e1dc6d6aad4f 540b229288ba cf949c7966b1 2cc29048b84a
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
16 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified Cargo.lock +21 −0
…
1251 unchanged lines
1252 1252
 "ml-dsa",
1253 1253
 "proptest",
1254 1254
 "rand 0.8.8",
1255 +
 "rayon",
1255 1256
 "rustls",
1256 1257
 "rustls-pemfile",
1257 1258
 "similar",
…
215 unchanged lines
1473 1474
]
1474 1475
1475 1476
[[package]]
1477 +
name = "rayon"
1478 +
version = "1.12.0"
1479 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1480 +
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
1481 +
dependencies = [
1482 +
 "either",
1483 +
 "rayon-core",
1484 +
]
1485 +
1486 +
[[package]]
1487 +
name = "rayon-core"
1488 +
version = "1.13.0"
1489 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1490 +
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
1491 +
dependencies = [
1492 +
 "crossbeam-deque",
1493 +
 "crossbeam-utils",
1494 +
]
1495 +
1496 +
[[package]]
1476 1497
name = "rcgen"
1477 1498
version = "0.14.10"
1478 1499
source = "registry+https://github.com/rust-lang/crates.io-index"
…
1033 unchanged lines
modified crates/oxy-core/Cargo.toml +1 −0
…
19 unchanged lines
20 20
webpki-roots = "1"
21 21
zstd = "0.13"
22 22
globset = "0.4"
23 +
rayon = "1"
23 24
24 25
[dev-dependencies]
25 26
proptest = "1"
…
1 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +61 −25
…
866 unchanged lines
867 867
        let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
868 868
        let wb = self.walker(&nested);
869 869
        let mut out = BTreeMap::new();
870 +
        let mut files: Vec<(String, PathBuf, fs::Metadata)> = Vec::new();
870 871
        for entry in wb.build() {
871 872
            let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?;
872 873
            let path = entry.path();
…
26 unchanged lines
899 900
                let meta = entry
900 901
                    .metadata()
901 902
                    .map_err(|e| RepoError::Msg(e.to_string()))?;
902 -
                let e = self.file_entry(&rel, path, &meta, cache)?;
903 +
                files.push((rel, path.to_path_buf(), meta));
904 +
            }
905 +
        }
906 +
        // Reading files is most of a walk's cost; do it in parallel.
907 +
        {
908 +
            use rayon::prelude::*;
909 +
            let read: Vec<(String, FsEntry)> = files
910 +
                .par_iter()
911 +
                .map(|(rel, path, meta)| {
912 +
                    Ok((rel.clone(), read_file_entry(rel, path, meta, cache)?))
913 +
                })
914 +
                .collect::<Result<Vec<_>>>()?;
915 +
            for (rel, mut e) in read {
916 +
                // A cached blob the store lost (gc, a partial clone): read the file after all.
917 +
                if e.content.is_empty() && e.blob.is_some_and(|b| !self.store.has(&b)) {
918 +
                    e.content = fs::read(self.root.join(&rel))?;
919 +
                    e.blob = None;
920 +
                }
903 921
                out.insert(rel, e);
904 922
            }
905 923
        }
…
88 unchanged lines
994 1012
        meta: &fs::Metadata,
995 1013
        cache: Option<&prompt::Manifest>,
996 1014
    ) -> Result<FsEntry> {
997 -
        let exec = is_executable(meta);
998 -
        let cached = cache.and_then(|m| {
999 -
            let e = m.entries.get(rel)?;
1000 -
            let mtime_ns = prompt::mtime_ns(meta);
1001 -
            let racy = mtime_ns + 2_000_000_000 >= m.written_ns;
1002 -
            (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e)
1003 -
        });
1004 -
        Ok(match cached {
1005 -
            Some(e) if e.blob.is_some_and(|b| self.store.has(&b)) => FsEntry {
1006 -
                kind: NodeKind::File,
1007 -
                exec,
1008 -
                content: vec![],
1009 -
                blob: e.blob,
1010 -
                content_hash: Some(e.hash.clone()),
1011 -
            },
1012 -
            Some(e) => FsEntry {
1013 -
                kind: NodeKind::File,
1014 -
                exec,
1015 -
                content: fs::read(path)?,
1016 -
                blob: None,
1017 -
                content_hash: Some(e.hash.clone()),
1018 -
            },
1019 -
            None => FsEntry::new(NodeKind::File, exec, fs::read(path)?),
1020 -
        })
1015 +
        let mut e = read_file_entry(rel, path, meta, cache)?;
1016 +
        if e.content.is_empty() && e.blob.is_some_and(|b| !self.store.has(&b)) {
1017 +
            e.content = fs::read(path)?;
1018 +
            e.blob = None;
1019 +
        }
1020 +
        Ok(e)
1021 1021
    }
1022 1022
1023 1023
    /// The non-directory paths of a snapshot with their kinds, for the
…
195 unchanged lines
1219 1219
        None => ("", path),
1220 1220
    }
1221 1221
}
1222 +
1223 +
/// One regular file's entry from disk, or from the stat cache when its
1224 +
/// size and mtime match the manifest outside the racy window. Touches no
1225 +
/// repository state, so the walk runs it in parallel; the caller checks
1226 +
/// that a cached blob is still in the store.
1227 +
fn read_file_entry(
1228 +
    rel: &str,
1229 +
    path: &Path,
1230 +
    meta: &fs::Metadata,
1231 +
    cache: Option<&prompt::Manifest>,
1232 +
) -> Result<FsEntry> {
1233 +
    let exec = is_executable(meta);
1234 +
    let cached = cache.and_then(|m| {
1235 +
        let e = m.entries.get(rel)?;
1236 +
        let mtime_ns = prompt::mtime_ns(meta);
1237 +
        let racy = mtime_ns + 2_000_000_000 >= m.written_ns;
1238 +
        (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e)
1239 +
    });
1240 +
    Ok(match cached {
1241 +
        Some(e) if e.blob.is_some() => FsEntry {
1242 +
            kind: NodeKind::File,
1243 +
            exec,
1244 +
            content: vec![],
1245 +
            blob: e.blob,
1246 +
            content_hash: Some(e.hash.clone()),
1247 +
        },
1248 +
        Some(e) => FsEntry {
1249 +
            kind: NodeKind::File,
1250 +
            exec,
1251 +
            content: fs::read(path)?,
1252 +
            blob: None,
1253 +
            content_hash: Some(e.hash.clone()),
1254 +
        },
1255 +
        None => FsEntry::new(NodeKind::File, exec, fs::read(path)?),
1256 +
    })
1257 +
}
modified crates/oxy-core/src/state.rs +36 −20
…
288 unchanged lines
289 289
            st.changes.insert(hash, meta);
290 290
        }
291 291
        st.tree = TreeGraph::from_value(o::field(m, 2, "tree")?)?;
292 -
        for f in o::array(o::field(m, 3, "files")?, "files")? {
293 -
            let e = o::array(f, "file")?;
294 -
            if e.len() != 2 {
295 -
                return Err(ObjectError::Schema("file: expected [node, graph]".into()));
296 -
            }
297 -
            let node = NodeId::from_slice(o::bytes(&e[0], "node")?)
298 -
                .ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
299 -
            st.files.insert(node, FileGraph::from_value(&e[1])?);
292 +
        // File graphs are independent; build them in parallel.
293 +
        {
294 +
            use rayon::prelude::*;
295 +
            let graphs = o::array(o::field(m, 3, "files")?, "files")?
296 +
                .par_iter()
297 +
                .map(|f| {
298 +
                    let e = o::array(f, "file")?;
299 +
                    if e.len() != 2 {
300 +
                        return Err(ObjectError::Schema("file: expected [node, graph]".into()));
301 +
                    }
302 +
                    let node = NodeId::from_slice(o::bytes(&e[0], "node")?)
303 +
                        .ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
304 +
                    Ok((node, FileGraph::from_value(&e[1])?))
305 +
                })
306 +
                .collect::<Result<Vec<_>, ObjectError>>()?;
307 +
            st.files.extend(graphs);
300 308
        }
301 309
        let opt = |v: &Value| -> Result<Option<ObjectRef>, ObjectError> {
302 310
            if v.is_null() {
…
246 unchanged lines
549 557
        (bytes, m)
550 558
    }
551 559
560 +
    /// Every file rendered, in parallel: files are independent, and
561 +
    /// rendering them is most of a snapshot's cost in a large tree.
552 562
    pub fn materialize(&self) -> Snapshot {
563 +
        use rayon::prelude::*;
553 564
        let tree = self.tree.view();
554 -
        let mut files = BTreeMap::new();
555 -
        for (path, e) in &tree.entries {
565 +
        let entries: Vec<(&String, &crate::tree::TreeEntry)> = tree.entries.iter().collect();
566 +
        let rendered: Vec<(String, FileState)> = entries
567 +
            .par_iter()
568 +
            .map(|(path, e)| (path.to_string(), self.file_state(e)))
569 +
            .collect();
570 +
        let files: BTreeMap<String, FileState> = rendered.into_iter().collect();
571 +
        Snapshot { tree, files }
572 +
    }
573 +
574 +
    fn file_state(&self, e: &crate::tree::TreeEntry) -> FileState {
575 +
        {
556 576
            let content = match e.kind {
557 577
                NodeKind::Dir => FileContent::Dir,
558 578
                NodeKind::Symlink | NodeKind::Subrepo => FileContent::Opaque {
…
10 unchanged lines
569 589
                    }
570 590
                }
571 591
            };
572 -
            files.insert(
573 -
                path.clone(),
574 -
                FileState {
575 -
                    node: e.node,
576 -
                    kind: e.kind,
577 -
                    mode: e.mode,
578 -
                    content,
579 -
                },
580 -
            );
592 +
            FileState {
593 +
                node: e.node,
594 +
                kind: e.kind,
595 +
                mode: e.mode,
596 +
                content,
597 +
            }
581 598
        }
582 -
        Snapshot { tree, files }
583 599
    }
584 600
}
585 601
…
323 unchanged lines
modified docs/backlog-overnight-2.md +2 −1
…
44 unchanged lines
45 45
- [x] O3 Crash safety audit: every write goes through write_atomic or the store's batch; a test that interrupts commit and push at each step and proves the repository still opens and fscks.
46 46
- [x] O4 Benchmarks with thresholds: 10 000 files, 5 000 changes; status, commit, log, blame, push, clone; record numbers in docs.
47 47
- [x] O10a Heads at scale (first part of O10): `State::maximal` compared every pair of heads (quadratic; about 1.5 s per call at 4 500 heads, called three times per commit); now one walk. Commit reuses the snapshot's base state instead of loading it again.
48 -
- [ ] O10b Snapshot fast path (rest of O10): at 10 000 files and 5 000 changes a commit takes 3.8 s and a clean status 3.0 s, because every snapshot loads and materializes the whole base state (about 75 ms load, 75 ms materialize, 50 ms walk at 4 000 files). Skip load and materialize when every file's stat matches a manifest written for the same base; make the base state lazy for callers that need it; cache the materialized snapshot per state. Target the spec §25 numbers.
48 +
- [x] O10b Parallel snapshot work: rendering the base state's files, building file graphs from a checkpoint, and reading working-copy files now run on every core (rayon).
49 +
- [ ] O10c Snapshot without the whole base (rest of O10): load only the file graphs a snapshot needs (a checkpoint split per file or indexed), skip reading and rendering files whose stat matches a manifest written for the same base, cache materialized states. Needs a spec change to the checkpoint format; target spec §25.
49 50
- [ ] O5 Concurrency: two processes committing, snapshotting and pushing on one repository; locks hold, nothing is lost.
50 51
- [ ] O6 Error messages audit: every user-facing error says what to do next; `oxy doctor` checks identity, config, remotes, CA files, hub reachability.
51 52
- [ ] O7 Shell completions (`oxy completions bash|zsh|fish`) and man pages generated from clap.
…
14 unchanged lines