State cache: bound it by size as well as count 736ae87ef318
The cache kept up to 64 loaded states whatever their size, and a state with its snapshot can be hundreds of MB for a large repository: the hub reached 17 GB before, and 11.9 GB 16 hours after the last restart. Each entry is now weighed (rendered text x3, plus a kilobyte per change) and the oldest go until the total fits 3 GiB (OXYHUB_STATE_CACHE_MB to change it); the newest always stays. closes #117
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-07 10:08 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
736ae87ef318fdbfc67ad4189a9ca83301980140227892e9d4c6e2725a842eac- Depends on
- 2e640ffa6c93 9ff27da517fe
- Depended on by
- d2077a57b1f9 39442b000e6b b210727578e8
- 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
- 9 atoms, 0 tree operations, 1 file
Browse the tree at this change
modified crates/hub/src/repos/cache.rs +66 −5
| … | 24 unchanged lines |
||
| 25 | 25 | struct Entry { |
|
| 26 | 26 | loaded: Arc<Loaded>, |
|
| 27 | 27 | last_used: u64, |
|
| 28 | + | /// Its estimated size in bytes (`weight`). |
|
| 29 | + | weight: usize, |
|
| 28 | 30 | } |
|
| 29 | 31 | ||
| 32 | + | /// A loaded state's estimated size: its rendered text three times over (the |
|
| 33 | + | /// text, and the line graphs it was rendered from), plus a kilobyte per |
|
| 34 | + | /// change. Rough, but proportional: a repository of large files weighs |
|
| 35 | + | /// more than one of small ones. |
|
| 36 | + | fn weight(l: &Loaded) -> usize { |
|
| 37 | + | let text: usize = l |
|
| 38 | + | .snapshot |
|
| 39 | + | .files |
|
| 40 | + | .values() |
|
| 41 | + | .map(|f| match &f.content { |
|
| 42 | + | oxy_core::state::FileContent::Text { bytes, .. } => bytes.len(), |
|
| 43 | + | _ => 64, |
|
| 44 | + | }) |
|
| 45 | + | .sum(); |
|
| 46 | + | text.saturating_mul(3).saturating_add(l.state.len().saturating_mul(1024)) |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// What the state cache may hold, in bytes: OXYHUB_STATE_CACHE_MB, else |
|
| 50 | + | /// 3 GiB. A count alone let a few large repositories' states grow the hub |
|
| 51 | + | /// to 17 GB. |
|
| 52 | + | fn budget() -> usize { |
|
| 53 | + | std::env::var("OXYHUB_STATE_CACHE_MB").ok().and_then(|v| v.parse::<usize>().ok()).unwrap_or(3 * 1024).saturating_mul(1 << 20) |
|
| 54 | + | } |
|
| 55 | + | ||
| 30 | 56 | /// What the bookmark lists and a change's "in bookmarks" need of a state: |
|
| 31 | 57 | /// small, so many are kept, and computed without filling the state cache. |
|
| 32 | 58 | /// A repository with more bookmarks than the state cache holds otherwise |
|
| … | 16 unchanged lines |
||
| 49 | 75 | /// A bounded least-recently-used cache of loaded states. |
|
| 50 | 76 | pub struct StateCache { |
|
| 51 | 77 | capacity: usize, |
|
| 78 | + | /// Bytes of estimated state it may hold (`budget`). |
|
| 79 | + | budget: usize, |
|
| 52 | 80 | inner: Mutex<(HashMap<Key, Entry>, u64)>, |
|
| 53 | 81 | summaries: Mutex<Summaries>, |
|
| 54 | 82 | } |
|
| 55 | 83 | ||
| 56 | 84 | impl StateCache { |
|
| 57 | 85 | pub fn new(capacity: usize) -> StateCache { |
|
| 58 | - | StateCache { capacity: capacity.max(1), inner: Mutex::new((HashMap::new(), 0)), summaries: Mutex::new((HashMap::new(), 0)) } |
|
| 86 | + | StateCache::with_budget(capacity, budget()) |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | pub fn with_budget(capacity: usize, budget: usize) -> StateCache { |
|
| 90 | + | StateCache { capacity: capacity.max(1), budget, inner: Mutex::new((HashMap::new(), 0)), summaries: Mutex::new((HashMap::new(), 0)) } |
|
| 59 | 91 | } |
|
| 60 | 92 | ||
| 61 | 93 | /// The summary of the state with these heads: from the state cache when |
|
| … | 51 unchanged lines |
||
| 113 | 145 | let state = repo.load_state(&sorted)?; |
|
| 114 | 146 | let snapshot = state.materialize(); |
|
| 115 | 147 | let loaded = Arc::new(Loaded { heads: sorted, state, snapshot }); |
|
| 148 | + | let w = weight(&loaded); |
|
| 116 | 149 | let mut guard = self.inner.lock().unwrap(); |
|
| 117 | 150 | let (map, tick) = &mut *guard; |
|
| 118 | - | if map.len() >= self.capacity { |
|
| 119 | - | if let Some(oldest) = map.iter().min_by_key(|(_, e)| e.last_used).map(|(k, _)| k.clone()) { |
|
| 120 | - | map.remove(&oldest); |
|
| 151 | + | // The oldest go until it fits by count and by size; the new one |
|
| 152 | + | // stays even when it alone is over the budget. |
|
| 153 | + | let budget = self.budget; |
|
| 154 | + | let mut total: usize = map.values().map(|e| e.weight).sum::<usize>() + w; |
|
| 155 | + | while !map.is_empty() && (map.len() >= self.capacity || total > budget) { |
|
| 156 | + | let Some(oldest) = map.iter().min_by_key(|(_, e)| e.last_used).map(|(k, _)| k.clone()) else { |
|
| 157 | + | break; |
|
| 158 | + | }; |
|
| 159 | + | if let Some(e) = map.remove(&oldest) { |
|
| 160 | + | total -= e.weight; |
|
| 121 | 161 | } |
|
| 122 | 162 | } |
|
| 123 | - | map.insert(key, Entry { loaded: loaded.clone(), last_used: *tick }); |
|
| 163 | + | map.insert(key, Entry { loaded: loaded.clone(), last_used: *tick, weight: w }); |
|
| 124 | 164 | Ok(loaded) |
|
| 125 | 165 | } |
|
| 126 | 166 | ||
| … | 45 unchanged lines |
||
| 172 | 212 | assert!(Arc::ptr_eq(&a, &cache.summary(&repo, &[]).unwrap()), "kept"); |
|
| 173 | 213 | assert!(a.changes.is_empty() && !a.conflicted); |
|
| 174 | 214 | assert!(cache.summary(&repo, &[oxy_core::hash::ObjectRef([3; 32])]).is_err()); |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | #[test] |
|
| 218 | + | fn a_size_budget_evicts_the_oldest_and_keeps_the_newest() { |
|
| 219 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 220 | + | let repo = Repo::init(dir.path()).unwrap(); |
|
| 221 | + | std::fs::write(dir.path().join("a.txt"), "a\n".repeat(1000)).unwrap(); |
|
| 222 | + | let one = repo.commit("one", &[]).unwrap(); |
|
| 223 | + | std::fs::write(dir.path().join("b.txt"), "b\n".repeat(1000)).unwrap(); |
|
| 224 | + | let two = repo.commit("two", &[]).unwrap(); |
|
| 225 | + | // Room for nothing: each load stays, the one before goes. |
|
| 226 | + | let cache = StateCache::with_budget(64, 0); |
|
| 227 | + | cache.load(&repo, &[one]).unwrap(); |
|
| 228 | + | assert_eq!(cache.len(), 1, "kept though over the budget"); |
|
| 229 | + | cache.load(&repo, &[two]).unwrap(); |
|
| 230 | + | assert_eq!(cache.len(), 1, "the older one went"); |
|
| 231 | + | // Room for plenty: both stay. |
|
| 232 | + | let roomy = StateCache::with_budget(64, 1 << 30); |
|
| 233 | + | roomy.load(&repo, &[one]).unwrap(); |
|
| 234 | + | roomy.load(&repo, &[two]).unwrap(); |
|
| 235 | + | assert_eq!(roomy.len(), 2); |
|
| 175 | 236 | } |
|
| 176 | 237 | } |