OXYGENHUB

Git import holds a budget of graph memory d45dab0484ea

A long-lived file's graph keeps every line it ever had, and a kept
state shed its graphs only past 2 000 of them, so a few large graphs
held gigabytes; past 1.5 million atoms they are shed as well
(KEPT_ATOMS, State::built_atoms; OXY_IMPORT_KEPT_ATOMS for measuring).
Freed pages go back to the system after each merge. depends_on walks
down by topological level instead of memoising each queried change's
whole ancestry, and the closure memo is capped. Measured on VS Code's
28 637-commit reproducer: 16.9 GB to 5.1 GB, 61 to 36 minutes, the same
verify result as before. The trace adds atoms and deferred edits.
refs #25
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 23:02 UTC
Signature
✔ signed by Andy McCall
Hash
d45dab0484eadd0d03432e03b51d6b71c144e7a27e0c978fd7cdc8bace414c50
Depends on
119bbf50cb86 3f1b8fe25ec9 55e5a16a2b9c 614f55e14eee 79ab8528ff62 8fd78eb52ad2 c07406bcc237 d443639bcf35 e8d38e47e9d7 f6451fbbae3f f9575918477f fb5b6181618a fd280228bdc2 ff0af1ee299d
Depended on by
4476cdbb0443 aedfe465b611 5552e69c7325 810691ca6ce6 9935d490ddfb 33be24095b4b
In bookmarks
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.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
21 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Git import holds a budget of graph memory (#25).** A long-lived file's graph keeps every line it ever had, and the import shed a kept state's graphs only past 2 000 of them; it now also sheds them past 1.5 million atoms, and gives freed memory back after each merge. A 28 637-commit slice of VS Code imports at 5.1 GB instead of 16.9 GB, in 36 minutes instead of 61, with the same verify result. The dependency check no longer memoises every queried change's whole ancestry.
6 +
5 7
- **A rotation follows you everywhere (#69).** `delegate add`, `delegate remove`, `rotate` and `renew` record the successor key in the user config when it named the old key, and in the repository's own file when that pinned it, so every checkout moves together. `oxy identity show` warns when a repository pins a key other than the user config's; `oxy config --unset KEY` drops a key; `oxy identity delegate add` takes several identity files in one rotation and reminds you that agents configured with your `author.key` need the new one.
6 8
7 9
## 0.4.0 (2026-10-04)
…
102 unchanged lines
modified crates/oxy-core/src/alloc_stats.rs +13 −0
…
76 unchanged lines
77 77
        count: COUNT.load(Relaxed),
78 78
    })
79 79
}
80 +
81 +
/// Give pages the program has freed back to the system. glibc keeps freed
82 +
/// memory in each thread's arena; after a large merge renders thousands of
83 +
/// files on many threads, that kept memory is most of the process's size
84 +
/// (#25). Elsewhere it does nothing.
85 +
pub fn release_freed_memory() {
86 +
    #[cfg(all(target_os = "linux", target_env = "gnu"))]
87 +
    // SAFETY: malloc_trim only returns free pages to the system; it is
88 +
    // safe to call at any time from any thread.
89 +
    unsafe {
90 +
        libc::malloc_trim(0);
91 +
    }
92 +
}
modified crates/oxy-core/src/repo/git.rs +30 −2
…
287 unchanged lines
288 288
289 289
/// A merge bringing more commits than this sheds its base's graphs first.
290 290
const MERGE_SHED_COMMITS: usize = 100;
291 +
292 +
/// The atoms a kept state's built graphs may hold before they go back into
293 +
/// its history (#25): about a few hundred megabytes.
294 +
const KEPT_ATOMS: usize = 1_500_000;
291 295
/// A kept state with more built graphs than this sheds them.
292 296
const KEPT_GRAPHS: usize = 2000;
293 297
…
465 unchanged lines
759 763
                .unwrap_or(0)
760 764
        };
761 765
        let mut done = 0usize;
766 +
        // Atoms a kept state's graphs may hold before they are shed;
767 +
        // OXY_IMPORT_KEPT_ATOMS overrides it, for measuring.
768 +
        let kept_atoms: usize = std::env::var("OXY_IMPORT_KEPT_ATOMS")
769 +
            .ok()
770 +
            .and_then(|v| v.parse().ok())
771 +
            .unwrap_or(KEPT_ATOMS);
762 772
        // Graphs turned back into history (see `State::shed_graphs`).
763 773
        let mut shed = 0usize;
