OXYGENHUB

Bookmark summaries: stop walking every state through the cache 2e640ffa6c93

A change page's 'in bookmarks', the Bookmarks tab, the Conflicts tab,
compare's default and GET /bookmarks loaded every bookmark's full state.
With more bookmarks than the state cache holds (metalgear: 128 against
64) every lookup missed, each such request took 7-8 s, and it evicted
what every other page had cached. They now read a small per-heads
summary (change set, latest time, conflicted), kept in its own cache and
computed without filling the state cache; the Conflicts tab loads only
conflicted states whole.

closes #116
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-06 17:02 UTC
Signature
✔ signed by Andy McCall
Hash
2e640ffa6c93ef7babddd7517a807a974ff423315d05bbc79884b9e374f260f8
Depends on
9ff27da517fe d23464c0ac60
Depended on by
39442b000e6b 736ae87ef318
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
13 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/hub/src/repos/cache.rs +78 −1
…
26 unchanged lines
27 27
    last_used: u64,
28 28
}
29 29
30 +
/// What the bookmark lists and a change's "in bookmarks" need of a state:
31 +
/// small, so many are kept, and computed without filling the state cache.
32 +
/// A repository with more bookmarks than the state cache holds otherwise
33 +
/// cycled through it on every such page, so nothing stayed cached
34 +
/// (metalgear's 128 bookmarks: 7.7 s per change page, and every page of
35 +
/// every repository slower after it).
36 +
pub struct Summary {
37 +
    pub changes: std::collections::HashSet<ChangeHash>,
38 +
    pub latest_ms: u64,
39 +
    pub conflicted: bool,
40 +
}
41 +
42 +
/// Summaries kept; each is the state's change set, a few hundred kilobytes
43 +
/// for a long history.
44 +
const SUMMARIES: usize = 2048;
45 +
46 +
/// Summaries by heads, each with when it was last used, and the clock.
47 +
type Summaries = (HashMap<Key, (Arc<Summary>, u64)>, u64);
48 +
30 49
/// A bounded least-recently-used cache of loaded states.
31 50
pub struct StateCache {
32 51
    capacity: usize,
33 52
    inner: Mutex<(HashMap<Key, Entry>, u64)>,
53 +
    summaries: Mutex<Summaries>,
34 54
}
35 55
36 56
impl StateCache {
37 57
    pub fn new(capacity: usize) -> StateCache {
38 -
        StateCache { capacity: capacity.max(1), inner: Mutex::new((HashMap::new(), 0)) }
58 +
        StateCache { capacity: capacity.max(1), inner: Mutex::new((HashMap::new(), 0)), summaries: Mutex::new((HashMap::new(), 0)) }
59 +
    }
60 +
61 +
    /// The summary of the state with these heads: from the state cache when
62 +
    /// it holds the state, else loaded and dropped, the state cache left as
63 +
    /// it was.
64 +
    pub fn summary(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Arc<Summary>, oxy_core::repo::RepoError> {
65 +
        let mut sorted = heads.to_vec();
66 +
        sorted.sort();
67 +
        sorted.dedup();
68 +
        let key = Key { repo: repo.id()?, heads: sorted.clone() };
69 +
        {
70 +
            let mut guard = self.summaries.lock().unwrap();
71 +
            let (map, tick) = &mut *guard;
72 +
            *tick += 1;
73 +
            if let Some(e) = map.get_mut(&key) {
74 +
                e.1 = *tick;
75 +
                return Ok(e.0.clone());
76 +
            }
77 +
        }
78 +
        let held = self.inner.lock().unwrap().0.get(&key).map(|e| e.loaded.clone());
79 +
        let summary = Arc::new(match held {
80 +
            Some(l) => summarise(&l.state, &l.snapshot),
81 +
            None => {
82 +
                let state = repo.load_state(&sorted)?;
83 +
                let snapshot = state.materialize();
84 +
                summarise(&state, &snapshot)
85 +
            }
86 +
        });
87 +
        let mut guard = self.summaries.lock().unwrap();
88 +
        let (map, tick) = &mut *guard;
89 +
        if map.len() >= SUMMARIES {
90 +
            if let Some(oldest) = map.iter().min_by_key(|(_, e)| e.1).map(|(k, _)| k.clone()) {
91 +
                map.remove(&oldest);
92 +
            }
93 +
        }
94 +
        map.insert(key, (summary.clone(), *tick));
95 +
        Ok(summary)
39 96
    }
40 97
41 98
    /// The state with these heads, loaded once per hub process.
…
34 unchanged lines
76 133
    }
77 134
}
78 135
136 +
fn summarise(state: &State, snapshot: &Snapshot) -> Summary {
137 +
    Summary {
138 +
        changes: state.changes().map(|(h, _)| *h).collect(),
139 +
        latest_ms: state.changes().map(|(_, m)| m.hlc.wall_ms).max().unwrap_or(0),
140 +
        conflicted: snapshot.is_conflicted(),
141 +
    }
142 +
}
143 +
79 144
#[cfg(test)]
80 145
mod tests {
81 146
    use super::*;
…
13 unchanged lines
95 160
        assert!(cache.load(&repo, &[h1]).is_err());
96 161
        assert_eq!(cache.len(), 1);
97 162
        let _ = (h2, dir);
163 +
    }
