OXYGENHUB

Keep bookmark briefs across restarts, and log slow requests 39442b000e6b

The overview and bookmark list summarised every bookmark from its whole
state, and kept the summaries only in memory: after a restart the first
visit to doomsdayone/metalgear (162 bookmarks) took ~31 s. A state's brief
(change count, latest change, conflicted) is a pure function of its heads,
so it is now kept on disk as well, worked out four at a time when a page
needs many, and worked out as soon as a push moves a bookmark. The state
cache's count limit goes from 64, always full, to 256; the byte budget
still bounds it. Requests of two seconds or more are logged with their
method, path and status.

closes #145
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-12 18:57 UTC
Signature
✔ signed by Andy McCall
Hash
39442b000e6b8c1027d74c2f01940a76691a4e65ef17def5d269e8cb6a195bfd
Depends on
2e640ffa6c93 3f4b0ea6eabf 42fa95540468 736ae87ef318 93be9ce4653e 9ff27da517fe d23464c0ac60 f5c443378333
Depended on by
b9b74d997cec bff9106edcba 33bd0bd45f8a f677d6270298
In bookmarks
api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
25 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/hub/src/lib.rs +17 −1
…
63 unchanged lines
64 64
65 65
/// The full router: pages, API, protocol and static assets.
66 66
pub fn router(app: Shared) -> axum::Router {
67 -
    web::router(app)
67 +
    web::router(app).layer(axum::middleware::from_fn(slow_requests))
68 68
}
69 69
70 +
/// Requests slower than this are logged, with their path (#145).
71 +
const SLOW: std::time::Duration = std::time::Duration::from_secs(2);
72 +
73 +
/// Log a slow request's method, path and time, so a slow page can be
74 +
/// found afterwards. Never the query, which may hold a token.
75 +
async fn slow_requests(req: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response {
76 +
    let (method, path) = (req.method().clone(), req.uri().path().to_string());
77 +
    let start = std::time::Instant::now();
78 +
    let res = next.run(req).await;
79 +
    let took = start.elapsed();
80 +
    if took >= SLOW {
81 +
        tracing::warn!("slow request: {method} {path} took {:.1} s ({})", took.as_secs_f64(), res.status().as_u16());
82 +
    }
83 +
    res
84 +
}
85 +
70 86
/// Serve on the configured addresses until the process is told to stop.
71 87
pub async fn serve(app: Shared) -> anyhow::Result<()> {
72 88
    let router = router(app.clone());
…
26 unchanged lines
modified crates/hub/src/protocol.rs +9 −0
…
217 unchanged lines
218 218
                    crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await;
219 219
                }
220 220
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
221 +
                // The moved bookmarks' briefs, worked out now so no page
222 +
                // waits for them (#145).
223 +
                if let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) {
224 +
                    let all: Vec<Vec<oxy_core::hash::ChangeHash>> = moved.iter().map(|(_, _, h)| h.clone()).collect();
225 +
                    let warm = app.clone();
226 +
                    tokio::spawn(async move {
227 +
                        warm.repos.read(&name, move |r, cache| Ok(cache.briefs(r, &all).map(|_| ())?)).await.ok();
228 +
                    });
229 +
                }
221 230
                for (bookmark, state, heads) in &moved {
222 231
                    crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await;
223 232
                }
…
30 unchanged lines
modified crates/hub/src/repos/cache.rs +155 −4
…
6 unchanged lines
7 7
use oxy_core::repo::Repo;
8 8
use oxy_core::state::{Snapshot, State};
9 9
use std::collections::HashMap;
10 +
use std::path::{Path, PathBuf};
10 11
use std::sync::{Arc, Mutex};
11 12
12 13
/// A state and its materialised snapshot.
…
52 unchanged lines
65 66
    pub conflicted: bool,
