OXYGENHUB

State cache keyed by heads; history and graph through oxy-core's lanes and log window 9ff27da517fe

Every view loads states through a bounded LRU shared across threads, so a
warm overview of oxygen renders in under 2 ms instead of 300 ms.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 18:05 UTC
Signature
✔ signed by Andy McCall
Hash
9ff27da517feccb327d434773d0334aac5875014513332fdb77787069978b41f
Depends on
d23464c0ac60
Depended on by
ba0a55bb5b3c 7c06f5b28ade d2077a57b1f9 ffb67a81188c 39442b000e6b b210727578e8 736ae87ef318 2e640ffa6c93 df15b74774ca e271a37b629a 83d5052bf76c b19606f28101 75d611ab99d1 6ae41081ccd3 3f4b0ea6eabf 145306044896 b64e2537d702 de505df5cf8d 19bb31ff1f5c e1078e99c560
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.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 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
77 atoms, 1 tree operation, 11 files

Browse the tree at this change

added crates/hub/src/repos/cache.rs +99 −0
1 +
//! Loaded states cached by their heads (hub spec §16.3). A state is a pure
2 +
//! function of its heads, so entries never go stale; the cache is only
3 +
//! bounded. Shared across threads because `oxy_core::state::State` is
4 +
//! `Send + Sync`.
5 +
6 +
use oxy_core::hash::ChangeHash;
7 +
use oxy_core::repo::Repo;
8 +
use oxy_core::state::{Snapshot, State};
9 +
use std::collections::HashMap;
10 +
use std::sync::{Arc, Mutex};
11 +
12 +
/// A state and its materialised snapshot.
13 +
pub struct Loaded {
14 +
    pub heads: Vec<ChangeHash>,
15 +
    pub state: State,
16 +
    pub snapshot: Snapshot,
17 +
}
18 +
19 +
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20 +
struct Key {
21 +
    repo: [u8; 16],
22 +
    heads: Vec<ChangeHash>,
23 +
}
24 +
25 +
struct Entry {
26 +
    loaded: Arc<Loaded>,
27 +
    last_used: u64,
28 +
}
29 +
30 +
/// A bounded least-recently-used cache of loaded states.
31 +
pub struct StateCache {
32 +
    capacity: usize,
33 +
    inner: Mutex<(HashMap<Key, Entry>, u64)>,
34 +
}
35 +
36 +
impl StateCache {
37 +
    pub fn new(capacity: usize) -> StateCache {
38 +
        StateCache { capacity: capacity.max(1), inner: Mutex::new((HashMap::new(), 0)) }
39 +
    }
40 +
41 +
    /// The state with these heads, loaded once per hub process.
42 +
    pub fn load(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Arc<Loaded>, oxy_core::repo::RepoError> {
43 +
        let mut sorted = heads.to_vec();
44 +
        sorted.sort();
45 +
        sorted.dedup();
46 +
        let key = Key { repo: repo.id()?, heads: sorted.clone() };
47 +
        {
48 +
            let mut guard = self.inner.lock().unwrap();
49 +
            let (map, tick) = &mut *guard;
50 +
            *tick += 1;
51 +
            if let Some(e) = map.get_mut(&key) {
52 +
                e.last_used = *tick;
53 +
                return Ok(e.loaded.clone());
54 +
            }
55 +
        }
56 +
        let state = repo.load_state(&sorted)?;
57 +
        let snapshot = state.materialize();
58 +
        let loaded = Arc::new(Loaded { heads: sorted, state, snapshot });
59 +
        let mut guard = self.inner.lock().unwrap();
60 +
        let (map, tick) = &mut *guard;
61 +
        if map.len() >= self.capacity {
62 +
            if let Some(oldest) = map.iter().min_by_key(|(_, e)| e.last_used).map(|(k, _)| k.clone()) {
63 +
                map.remove(&oldest);
64 +
            }
65 +
        }
66 +
        map.insert(key, Entry { loaded: loaded.clone(), last_used: *tick });
67 +
        Ok(loaded)
68 +
    }
69 +
70 +
    pub fn len(&self) -> usize {
71 +
        self.inner.lock().unwrap().0.len()
72 +
    }
73 +
74 +
    pub fn is_empty(&self) -> bool {
75 +
        self.len() == 0
76 +
    }
77 +
}
78 +
79 +
#[cfg(test)]
80 +
mod tests {
81 +
    use super::*;
82 +
83 +
    #[test]
84 +
    fn caches_by_heads_and_evicts_the_oldest() {
85 +
        let dir = tempfile::tempdir().unwrap();
86 +
        let repo = Repo::init_bare(dir.path()).unwrap();
87 +
        let cache = StateCache::new(2);
88 +
        let a = cache.load(&repo, &[]).unwrap();
89 +
        let b = cache.load(&repo, &[]).unwrap();
90 +
        assert!(Arc::ptr_eq(&a, &b), "same heads, same entry");
91 +
        assert_eq!(cache.len(), 1);
92 +
        let h1 = oxy_core::hash::ObjectRef([1; 32]);
93 +
        let h2 = oxy_core::hash::ObjectRef([2; 32]);
94 +
        // Unknown heads fail to load and are not cached.
95 +
        assert!(cache.load(&repo, &[h1]).is_err());
96 +
        assert_eq!(cache.len(), 1);
97 +
        let _ = (h2, dir);
98 +
    }
99 +
}
modified crates/hub/src/api/mod.rs +2 −2
…
73 unchanged lines
74 74
async fn bookmarks(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Bookmark>>, ApiError> {
75 75
    let (repo, row) = public_row(&app, &owner, &name).await?;
76 76
    let default = row.default_bookmark.clone();
77 -
    let list = app.repos.read(&repo, move |r| view::bookmarks(r, &default)).await?;
77 +
    let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?;
78 78
    Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect()))
79 79
}
80 80
81 81
async fn tags(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Tag>>, ApiError> {
82 82
    let (repo, _) = public_row(&app, &owner, &name).await?;
83 -
    let list = app.repos.read(&repo, view::tags).await?;
83 +
    let list = app.repos.read(&repo, |r, _| view::tags(r)).await?;
84 84
    Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect()))