764 774
        let mut lap = |i: usize, clock: &mut std::time::Instant| {
…
380 unchanged lines
1145 1155
            if waiting.get(gc.sha.as_str()).is_some_and(|n| *n > 0) {
1146 1156
                // A kept state holds its graphs until its children come:
1147 1157
                // past a few thousand, they go back into its history.
1148 -
                if base.built_count() > KEPT_GRAPHS {
1158 +
                // Past a few thousand graphs, or past a budget of atoms: a
1159 +
                // long-lived file's graph keeps every line it ever had, so a
1160 +
                // handful of them can hold gigabytes (#25).
1161 +
                if base.built_count() > KEPT_GRAPHS || base.built_atoms() > kept_atoms {
1149 1162
                    shed += base.shed_graphs(|b| self.store_blob(b))?;
1150 1163
                }
1151 1164
                kept.insert(gc.sha.clone(), (base, tree));
…
8 unchanged lines
1160 1173
            report.commits += 1;
1161 1174
            lap(5, &mut clock);
1162 1175
            done += 1;
1176 +
            // A merge renders thousands of files on many threads and frees
1177 +
            // it all; the allocator keeps those pages unless asked to give
1178 +
            // them back, and the next merge piles on top (#25).
1179 +
            let before_trim = if trace { rss_mb() } else { 0 };
1180 +
            if gc.parents.len() > 1 {
1181 +
                crate::alloc_stats::release_freed_memory();
1182 +
            }
1163 1183
            if trace {
1164 1184
                let built = kept
1165 1185
                    .get(gc.sha.as_str())
1166 1186
                    .map(|(st, _)| st.built_count())
1187 +
                    .unwrap_or(0);
1188 +
                let (edits, most) = kept
1189 +
                    .get(gc.sha.as_str())
1190 +
                    .map(|(st, _)| st.deferred_edit_count())
1191 +
                    .unwrap_or((0, 0));
1192 +
                let atoms = kept
1193 +
                    .get(gc.sha.as_str())
1194 +
                    .map(|(st, _)| st.built_atoms())
1167 1195
                    .unwrap_or(0);
1168 1196
                eprintln!(
1169 -
                    "trace {done} {} parents={} base={base_how} merged={merged} built={built} kept={} shed={shed} rss_mb={}",
1197 +
                    "trace {done} {} parents={} base={base_how} merged={merged} built={built} atoms={atoms} kept={} shed={shed} rss_mb={} before_trim_mb={before_trim} deferred_edits={edits} most={most}",
1170 1198
                    &gc.sha[..12.min(gc.sha.len())],
1171 1199
                    gc.parents.len(),
1172 1200
                    kept.len(),
…
534 unchanged lines
modified crates/oxy-core/src/state.rs +161 −8
…
96 unchanged lines
97 97
    /// by a few changes costs little more than one.
98 98
    files: crate::fasthash::FastMap<NodeId, Arc<FileGraph>>,
99 99
    replaces: HashMap<NodeId, Vec<ReplaceRecord>>,
100 -
    /// Memoised transitive dependencies. A mutex, not a `RefCell`, so a
101 -
    /// loaded state can be shared across threads by a host that caches it.
102 -
    closure: Mutex<HashMap<ChangeHash, Arc<HashSet<ChangeHash>>>>,
100 +
    /// Memoised transitive dependencies, bounded by the hashes they hold
101 +
    /// (each set is the whole history below a change, so keeping one for
102 +
    /// every change asked about grew with the square of history: #25). A
103 +
    /// mutex, not a `RefCell`, so a loaded state can be shared across
104 +
    /// threads by a host that caches it.
105 +
    closure: Mutex<ClosureMemo>,
106 +
    /// Each change's level: 0 with no dependencies, else one more than its
107 +
    /// highest dependency. A change can only depend on changes of lower
108 +
    /// level, which bounds the walk [`DepOracle::depends_on`] does.
109 +
    levels: Mutex<HashMap<ChangeHash, u32>>,
103 110
    /// `None` for a full state. For a history-only state (loaded for its
104 111
    /// changes and tree), the file graphs not built yet, by node: `files`
105 112
    /// holds only those loaded with [`State::load_graphs`].
…
19 unchanged lines
125 132
    key: std::sync::OnceLock<[u8; 32]>,
126 133
}
127 134
135 +
/// The closures kept, and how many hashes they hold in all.
136 +
#[derive(Default)]
137 +
struct ClosureMemo {
138 +
    sets: HashMap<ChangeHash, Arc<HashSet<ChangeHash>>>,
139 +
    held: usize,
140 +
}
141 +
142 +
/// The most hashes the closure memo holds before it starts again: a few
143 +
/// hundred megabytes at most.
144 +
const CLOSURE_MEMO_HASHES: usize = 4_000_000;
145 +
128 146
#[derive(Clone, Default)]
129 147
struct DeferredBody {
130 148
    base: Option<DeferredBase>,
…
376 unchanged lines
507 525
            files: self.files.clone(),
508 526
            replaces: self.replaces.clone(),
509 527
            // A cache: the clone builds its own.
510 -
            closure: Mutex::new(HashMap::new()),
528 +
            closure: Mutex::new(ClosureMemo::default()),
529 +
            levels: Mutex::new(HashMap::new()),
511 530
            deferred: self.deferred.clone(),
512 531
        }
513 532
    }
…
476 unchanged lines
990 1009
    pub fn built_count(&self) -> usize {
991 1010
        self.files.len()
992 1011
    }
1012 +
1013 +
    /// Atoms in this state's built graphs, live and deleted: what their
1014 +
    /// memory follows, since a graph keeps every line its file ever had.
1015 +
    pub fn built_atoms(&self) -> usize {
1016 +
        self.files.values().map(|g| g.atom_count()).sum()
1017 +
    }
1018 +
1019 +
    /// Edits held in the deferred histories of files not built, and the
1020 +
    /// most any one file holds (for measuring, #25).
1021 +
    pub fn deferred_edit_count(&self) -> (usize, usize) {
1022 +
        let Some(d) = self.deferred.as_ref() else {
1023 +
            return (0, 0);
1024 +
        };
1025 +
        let mut total = 0;
1026 +
        let mut most = 0;
1027 +
        for v in d.values() {
1028 +
            let n: usize = v.body().edits.iter().map(|(_, e)| e.len()).sum();
1029 +
            total += n;
1030 +
            most = most.max(n);
1031 +
        }
1032 +
        (total, most)
1033 +
    }
993 1034
}
994 1035
995 1036
impl Deferred {
…
133 unchanged lines
1129 1170
        let meta = ChangeMeta::of(&change);
1130 1171
        self.apply_with(h, change, texts);
1131 1172
        self.changes.insert(h, Arc::new(meta));
1132 -
        self.closure.lock().unwrap().clear();
1173 +
        {
1174 +
            let mut m = self.closure.lock().unwrap();
1175 +
            m.sets.clear();
1176 +
            m.held = 0;
1177 +
        }
1133 1178
    }
1134 1179
1135 1180
    // ------------------------------------------------------------ checkpoints (spec §11)
…
381 unchanged lines
1517 1562
1518 1563
    /// Transitive dependencies of `h`, excluding `h` itself.
1519 1564
    pub fn closure(&self, h: &ChangeHash) -> Arc<HashSet<ChangeHash>> {
1520 -
        if let Some(c) = self.closure.lock().unwrap().get(h) {
1565 +
        if let Some(c) = self.closure.lock().unwrap().sets.get(h) {
1521 1566
            return c.clone();
1522 1567
        }
1523 1568
        let mut out = HashSet::new();
…
10 unchanged lines
1534 1579
            }
1535 1580
        }
1536 1581
        let rc = Arc::new(out);
1537 -
        self.closure.lock().unwrap().insert(*h, rc.clone());
1582 +
        let mut memo = self.closure.lock().unwrap();
1583 +
        if memo.held + rc.len() > CLOSURE_MEMO_HASHES {
1584 +
            memo.sets.clear();
1585 +
            memo.held = 0;
1586 +
        }
1587 +
        memo.held += rc.len();
1588 +
        memo.sets.insert(*h, rc.clone());
1538 1589
        rc
1539 1590
    }
1540 1591
1592 +
    /// `h`'s level (see `levels`), worked out for it and its ancestors not
1593 +
    /// yet known, without recursion: histories are deeper than a stack. An
1594 +
    /// unknown change has level 0.
1595 +
    fn level(&self, h: &ChangeHash) -> u32 {
1596 +
        let mut levels = self.levels.lock().unwrap();
1597 +
        if let Some(l) = levels.get(h) {
1598 +
            return *l;
1599 +
        }
1600 +
        let mut stack: Vec<(ChangeHash, bool)> = vec![(*h, false)];
1601 +
        while let Some((c, expanded)) = stack.pop() {
1602 +
            if levels.contains_key(&c) {
1603 +
                continue;
1604 +
            }
1605 +
            let deps: &[ChangeHash] = self
1606 +
                .changes
1607 +
                .get(&c)
1608 +
                .map(|m| m.deps.as_slice())
1609 +
                .unwrap_or(&[]);
1610 +
            if expanded {
1611 +
                let l = deps
1612 +
                    .iter()
1613 +
                    .map(|d| levels.get(d).map_or(0, |l| l + 1))
1614 +
                    .max()
1615 +
                    .unwrap_or(0);
1616 +
                levels.insert(c, l);
1617 +
            } else {
1618 +
                stack.push((c, true));
1619 +
                for d in deps {
1620 +
                    if !levels.contains_key(d) {
1621 +
                        stack.push((*d, false));
1622 +
                    }
1623 +
                }
1624 +
            }
1625 +
        }
1626 +
        levels[h]
1627 +
    }
1628 +
1541 1629
    /// Changes that no other change depends on directly, sorted.
1542 1630
    pub fn heads(&self) -> Vec<ChangeHash> {
1543 1631
        let depended: HashSet<&ChangeHash> =
…
295 unchanged lines
1839 1927
}
1840 1928
1841 1929
impl DepOracle for State {
1930 +
    /// Whether `a` depends on `b`, by a walk down from `a` that stops at
1931 +
    /// changes no higher than `b`: they cannot have `b` below them. Nothing
1932 +
    /// of the walk is kept, so asking about many pairs costs no more memory
1933 +
    /// than one walk (#25); a closure already memoised answers at once.
1842 1934
    fn depends_on(&self, a: &ChangeHash, b: &ChangeHash) -> bool {
1843 -
        a == b || self.closure(a).contains(b)
1935 +
        if a == b {
1936 +
            return true;
1937 +
        }
1938 +
        if let Some(c) = self.closure.lock().unwrap().sets.get(a) {
1939 +
            return c.contains(b);
1940 +
        }
1941 +
        if !self.changes.contains_key(b) {
1942 +
            return false;
1943 +
        }
1944 +
        let lb = self.level(b);
1945 +
        if self.level(a) <= lb {
1946 +
            return false;
1947 +
        }
1948 +
        let mut seen: HashSet<ChangeHash> = HashSet::new();
1949 +
        let mut stack: Vec<ChangeHash> = self
1950 +
            .changes
1951 +
            .get(a)
1952 +
            .map(|m| m.deps.clone())
1953 +
            .unwrap_or_default();
1954 +
        while let Some(c) = stack.pop() {
1955 +
            if c == *b {
1956 +
                return true;
1957 +
            }
1958 +
            if !seen.insert(c) || self.level(&c) <= lb {
1959 +
                continue;
1960 +
            }
1961 +
            if let Some(m) = self.changes.get(&c) {
1962 +
                stack.extend(m.deps.iter().copied());
1963 +
            }
1964 +
        }
1965 +
        false
1844 1966
    }
1845 1967
}
1846 1968
…
684 unchanged lines
2531 2653
        want.sort();
2532 2654
        assert_eq!(m, want);
2533 2655
        assert_eq!(st.heads(), want);
2656 +
    }