66 67
}
67 68
69 +
impl Summary {
70 +
    pub fn brief(&self) -> Brief {
71 +
        Brief { changes: self.changes.len(), latest_ms: self.latest_ms, conflicted: self.conflicted }
72 +
    }
73 +
}
74 +
75 +
/// What a bookmark list and the overview show of a state (#145): how many
76 +
/// changes, the latest, and whether it is conflicted. A pure function of
77 +
/// the heads, so it is kept on disk as well and a restart costs nothing:
78 +
/// metalgear's 162 bookmarks took ~31 s to summarise again after one.
79 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80 +
pub struct Brief {
81 +
    pub changes: usize,
82 +
    pub latest_ms: u64,
83 +
    pub conflicted: bool,
84 +
}
85 +
68 86
/// Summaries kept; each is the state's change set, a few hundred kilobytes
69 87
/// for a long history.
70 88
const SUMMARIES: usize = 2048;
89 +
/// Most summaries computed at once when a page needs many.
90 +
const PARALLEL: usize = 4;
71 91
72 92
/// Summaries by heads, each with when it was last used, and the clock.
73 93
type Summaries = (HashMap<Key, (Arc<Summary>, u64)>, u64);
…
5 unchanged lines
79 99
    budget: usize,
80 100
    inner: Mutex<(HashMap<Key, Entry>, u64)>,
81 101
    summaries: Mutex<Summaries>,
102 +
    /// Where briefs are kept on disk, when anywhere.
103 +
    briefs: Option<PathBuf>,
82 104
}
83 105
84 106
impl StateCache {
…
2 unchanged lines
87 109
    }
88 110
89 111
    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)) }
112 +
        StateCache {
113 +
            capacity: capacity.max(1),
114 +
            budget,
115 +
            inner: Mutex::new((HashMap::new(), 0)),
116 +
            summaries: Mutex::new((HashMap::new(), 0)),
117 +
            briefs: None,
118 +
        }
119 +
    }
120 +
121 +
    /// Keep briefs on disk under `dir`, one small file per state.
122 +
    pub fn with_briefs(mut self, dir: PathBuf) -> StateCache {
123 +
        self.briefs = Some(dir);
124 +
        self
125 +
    }
126 +
127 +
    fn brief_path(&self, key: &Key) -> Option<PathBuf> {
128 +
        let dir = self.briefs.as_ref()?;
129 +
        let mut h = blake3::Hasher::new();
130 +
        for c in &key.heads {
131 +
            h.update(&c.0);
132 +
        }
133 +
        Some(dir.join(hex::encode(key.repo)).join(h.finalize().to_hex().as_str()))
134 +
    }
135 +
136 +
    fn known_brief(&self, key: &Key) -> Option<Brief> {
137 +
        if let Some((s, _)) = self.summaries.lock().unwrap().0.get(key) {
138 +
            return Some(s.brief());
139 +
        }
140 +
        read_brief(&self.brief_path(key)?)
141 +
    }
142 +
143 +
    /// The brief of the state with these heads: from memory, from disk, or
144 +
    /// worked out and kept.