164 +
165 +
    #[test]
166 +
    fn summaries_leave_the_state_cache_alone() {
167 +
        let dir = tempfile::tempdir().unwrap();
168 +
        let repo = Repo::init_bare(dir.path()).unwrap();
169 +
        let cache = StateCache::new(2);
170 +
        let a = cache.summary(&repo, &[]).unwrap();
171 +
        assert_eq!(cache.len(), 0, "a summary does not fill the state cache");
172 +
        assert!(Arc::ptr_eq(&a, &cache.summary(&repo, &[]).unwrap()), "kept");
173 +
        assert!(a.changes.is_empty() && !a.conflicted);
174 +
        assert!(cache.summary(&repo, &[oxy_core::hash::ObjectRef([3; 32])]).is_err());
98 175
    }
99 176
}
modified crates/hub/src/repos/view.rs +10 −9
…
307 unchanged lines
308 308
    }
309 309
    let mut in_bookmarks = Vec::new();
310 310
    for b in repo.bookmarks()? {
311 -
        if cache.load(repo, &b.heads)?.state.contains(hash) {
311 +
        if cache.summary(repo, &b.heads)?.changes.contains(hash) {
312 312
            in_bookmarks.push(b.name);
313 313
        }
314 314
    }
315 315
    let mut in_tags = Vec::new();
316 316
    for t in repo.tags()? {
317 -
        if cache.load(repo, &t.heads)?.state.contains(hash) {
317 +
        if cache.summary(repo, &t.heads)?.changes.contains(hash) {
318 318
            in_tags.push(t.name);
319 319
        }
320 320
    }
…
280 unchanged lines
601 601
pub fn bookmarks(repo: &Repo, cache: &StateCache, default: &str) -> Result<Vec<BookmarkView>> {
602 602
    let mut out = Vec::new();
603 603
    for b in repo.bookmarks()? {
604 -
        let loaded = cache.load(repo, &b.heads)?;
605 -
        let state = &loaded.state;
606 -
        let latest_ms = state.changes().map(|(_, m)| m.hlc.wall_ms).max().unwrap_or(0);
607 -
        let conflicted = loaded.snapshot.is_conflicted();
604 +
        let s = cache.summary(repo, &b.heads)?;
608 605
        out.push(BookmarkView {
609 606
            name: b.name.clone(),
610 607
            heads: b.heads.iter().map(|h| h.to_hex()).collect(),
611 -
            changes: state.len(),
608 +
            changes: s.changes.len(),
612 609
            default: b.name == default,
613 -
            conflicted,
614 -
            latest_ms,
610 +
            conflicted: s.conflicted,
611 +
            latest_ms: s.latest_ms,
615 612
        });
616 613
    }
617 614
    out.sort_by(|a, b| b.default.cmp(&a.default).then_with(|| a.name.cmp(&b.name)));
…
107 unchanged lines
725 722
    let labels = head_labels(repo)?;
726 723
    let mut out = Vec::new();
727 724
    for b in repo.bookmarks()? {
725 +
        // Only a conflicted state is loaded whole.
726 +
        if !cache.summary(repo, &b.heads)?.conflicted {
727 +
            continue;
728 +
        }
728 729
        let loaded = cache.load(repo, &b.heads)?;
729 730
        let state = &loaded.state;
730 731
        let snapshot = &loaded.snapshot;
…
169 unchanged lines