2657 +
2658 +
    /// The bounded walk of `depends_on` agrees with the closure on random
2659 +
    /// graphs, and the memo stays within its bound.
2660 +
    #[test]
2661 +
    fn depends_on_matches_the_closure() {
2662 +
        use rand::{Rng, SeedableRng};
2663 +
        let mut rng = rand::rngs::StdRng::seed_from_u64(11);
2664 +
        for _ in 0..10 {
2665 +
            let mut st = State::new();
2666 +
            let mut all: Vec<ChangeHash> = Vec::new();
2667 +
            for _ in 0..80 {
2668 +
                let deps: Vec<ChangeHash> =
2669 +
                    all.iter().copied().filter(|_| rng.gen_bool(0.06)).collect();
2670 +
                all.push(st.add(change(deps, vec![], vec![])).unwrap());
2671 +
            }
2672 +
            let unknown = ObjectRef([0xee; 32]);
2673 +
            for a in &all {
2674 +
                let want = st.closure(a).clone();
2675 +
                st.closure.lock().unwrap().sets.clear();
2676 +
                st.closure.lock().unwrap().held = 0;
2677 +
                for b in all.iter().chain([&unknown]) {
2678 +
                    assert_eq!(
2679 +
                        st.depends_on(a, b),
2680 +
                        a == b || want.contains(b),
2681 +
                        "{a:?} on {b:?}"
2682 +
                    );
2683 +
                }
2684 +
            }
2685 +
            assert!(st.closure.lock().unwrap().held <= CLOSURE_MEMO_HASHES);
2686 +
        }
2534 2687
    }
2535 2688
2536 2689
    /// The one-walk `maximal` agrees with the definition (not in the
…
29 unchanged lines