145 +
    pub fn brief(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Brief, oxy_core::repo::RepoError> {
146 +
        Ok(self.briefs(repo, &[heads.to_vec()])?[0])
147 +
    }
148 +
149 +
    /// The briefs of many states, those not yet known worked out a few at
150 +
    /// a time in parallel.
151 +
    pub fn briefs(&self, repo: &Repo, all: &[Vec<ChangeHash>]) -> Result<Vec<Brief>, oxy_core::repo::RepoError> {
152 +
        let id = repo.id()?;
153 +
        let mut out: Vec<Option<Brief>> = all.iter().map(|h| self.known_brief(&Key { repo: id, heads: sorted(h) })).collect();
154 +
        let missing: Vec<usize> = (0..all.len()).filter(|&i| out[i].is_none()).collect();
155 +
        if missing.is_empty() {
156 +
            return Ok(out.into_iter().map(|b| b.expect("known")).collect());
157 +
        }
158 +
        let next = Mutex::new(missing.iter());
159 +
        let found: Mutex<Vec<(usize, Result<Brief, oxy_core::repo::RepoError>)>> = Mutex::new(Vec::new());
160 +
        let threads = PARALLEL.min(missing.len()).min(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1));
161 +
        // A `Repo` stays on its thread, so each worker opens its own.
162 +
        let root = repo.root().to_path_buf();
163 +
        std::thread::scope(|s| {
164 +
            for _ in 0..threads {
165 +
                s.spawn(|| {
166 +
                    let mine = Repo::open(&root);
167 +
                    loop {
168 +
                        let Some(&i) = next.lock().unwrap().next() else { break };
169 +
                        let b = match &mine {
170 +
                            Ok(r) => self.summary(r, &all[i]).map(|s| s.brief()),
171 +
                            Err(e) => Err(oxy_core::repo::RepoError::Msg(format!("open {}: {e}", root.display()))),
172 +
                        };
173 +
                        found.lock().unwrap().push((i, b));
174 +
                    }
175 +
                });
176 +
            }
177 +
        });
178 +
        for (i, b) in found.into_inner().unwrap() {
179 +
            out[i] = Some(b?);
180 +
        }
181 +
        Ok(out.into_iter().map(|b| b.expect("worked out")).collect())
91 182
    }
92 183
93 184
    /// The summary of the state with these heads: from the state cache when
94 185
    /// it holds the state, else loaded and dropped, the state cache left as
95 186
    /// it was.
96 187
    pub fn summary(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Arc<Summary>, oxy_core::repo::RepoError> {
97 -
        let mut sorted = heads.to_vec();
98 -
        sorted.sort();
99 -
        sorted.dedup();
188 +
        let sorted = sorted(heads);
100 189
        let key = Key { repo: repo.id()?, heads: sorted.clone() };
101 190
        {
102 191
            let mut guard = self.summaries.lock().unwrap();
…
13 unchanged lines
116 205
                summarise(&state, &snapshot)
117 206
            }
118 207
        });
208 +
        if let Some(path) = self.brief_path(&key) {
209 +
            write_brief(&path, summary.brief());
210 +
        }
119 211
        let mut guard = self.summaries.lock().unwrap();
120 212
        let (map, tick) = &mut *guard;
121 213
        if map.len() >= SUMMARIES {
…
48 unchanged lines
170 262
171 263
    pub fn is_empty(&self) -> bool {
172 264
        self.len() == 0
265 +
    }
266 +
}
267 +
268 +
fn sorted(heads: &[ChangeHash]) -> Vec<ChangeHash> {
269 +
    let mut v = heads.to_vec();
270 +
    v.sort();
271 +
    v.dedup();
272 +
    v
273 +
}
274 +
275 +
/// A brief on disk: `changes latest_ms conflicted`.
276 +
fn read_brief(path: &Path) -> Option<Brief> {
277 +
    let text = std::fs::read_to_string(path).ok()?;
278 +
    let mut f = text.split_whitespace();
279 +
    Some(Brief { changes: f.next()?.parse().ok()?, latest_ms: f.next()?.parse().ok()?, conflicted: f.next()? == "1" })
280 +
}
281 +
282 +
/// Kept when it can be; a brief that is not kept is only worked out again.
283 +
fn write_brief(path: &Path, b: Brief) {
284 +
    let Some(dir) = path.parent() else { return };
285 +
    if std::fs::create_dir_all(dir).is_err() {
286 +
        return;
287 +
    }
288 +
    let tmp = path.with_extension(format!("tmp{}", std::process::id()));
289 +
    if std::fs::write(&tmp, format!("{} {} {}", b.changes, b.latest_ms, u8::from(b.conflicted))).is_ok() {
290 +
        std::fs::rename(&tmp, path).ok();
173 291
    }
174 292
}
175 293
…
57 unchanged lines
233 351
        roomy.load(&repo, &[one]).unwrap();