85 85
}
modified crates/hub/src/main.rs +1 −1
…
185 unchanged lines
186 186
    let mut problems = 0;
187 187
    for r in db::repos::list(&app.db).await? {
188 188
        let name = RepoName::new(&r.owner, &r.name).unwrap();
189 -
        let report = app.repos.read(&name, oxygenhub::repos::view::verification).await;
189 +
        let report = app.repos.read(&name, |r, cache| oxygenhub::repos::view::verification(r, cache)).await;
190 190
        match report {
191 191
            Ok(v) => {
192 192
                println!(
…
21 unchanged lines
modified crates/hub/src/render/diff.rs +6 −0
…
165 unchanged lines
166 166
    hunks
167 167
}
168 168
169 +
/// The per-file diff of one change against its base state (what
170 +
/// `oxy diff CHANGE` shows), using an already loaded base.
171 +
pub fn change_files(change: &oxy_core::objects::Change, base: &oxy_core::state::State, base_snapshot: &Snapshot) -> Vec<FileDiff> {
172 +
    oxy_core::repo::diff_files_of(change, base, base_snapshot)
173 +
}
174 +
169 175
/// Every file that differs between two snapshots, with the base content
170 176
/// of files present in both (for context).
171 177
pub fn snapshot_diff(base: &Snapshot, head: &Snapshot) -> (Vec<FileDiff>, BTreeMap<String, Vec<u8>>) {
…
64 unchanged lines
modified crates/hub/src/render/graph.rs +8 −114
1 -
//! Lane layout for the history graph (hub spec §6.7): each change takes a
2 -
//! lane, its first dependency continues that lane, other dependencies
3 -
//! take free lanes, so edges flow downward from a change to what it
4 -
//! depends on. Ported from the VS Code extension's webview.
5 -
6 -
use std::collections::HashMap;
1 +
//! The history graph's lane layout is `oxy_core::lanes`, shared with `oxy
2 +
//! log`; this module only adapts hex hashes from views to it.
7 3
8 -
/// A row of the graph: the change's lane and the edges to the rows below.
9 -
#[derive(Clone, Debug, PartialEq, Eq)]
10 -
pub struct Row {
11 -
    pub lane: usize,
12 -
    /// `(from_lane, to_lane)` for each dependency drawn from this row.
13 -
    pub edges: Vec<(usize, usize)>,
14 -
    /// Lanes that pass through this row without touching it.
15 -
    pub through: Vec<usize>,
16 -
}
4 +
pub use oxy_core::lanes::{width, Row};
17 5
18 -
/// `changes` newest first as `(hash, deps)`. Dependencies outside the list
19 -
/// end their lane at the bottom.
6 +
/// `changes` newest first as `(hash hex, deps hex)`.
20 7
pub fn layout(changes: &[(String, Vec<String>)]) -> Vec<Row> {
21 -
    let mut active: Vec<Option<String>> = Vec::new();
22 -
    let mut rows = Vec::with_capacity(changes.len());
23 -
    let known: std::collections::HashSet<&str> = changes.iter().map(|(h, _)| h.as_str()).collect();
24 -
    let mut lanes: HashMap<String, usize> = HashMap::new();
25 -
    for (hash, deps) in changes {
26 -
        let lane = match active.iter().position(|a| a.as_deref() == Some(hash)) {
27 -
            Some(l) => l,
28 -
            None => match active.iter().position(|a| a.is_none()) {
29 -
                Some(l) => {
30 -
                    active[l] = Some(hash.clone());
31 -
                    l
32 -
                }
33 -
                None => {
34 -
                    active.push(Some(hash.clone()));
35 -
                    active.len() - 1
36 -
                }
37 -
            },
38 -
        };
39 -
        lanes.insert(hash.clone(), lane);
40 -
        let mut edges = Vec::new();
41 -
        let deps: Vec<&String> = deps.iter().filter(|d| known.contains(d.as_str())).collect();
42 -
        active[lane] = None;
43 -
        for (i, d) in deps.iter().enumerate() {
44 -
            let target = match active.iter().position(|a| a.as_deref() == Some(d.as_str())) {
45 -
                Some(l) => l,
46 -
                None => {
47 -
                    let l = if i == 0 && active[lane].is_none() {
48 -
                        lane
49 -
                    } else {
50 -
                        match active.iter().position(|a| a.is_none()) {
51 -
                            Some(l) => l,
52 -
                            None => {
53 -
                                active.push(None);
54 -
                                active.len() - 1
55 -
                            }
56 -
                        }
57 -
                    };
58 -
                    active[l] = Some((*d).clone());
59 -
                    l
60 -
                }
61 -
            };
62 -
            edges.push((lane, target));
63 -
        }
64 -
        let through: Vec<usize> = active
65 -
            .iter()
66 -
            .enumerate()
67 -
            .filter(|(l, a)| a.is_some() && *l != lane && !edges.iter().any(|e| e.1 == *l))
68 -
            .map(|(l, _)| l)
69 -
            .collect();
70 -
        rows.push(Row { lane, edges, through });
71 -
        // Trim trailing free lanes so the graph stays narrow.
72 -
        while active.last().is_some_and(|a| a.is_none()) {
73 -
            active.pop();
74 -
        }
75 -
    }
76 -
    rows
77 -
}
78 -
79 -
pub fn width(rows: &[Row]) -> usize {
80 -
    rows.iter()
81 -
        .map(|r| r.lane.max(r.edges.iter().map(|e| e.1).max().unwrap_or(0)).max(r.through.iter().copied().max().unwrap_or(0)))
82 -
        .max()
83 -
        .map(|m| m + 1)
84 -
        .unwrap_or(1)
85 -
}
86 -
87 -
#[cfg(test)]
88 -
mod tests {
89 -
    use super::*;
90 -
91 -
    fn c(h: &str, deps: &[&str]) -> (String, Vec<String>) {
92 -
        (h.into(), deps.iter().map(|d| d.to_string()).collect())
93 -
    }
94 -
95 -
    #[test]
96 -
    fn linear_history_is_one_lane() {
97 -
        let rows = layout(&[c("c", &["b"]), c("b", &["a"]), c("a", &[])]);
98 -
        assert!(rows.iter().all(|r| r.lane == 0), "{rows:?}");
99 -
        assert_eq!(rows[0].edges, vec![(0, 0)]);
100 -
        assert_eq!(width(&rows), 1);
101 -
    }
102 -
103 -
    #[test]
104 -
    fn independent_changes_take_lanes_and_rejoin() {
105 -
        // d depends on b and c, which both depend on a: a diamond.
106 -
        let rows = layout(&[c("d", &["b", "c"]), c("b", &["a"]), c("c", &["a"]), c("a", &[])]);
107 -
        assert_eq!(rows[0].lane, 0);
108 -
        assert_eq!(rows[0].edges, vec![(0, 0), (0, 1)]);
109 -
        assert_eq!(rows[1].lane, 0);
110 -
        assert_eq!(rows[2].lane, 1);
111 -
        assert_eq!(rows[2].edges, vec![(1, 0)], "c joins a's lane");
112 -
        assert_eq!(rows[3].lane, 0);
113 -
        assert_eq!(width(&rows), 2);
114 -
        // Two heads with no common change: two lanes throughout.
115 -
        let rows = layout(&[c("x", &[]), c("y", &[])]);
116 -
        assert_eq!((rows[0].lane, rows[1].lane), (0, 0), "a lane is reused once its change is drawn");
117 -
    }
8 +
    let parse = |h: &str| oxy_core::hash::ObjectRef::from_hex(h).unwrap_or(oxy_core::hash::ObjectRef([0; 32]));
9 +
    let input: Vec<(oxy_core::hash::ChangeHash, Vec<oxy_core::hash::ChangeHash>)> =
10 +
        changes.iter().map(|(h, d)| (parse(h), d.iter().map(|x| parse(x)).collect())).collect();
11 +
    oxy_core::lanes::layout(&input)
118 12
}
modified crates/hub/src/repos/mod.rs +14 −5
…
4 unchanged lines
5 5
//! the repository's lock (§16.2): pushes and other writes take the write
6 6
//! half, page reads the read half.
7 7
8 +
pub mod cache;
8 9
pub mod names;
9 10
pub mod view;
10 11
12 +
pub use cache::{Loaded, StateCache};
11 13
pub use names::RepoName;
12 14
13 15
use oxy_core::repo::Repo;
…
6 unchanged lines
20 22
pub struct Repos {
21 23
    root: PathBuf,
22 24
    locks: Mutex<HashMap<String, Arc<RwLock<()>>>>,
25 +
    cache: Arc<StateCache>,
23 26
}
24 27
25 28
#[derive(Debug, thiserror::Error)]
…
12 unchanged lines
38 41
39 42
impl Repos {
40 43
    pub fn new(root: PathBuf) -> Repos {
41 -
        Repos { root, locks: Mutex::new(HashMap::new()) }
44 +
        Repos { root, locks: Mutex::new(HashMap::new()), cache: Arc::new(StateCache::new(64)) }
45 +
    }
46 +
47 +
    pub fn cache(&self) -> &Arc<StateCache> {
48 +
        &self.cache
42 49
    }
43 50
44 51
    pub fn root(&self) -> &Path {
…
52 unchanged lines
97 104
    pub async fn read<T: Send + 'static>(
98 105
        &self,
99 106
        name: &RepoName,
100 -
        f: impl FnOnce(&Repo) -> Result<T, RepoError> + Send + 'static,
107 +
        f: impl FnOnce(&Repo, &StateCache) -> Result<T, RepoError> + Send + 'static,
101 108
    ) -> Result<T, RepoError> {
102 109
        let lock = self.lock(name);
103 110
        let guard = lock.read_owned().await;
104 111
        let dir = self.dir(name);
105 112
        let exists = self.exists(name);
113 +
        let cache = self.cache.clone();
106 114
        tokio::task::spawn_blocking(move || {
107 115
            let _guard = guard;
108 116
            if !exists {
109 117
                return Err(RepoError::NotFound);
110 118
            }
111 119
            let repo = Repo::open(&dir)?;
112 -
            f(&repo)
120 +
            f(&repo, &cache)
113 121
        })
114 122
        .await
115 123
        .map_err(|e| RepoError::Io(std::io::Error::other(e)))?
…
3 unchanged lines
119 127
    pub async fn write<T: Send + 'static>(
120 128
        &self,
121 129
        name: &RepoName,
122 -
        f: impl FnOnce(&Repo) -> Result<T, RepoError> + Send + 'static,
130 +
        f: impl FnOnce(&Repo, &StateCache) -> Result<T, RepoError> + Send + 'static,
123 131
    ) -> Result<T, RepoError> {
124 132
        let lock = self.lock(name);
125 133
        let guard = lock.write_owned().await;
126 134
        let dir = self.dir(name);
127 135
        let exists = self.exists(name);
136 +
        let cache = self.cache.clone();
128 137
        tokio::task::spawn_blocking(move || {
129 138
            let _guard = guard;
130 139
            if !exists {
131 140
                return Err(RepoError::NotFound);
132 141
            }
133 142
            let repo = Repo::open(&dir)?;
134 -
            f(&repo)
143 +
            f(&repo, &cache)
135 144
        })
136 145
        .await
137 146
        .map_err(|e| RepoError::Io(std::io::Error::other(e)))?
…
12 unchanged lines
modified crates/hub/src/repos/view.rs +75 −61
…
1 unchanged lines
2 2
//! spec §1.4). Nothing here knows about HTTP or templates; every function
3 3
//! takes an open `Repo` and returns plain data.
4 4
5 -
use super::RepoError;
5 +
use super::{RepoError, StateCache};
6 6
use oxy_core::hash::{ChangeHash, KeyId};
7 7
use oxy_core::objects::{Change, NodeKind};
8 8
use oxy_core::repo::{BlameLine, FileDiff, Repo, SignatureStatus};
9 -
use oxy_core::state::{FileContent, Snapshot, State};
9 +
use oxy_core::state::{FileContent, Snapshot};
10 10
use oxy_core::tree::TreeConflict;
11 11
use std::collections::{BTreeMap, BTreeSet, HashMap};
12 12
…
22 unchanged lines
35 35
}
36 36
37 37
/// Resolve a bookmark name, a tag name, or a change hash prefix.
38 -
pub fn resolve(repo: &Repo, spec: &str) -> Result<RefTarget> {
38 +
pub fn resolve(repo: &Repo, cache: &StateCache, spec: &str) -> Result<RefTarget> {
39 39
    let view = repo.view()?;
40 40
    if let Some(heads) = view.bookmarks.get(spec).map(|st| repo.store().get_state(st).map(|s| s.heads)).transpose()? {
41 41
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Bookmark, heads });
…
1 unchanged lines
43 43
    if let Some(heads) = repo.tag_heads(spec)? {
44 44
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Tag, heads });
45 45
    }
46 -
    let hash = resolve_change(repo, spec)?;
46 +
    let hash = resolve_change(repo, cache, spec)?;
47 47
    Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] })
48 48
}
49 49
50 50
/// A change by full hash or unique hex prefix, searched over every
51 51
/// bookmark's state.
52 -
pub fn resolve_change(repo: &Repo, spec: &str) -> Result<ChangeHash> {
52 +
pub fn resolve_change(repo: &Repo, cache: &StateCache, spec: &str) -> Result<ChangeHash> {
53 53
    let spec = spec.trim_start_matches('#');
54 54
    if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) {
55 55
        return Err(fail(format!("{spec:?} is not a change")));
…
4 unchanged lines
60 60
        }
61 61
    }
