OXYGENHUB

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
}