234 352
        roomy.load(&repo, &[two]).unwrap();
235 353
        assert_eq!(roomy.len(), 2);
354 +
    }
355 +
356 +
    #[test]
357 +
    fn briefs_are_worked_out_in_parallel_and_survive_a_restart() {
358 +
        let dir = tempfile::tempdir().unwrap();
359 +
        let repo = Repo::init(dir.path()).unwrap();
360 +
        std::fs::write(dir.path().join("a.txt"), "a\n").unwrap();
361 +
        let one = repo.commit("one", &[]).unwrap();
362 +
        std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
363 +
        let two = repo.commit("two", &[]).unwrap();
364 +
        let kept = tempfile::tempdir().unwrap();
365 +
        let cache = StateCache::new(8).with_briefs(kept.path().to_path_buf());
366 +
        let got = cache.briefs(&repo, &[vec![one], vec![one, two], vec![]]).unwrap();
367 +
        assert_eq!(got.iter().map(|b| b.changes).collect::<Vec<_>>(), vec![1, 2, 0]);
368 +
        assert!(got[1].latest_ms >= got[0].latest_ms && !got[1].conflicted);
369 +
        // A new cache (a restarted hub) reads them back without loading a
370 +
        // state: a repository that no longer has the changes still answers.
371 +
        let gone = tempfile::tempdir().unwrap();
372 +
        let empty = Repo::init(gone.path()).unwrap();
373 +
        let empty_id = empty.id().unwrap();
374 +
        for f in std::fs::read_dir(kept.path()).unwrap() {
375 +
            let f = f.unwrap().path();
376 +
            std::fs::rename(&f, kept.path().join(hex::encode(empty_id))).unwrap();
377 +
        }
378 +
        let restarted = StateCache::new(8).with_briefs(kept.path().to_path_buf());
379 +
        assert_eq!(restarted.brief(&empty, &[two, one]).unwrap(), got[1], "from disk, whatever the heads' order");
380 +
        assert!(StateCache::new(8).brief(&empty, &[one, two]).is_err(), "without the disk it would have to load");
381 +
        // Unknown heads are an error, not a guess.
382 +
        assert!(cache.briefs(&repo, &[vec![one], vec![oxy_core::hash::ObjectRef([9; 32])]]).is_err());
383 +
        // A bare repository, as the hub keeps them, works across threads too.
384 +
        let bare_dir = tempfile::tempdir().unwrap();
385 +
        let bare = Repo::init_bare(bare_dir.path()).unwrap();
386 +
        assert_eq!(cache.briefs(&bare, &[vec![], vec![]]).unwrap().len(), 2);
236 387
    }