62 62
    let heads = all_heads(repo)?;
63 -
    let state = repo.load_state(&heads)?;
64 -
    let matches: Vec<ChangeHash> = state.changes().map(|(h, _)| *h).filter(|h| h.starts_with_hex(spec)).collect();
63 +
    let loaded = cache.load(repo, &heads)?;
64 +
    let matches: Vec<ChangeHash> = loaded.state.changes().map(|(h, _)| *h).filter(|h| h.starts_with_hex(spec)).collect();
65 65
    match matches.len() {
66 66
        1 => Ok(matches[0]),
67 67
        0 => Err(fail(format!("no change matches {spec:?}"))),
…
168 unchanged lines
236 236
}
237 237
238 238
/// A window of the history of a state, newest first.
239 -
pub fn history(repo: &Repo, target: RefTarget, path: Option<&str>, offset: usize, limit: usize) -> Result<History> {
240 -
    let entries = match path {
241 -
        Some(p) => repo.log_for_path_in(p, &target.heads)?,
242 -
        None => repo.log_state(&target.heads)?,
239 +
pub fn history(repo: &Repo, cache: &StateCache, target: RefTarget, path: Option<&str>, offset: usize, limit: usize) -> Result<History> {
240 +
    let loaded = cache.load(repo, &target.heads)?;
241 +
    let head_set: BTreeSet<ChangeHash> = loaded.state.heads().into_iter().collect();
242 +
    let mut entries: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> = match path {
243 +
        Some(p) => {
244 +
            let Some(node) = loaded.snapshot.files.get(p).map(|f| f.node) else { return Err(fail(format!("{p} is not in that state"))) };
245 +
            loaded
246 +
                .state
247 +
                .changes()
248 +
                .filter(|(h, _)| {
249 +
                    repo.store()
250 +
                        .get_change(h)
251 +
                        .map(|c| c.tree_ops.iter().any(|o| o.node() == node) || c.edits.iter().any(|x| x.node() == node))
252 +
                        .unwrap_or(false)
253 +
                })
254 +
                .map(|(h, m)| (*h, m.clone()))
255 +
                .collect()
256 +
        }
257 +
        None => loaded.state.changes().map(|(h, m)| (*h, m.clone())).collect(),
243 258
    };
259 +
    entries.sort_by(|a, b| b.1.hlc.cmp(&a.1.hlc).then(b.0.cmp(&a.0)));
244 260
    let total = entries.len();
245 261
    let labels = head_labels(repo)?;
246 262
    let window: Vec<ChangeView> =
247 -
        entries.iter().skip(offset).take(limit).map(|e| change_view(repo, &e.hash, &e.change, e.is_head, &labels)).collect();
263 +
        entries.iter().skip(offset).take(limit).map(|(h, m)| change_view(repo, h, m, head_set.contains(h), &labels)).collect();
248 264
    Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: path.map(String::from) })
249 265
}
250 266
…
13 unchanged lines
264 280
    pub tree_ops: usize,
