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 |