237 388
}
modified crates/hub/src/repos/mod.rs +6 −1
…
45 unchanged lines
46 46
47 47
impl Repos {
48 48
    pub fn new(root: PathBuf) -> Repos {
49 -
        Repos { root, locks: Mutex::new(HashMap::new()), cache: Arc::new(StateCache::new(64)), searches: search::SearchCache::default() }
49 +
        // States are bounded by their estimated bytes (`cache::budget`); the
50 +
        // count only stops many tiny states piling up. At 64 it was always
51 +
        // full and evicting long before the bytes were (#145).
52 +
        let briefs = root.parent().unwrap_or(&root).join("cache").join("summaries").join(root.file_name().unwrap_or_default());
53 +
        let cache = Arc::new(StateCache::new(256).with_briefs(briefs));
54 +
        Repos { root, locks: Mutex::new(HashMap::new()), cache, searches: search::SearchCache::default() }
50 55
    }
51 56
52 57
    pub fn cache(&self) -> &Arc<StateCache> {
…
174 unchanged lines
modified crates/hub/src/repos/view.rs +8 −5
…
744 unchanged lines
745 745
}
746 746
747 747
pub fn bookmarks(repo: &Repo, cache: &StateCache, default: &str) -> Result<Vec<BookmarkView>> {
748 +
    let list = repo.bookmarks()?;
749 +
    let briefs = cache.briefs(repo, &list.iter().map(|b| b.heads.clone()).collect::<Vec<_>>())?;
748 750
    let mut out = Vec::new();
749 -
    for b in repo.bookmarks()? {
750 -
        let s = cache.summary(repo, &b.heads)?;
751 +
    for (b, s) in list.iter().zip(briefs) {
751 752
        out.push(BookmarkView {
752 753
            name: b.name.clone(),
753 754
            heads: b.heads.iter().map(|h| h.to_hex()).collect(),
754 -
            changes: s.changes.len(),
755 +
            changes: s.changes,
755 756
            default: b.name == default,
756 757
            conflicted: s.conflicted,
757 758
            latest_ms: s.latest_ms,
…
107 unchanged lines
865 866
}
866 867
867 868
pub fn conflicts(repo: &Repo, cache: &StateCache) -> Result<Vec<ConflictView>> {
869 +
    let list = repo.bookmarks()?;
870 +
    let briefs = cache.briefs(repo, &list.iter().map(|b| b.heads.clone()).collect::<Vec<_>>())?;
868 871
    let mut out = Vec::new();
869 -
    for b in repo.bookmarks()? {
872 +
    for (b, brief) in list.iter().zip(briefs) {
870 873
        // Only a conflicted state is loaded whole.
871 -
        if !cache.summary(repo, &b.heads)?.conflicted {
874 +
        if !brief.conflicted {
872 875
            continue;
873 876
        }
874 877
        let loaded = cache.load(repo, &b.heads)?;
…
244 unchanged lines
modified docs/spec.md +1 −1
…
448 unchanged lines
449 449
450 450
### 16.3 Caches
451 451
452 -
Keyed by state hash, so they never go stale: loaded `State` objects (LRU by memory), tree listings, rendered file views, blame, history graph layouts per bookmark head set, projected states per proposal, archives, code search results. Keyed by content: rendered Markdown (BLAKE3 of the text and its two link bases, 32 MiB) and syntax highlighted files (BLAKE3 of the file name, which picks the syntax, and the text, 64 MiB), each an LRU bounded by the bytes it holds, rendering outside the lock; an entry larger than a quarter of its budget is not kept. Keyed by repository: bookmark and tag lists, invalidated by the push path. `ETag` and `Cache-Control: private, max-age=0, must-revalidate` on pages; `immutable` on content-addressed responses (raw files by hash, archives by state).
452 +
Keyed by state hash, so they never go stale: loaded `State` objects (LRU by memory, up to 256 and `OXYHUB_STATE_CACHE_MB`, default 3 GiB), a state's brief (its change count, latest change and whether it is conflicted, #145: kept in memory and on disk under `<data>/cache/summaries/`, worked out four at a time when a page needs many and as soon as a push moves a bookmark, so a restart never makes a bookmark list or an overview load every bookmark's state again), tree listings, rendered file views, blame, history graph layouts per bookmark head set, projected states per proposal, archives, code search results. Keyed by content: rendered Markdown (BLAKE3 of the text and its two link bases, 32 MiB) and syntax highlighted files (BLAKE3 of the file name, which picks the syntax, and the text, 64 MiB), each an LRU bounded by the bytes it holds, rendering outside the lock; an entry larger than a quarter of its budget is not kept. Keyed by repository: bookmark and tag lists, invalidated by the push path. `ETag` and `Cache-Control: private, max-age=0, must-revalidate` on pages; `immutable` on content-addressed responses (raw files by hash, archives by state). A request that takes two seconds or more is logged at warn with its method, path (never the query) and status.
453 453
454 454
### 16.4 Large repositories
455 455
…
99 unchanged lines