265 281
}
266 282
267 -
pub fn change(repo: &Repo, hash: &ChangeHash) -> Result<ChangeDetail> {
283 +
pub fn change(repo: &Repo, cache: &StateCache, hash: &ChangeHash) -> Result<ChangeDetail> {
268 284
    let c: Change = repo.store().get_change(hash)?;
269 285
    let labels = head_labels(repo)?;
270 286
    let heads = all_heads(repo)?;
271 -
    let state = repo.load_state(&heads)?;
287 +
    let loaded = cache.load(repo, &heads)?;
288 +
    let state = &loaded.state;
272 289
    let is_head = state.heads().contains(hash);
273 290
    let meta = oxy_core::state::ChangeMeta::of(&c);
274 291
    let view = change_view(repo, hash, &meta, is_head, &labels);
…
4 unchanged lines
279 296
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels))
280 297
        .collect();
281 298
    dependents.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
282 -
    let files = repo.diff_files(Some(hash))?;
283 -
    let base_state = repo.load_state(&c.deps)?;
284 -
    let base_snapshot = base_state.materialize();
299 +
    let base = cache.load(repo, &c.deps)?;
300 +
    let files = crate::render::diff::change_files(&c, &base.state, &base.snapshot);
301 +
    let base_snapshot = &base.snapshot;
285 302
    let mut bases = BTreeMap::new();
286 303
    for f in &files {
287 304
        let old = f.from.as_deref().unwrap_or(&f.path);
…
3 unchanged lines
291 308
    }
292 309
    let mut in_bookmarks = Vec::new();
293 310
    for b in repo.bookmarks()? {
294 -
        if repo.load_state(&b.heads)?.contains(hash) {
311 +
        if cache.load(repo, &b.heads)?.state.contains(hash) {
295 312
            in_bookmarks.push(b.name);
296 313
        }
297 314
    }
298 315
    let mut in_tags = Vec::new();
299 316
    for t in repo.tags()? {
300 -
        if repo.load_state(&t.heads)?.contains(hash) {
317 +
        if cache.load(repo, &t.heads)?.state.contains(hash) {
301 318
            in_tags.push(t.name);
302 319
        }
303 320
    }
…
64 unchanged lines
368 385
}
369 386
370 387
/// The entries directly under `dir` (`""` for the root).
371 -
pub fn tree(repo: &Repo, target: RefTarget, dir: &str) -> Result<Tree> {
372 -
    let state = repo.load_state(&target.heads)?;
373 -
    let snapshot = state.materialize();
374 -
    tree_of(repo, target, &snapshot, dir)
388 +
pub fn tree(repo: &Repo, cache: &StateCache, target: RefTarget, dir: &str) -> Result<Tree> {
389 +
    let loaded = cache.load(repo, &target.heads)?;
390 +
    tree_of(repo, target, &loaded.snapshot, dir)
375 391
}
376 392
377 393
pub fn tree_of(_repo: &Repo, target: RefTarget, snapshot: &Snapshot, dir: &str) -> Result<Tree> {
…
51 unchanged lines
429 445
    pub last_change: Option<ChangeView>,
430 446
}
431 447
432 -
pub fn file(repo: &Repo, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> {
433 -
    let state = repo.load_state(&target.heads)?;
434 -
    let snapshot = state.materialize();
448 +
pub fn file(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> {
449 +
    let loaded = cache.load(repo, &target.heads)?;
450 +
    let snapshot = &loaded.snapshot;
435 451
    let path = path.trim_matches('/');
436 452
    let Some(f) = snapshot.files.get(path) else { return Err(RepoError::NotFound) };
437 453
    let (kind, bytes) = match (&f.kind, &f.content) {
…
50 unchanged lines
488 504
/// Last change per entry of a directory, for tree rows (fetched lazily).
489 505
/// A directory's last change is the newest change touching anything
490 506
/// beneath it, as GitHub shows it.
491 -
pub fn last_changes(repo: &Repo, heads: &[ChangeHash], paths: &[String]) -> Result<Vec<(String, ChangeView)>> {
507 +
pub fn last_changes(repo: &Repo, cache: &StateCache, heads: &[ChangeHash], paths: &[String]) -> Result<Vec<(String, ChangeView)>> {
492 508
    let labels = head_labels(repo)?;
493 -
    let state = repo.load_state(heads)?;
494 -
    let snapshot = state.materialize();
509 +
    let loaded = cache.load(repo, heads)?;
510 +
    let state = &loaded.state;
511 +
    let snapshot = &loaded.snapshot;
495 512
    // Newest change touching each node, from one pass over the log.
496 513
    let mut newest: HashMap<oxy_core::ids::NodeId, (ChangeHash, u64)> = HashMap::new();
497 514
    for e in repo.log_state(heads)? {
…
36 unchanged lines
534 551
    pub lines: Vec<BlameLine>,
535 552
}
536 553
537 -
pub fn blame(repo: &Repo, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> {
554 +
pub fn blame(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> {
538 555
    let heads = target.heads.clone();
539 -
    let file = file(repo, target, path, render_limit)?;
556 +
    let file = file(repo, cache, target, path, render_limit)?;
540 557
    let lines = repo.blame_in(path, &heads)?;
541 558
    Ok(Blame { file, lines })
542 559
}
…
38 unchanged lines
581 598
    Expired,
582 599
}
583 600
584 -
pub fn bookmarks(repo: &Repo, default: &str) -> Result<Vec<BookmarkView>> {
601 +
pub fn bookmarks(repo: &Repo, cache: &StateCache, default: &str) -> Result<Vec<BookmarkView>> {
585 602
    let mut out = Vec::new();
586 603
    for b in repo.bookmarks()? {
587 -
        let state = repo.load_state(&b.heads)?;
604 +
        let loaded = cache.load(repo, &b.heads)?;
605 +
        let state = &loaded.state;
588 606
        let latest_ms = state.changes().map(|(_, m)| m.hlc.wall_ms).max().unwrap_or(0);
589 -
        let conflicted = state.materialize().is_conflicted();
607 +
        let conflicted = loaded.snapshot.is_conflicted();
590 608
        out.push(BookmarkView {
591 609
            name: b.name.clone(),
592 610
            heads: b.heads.iter().map(|h| h.to_hex()).collect(),
…
30 unchanged lines
623 641
    }
624 642
}
625 643
626 -
pub fn overview(repo: &Repo, default_bookmark: &str) -> Result<Overview> {
627 -
    let target = match resolve(repo, default_bookmark) {
644 +
pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str) -> Result<Overview> {
645 +
    let target = match resolve(repo, cache, default_bookmark) {
628 646
        Ok(t) => t,
629 647
        Err(_) => RefTarget { label: default_bookmark.into(), kind: RefKind::Bookmark, heads: vec![] },
630 648
    };
631 649
    let heads = target.heads.len();
632 650
    // One load of the default state serves the counts, the latest change
633 651
    // and the tree (hub spec §16.3).
634 -
    let state = repo.load_state(&target.heads)?;
635 -
    let snapshot = state.materialize();
652 +
    let loaded = cache.load(repo, &target.heads)?;
653 +
    let state = &loaded.state;
654 +
    let snapshot = &loaded.snapshot;
636 655
    let changes = state.len();
637 656
    let conflicted = snapshot.is_conflicted();
638 657
    let labels = head_labels(repo)?;
…
1 unchanged lines
640 659
        .changes()
641 660
        .max_by(|(ha, a), (hb, b)| a.hlc.cmp(&b.hlc).then(ha.cmp(hb)))
642 661
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels));
643 -
    let tree = tree_of(repo, target, &snapshot, "")?;
662 +
    let tree = tree_of(repo, target, snapshot, "")?;
644 663
    let license = tree
645 664
        .entries
646 665
        .iter()
…
3 unchanged lines
650 669
        tree,
651 670
        changes,
652 671
        heads,
653 -
        bookmarks: bookmarks(repo, default_bookmark)?,
672 +
        bookmarks: bookmarks(repo, cache, default_bookmark)?,
654 673
        tags: tags(repo)?,
655 674
        latest,
656 675
        conflicted,
…
16 unchanged lines
673 692
    pub bases: BTreeMap<String, Vec<u8>>,
674 693
}
675 694
676 -
pub fn compare(repo: &Repo, base: RefTarget, head: RefTarget) -> Result<Compare> {
677 -
    let base_state = repo.load_state(&base.heads)?;
678 -
    let head_state = repo.load_state(&head.heads)?;
695 +
pub fn compare(repo: &Repo, cache: &StateCache, base: RefTarget, head: RefTarget) -> Result<Compare> {
696 +
    let base_loaded = cache.load(repo, &base.heads)?;
697 +
    let head_loaded = cache.load(repo, &head.heads)?;
698 +
    let base_state = &base_loaded.state;
699 +
    let head_state = &head_loaded.state;
679 700
    let labels = head_labels(repo)?;
680 701
    let mut added: Vec<ChangeView> = head_state
681 702
        .changes()
…
2 unchanged lines
684 705
        .collect();
685 706
    added.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
686 707
    let missing = base_state.changes().filter(|(h, _)| !head_state.contains(h)).count();
687 -
    let base_snapshot = base_state.materialize();
688 -
    let head_snapshot = head_state.materialize();
689 -
    let (files, bases) = crate::render::diff::snapshot_diff(&base_snapshot, &head_snapshot);
708 +
    let (files, bases) = crate::render::diff::snapshot_diff(&base_loaded.snapshot, &head_loaded.snapshot);
690 709
    Ok(Compare { base, head, added, missing, files, bases })
691 710
}
692 711
…
9 unchanged lines
702 721
    pub contributors: Vec<ChangeView>,
703 722
}
704 723
705 -
pub fn conflicts(repo: &Repo) -> Result<Vec<ConflictView>> {
724 +
pub fn conflicts(repo: &Repo, cache: &StateCache) -> Result<Vec<ConflictView>> {
706 725
    let labels = head_labels(repo)?;
707 726
    let mut out = Vec::new();
708 727
    for b in repo.bookmarks()? {
709 -
        let state = repo.load_state(&b.heads)?;
710 -
        let snapshot = state.materialize();
728 +
        let loaded = cache.load(repo, &b.heads)?;
729 +
        let state = &loaded.state;
730 +
        let snapshot = &loaded.snapshot;
711 731
        for c in &snapshot.tree.conflicts {
712 -
            let (path, kind) = describe_tree_conflict(&snapshot, c);
732 +
            let (path, kind) = describe_tree_conflict(snapshot, c);
713 733
            out.push(ConflictView { bookmark: b.name.clone(), path, kind, marked: None, contributors: vec![] });
714 734
        }
715 735
        for (path, f) in &snapshot.files {
…
90 unchanged lines
806 826
    }
807 827
}
808 828
809 -
pub fn verification(repo: &Repo) -> Result<Verification> {
829 +
pub fn verification(repo: &Repo, cache: &StateCache) -> Result<Verification> {
810 830
    let heads = all_heads(repo)?;
811 -
    let state = repo.load_state(&heads)?;
831 +
    let loaded = cache.load(repo, &heads)?;
832 +
    let state = &loaded.state;
812 833
    let mut v = Verification::default();
813 834
    for (h, _) in state.changes() {
814 835
        v.total += 1;
…
15 unchanged lines
830 851
    Ok(v)
831 852
}
832 853
833 -
pub fn trust(repo: &Repo, hub_key: &KeyId) -> Result<Trust> {
854 +
pub fn trust(repo: &Repo, cache: &StateCache, hub_key: &KeyId) -> Result<Trust> {
834 855
    let policy = match repo.current_policy()? {
835 856
        Some((_, sp)) => {
836 857
            let p = &sp.policy;
…
37 unchanged lines
874 895
            }
875 896
        }
876 897
    }
877 -
    Ok(Trust { policy, verification: verification(repo)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated })
878 -
}
879 -
880 -
// ------------------------------------------------------------- helpers
881 -
882 -
/// The state of a bookmark or any heads, for callers that need it raw.
883 -
pub fn state_of(repo: &Repo, heads: &[ChangeHash]) -> Result<State> {
884 -
    Ok(repo.load_state(heads)?)
898 +
    Ok(Trust { policy, verification: verification(repo, cache)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated })
885 899
}
modified crates/hub/src/web/history.rs +10 −10
…
39 unchanged lines
40 40
    let offset = q.offset.unwrap_or(0);
41 41
    let h = app
42 42
        .repos
43 -
        .read(&repo, move |repo| {
44 -
            let target = view::resolve(repo, &spec)?;
45 -
            view::history(repo, target, path.as_deref(), offset, PAGE)
43 +
        .read(&repo, move |repo, cache| {
44 +
            let target = view::resolve(repo, cache, &spec)?;
45 +
            view::history(repo, cache, target, path.as_deref(), offset, PAGE)
46 46
        })
47 47
        .await
48 48
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
…
53 unchanged lines
102 102
    let spec = hash.clone();
103 103
    let c = app
104 104
        .repos
105 -
        .read(&repo, move |repo| {
106 -
            let h = view::resolve_change(repo, &spec)?;
107 -
            view::change(repo, &h)
105 +
        .read(&repo, move |repo, cache| {
106 +
            let h = view::resolve_change(repo, cache, &spec)?;
107 +
            view::change(repo, cache, &h)
108 108
        })
109 109
        .await
110 110
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
…
25 unchanged lines
136 136
    let (a, b) = (a.to_string(), b.to_string());
137 137
    let cmp = app
138 138
        .repos
139 -
        .read(&repo, move |repo| {
140 -
            let base = view::resolve(repo, &a)?;
141 -
            let head = view::resolve(repo, &b)?;
142 -
            view::compare(repo, base, head)
139 +
        .read(&repo, move |repo, cache| {
140 +
            let base = view::resolve(repo, cache, &a)?;
141 +
            let head = view::resolve(repo, cache, &b)?;
142 +
            view::compare(repo, cache, base, head)
143 143
        })
144 144
        .await
145 145
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
…
3 unchanged lines
modified crates/hub/src/web/refs.rs +4 −3
…
17 unchanged lines
18 18
    let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"));
19 19
    let (repo, row) = lookup(&app, &shell, &owner, &name).await?;
20 20
    let default = row.default_bookmark.clone();
21 -
    let bookmarks = app.repos.read(&repo, move |r| view::bookmarks(r, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
21 +
    let bookmarks =
22 +
        app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
22 23
    Ok(Page(BookmarksPage { shell, repo: RepoShell::new(&app, &row, "bookmarks"), bookmarks }))
23 24
}
24 25
…
8 unchanged lines
33 34
pub async fn tags(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Page<TagsPage>, Error> {
34 35
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"));
35 36
    let (repo, row) = lookup(&app, &shell, &owner, &name).await?;
36 -
    let tags = app.repos.read(&repo, view::tags).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
37 +
    let tags = app.repos.read(&repo, |r, _| view::tags(r)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
37 38
    Ok(Page(TagsPage { shell, repo: RepoShell::new(&app, &row, "tags"), tags }))
38 39
}
39 40
…
24 unchanged lines
64 65
    let shell = Shell::new(&app, &format!("/{owner}/{name}/trust"));
65 66
    let (repo, row) = lookup(&app, &shell, &owner, &name).await?;
66 67
    let key = app.hub_key.key;
67 -
    let t = app.repos.read(&repo, move |r| view::trust(r, &key)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
68 +
    let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
68 69
    Ok(Page(TrustPage { shell, repo: RepoShell::new(&app, &row, "trust"), t }))
69 70
}
70 71
…
14 unchanged lines
modified crates/hub/src/web/repo.rs +12 −11
…
21 unchanged lines
22 22
    let shell = Shell::new(&app, &format!("/{owner}/{name}"));
23 23
    let (repo, row) = lookup(&app, &shell, &owner, &name).await?;
24 24
    let default = row.default_bookmark.clone();
25 -
    let o = app.repos.read(&repo, move |r| view::overview(r, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
25 +
    let o =
26 +
        app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
26 27
    let rs = RepoShell::new(&app, &row, "code");
27 28
    let readme_html = o.readme_html(&rs, &o.tree.target.label);
28 29
    Ok(Page(OverviewPage { shell, repo: rs, o, readme_html }))
…
30 unchanged lines
59 60
    let (spec, dir) = (r.clone(), path.clone());
60 61
    let t = app
61 62
        .repos
62 -
        .read(&repo, move |repo| {
63 -
            let target = view::resolve(repo, &spec)?;
64 -
            view::tree(repo, target, &dir)
63 +
        .read(&repo, move |repo, cache| {
64 +
            let target = view::resolve(repo, cache, &spec)?;
65 +
            view::tree(repo, cache, target, &dir)
65 66
        })
66 67
        .await
67 68
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
…
50 unchanged lines
118 119
    let (spec, p) = (r.clone(), path.clone());
119 120
    let (f, blame) = app
120 121
        .repos
121 -
        .read(&repo, move |repo| {
122 -
            let target = view::resolve(repo, &spec)?;
122 +
        .read(&repo, move |repo, cache| {
123 +
            let target = view::resolve(repo, cache, &spec)?;
123 124
            if want_blame {
124 -
                let b = view::blame(repo, target, &p, limit)?;
125 +
                let b = view::blame(repo, cache, target, &p, limit)?;
125 126
                Ok((b.file, Some(b.lines)))
126 127
            } else {
127 -
                Ok((view::file(repo, target, &p, limit)?, None))
128 +
                Ok((view::file(repo, cache, target, &p, limit)?, None))
128 129
            }
129 130
        })
130 131
        .await
…
62 unchanged lines
193 194
    let spec = r.clone();
194 195
    let found = app
195 196
        .repos
196 -
        .read(&repo, move |repo| {
197 -
            let target = view::resolve(repo, &spec)?;
198 -
            view::last_changes(repo, &target.heads, &paths)
197 +
        .read(&repo, move |repo, cache| {
198 +
            let target = view::resolve(repo, cache, &spec)?;
199 +
            view::last_changes(repo, cache, &target.heads, &paths)
199 200
        })
200 201
        .await
201 202
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
…
20 unchanged lines
modified docs/plan.md +1 −2
…
25 unchanged lines
26 26
27 27
## Notes
28 28
29 -
- State caching (spec §16.3) is not started: pages load the state they need per request; the overview loads it once and reuses it. Measured on the `oxygen` repository (88 changes): overview ~0.5 s, tree ~0.14 s, history ~0.13 s, a 2 000-line file ~0.7 s. A per-heads cache is the first performance item for 0.2.
30 -
- `State` holds `Rc` values, so a cross-thread cache needs a `Send` snapshot type in `oxy-core` first.
29 +
- State caching (spec §16.3): `repos::cache::StateCache`, a bounded LRU of loaded states keyed by repository id and heads, shared across threads since `State` became `Send + Sync`. Measured on the `oxygen` repository (90 changes): overview 154 ms cold and under 2 ms warm, history 25 ms, tree under 1 ms. Rendered-page and syntax-highlight caches are still to do.