OXYGENHUB

Cache rendered Markdown and highlighted files by content, in memory-bounded LRUs f5c443378333

refs #14
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 14:06 UTC
Signature
✔ signed by Andy McCall
Hash
f5c4433783335420787bf7c185fdc00ce8b856d2b57945ae841c88e94e8537ec
Depends on
93be9ce4653e c40c2d3edb51 d23464c0ac60
Depended on by
f157bcea0e32 39442b000e6b
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
14 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/hub/src/render/cache.rs +137 −0
1 +
//! Caches of rendered output keyed by what the output depends on (hub spec
2 +
//! §16.3): the BLAKE3 of the content and of anything else that changes the
3 +
//! result, such as a Markdown file's link bases or a file's name, which
4 +
//! picks its syntax. Bounded by the bytes they hold; the least recently
5 +
//! used entries go first.
6 +
7 +
use std::collections::HashMap;
8 +
use std::sync::{Arc, Mutex};
9 +
10 +
pub struct Lru<V> {
11 +
    inner: Mutex<Inner<V>>,
12 +
}
13 +
14 +
struct Inner<V> {
15 +
    budget: usize,
16 +
    used: usize,
17 +
    tick: u64,
18 +
    hits: u64,
19 +
    misses: u64,
20 +
    map: HashMap<[u8; 32], (u64, Arc<V>, usize)>,
21 +
}
22 +
23 +
/// Something whose size counts against a cache's budget.
24 +
pub trait Weigh {
25 +
    fn weight(&self) -> usize;
26 +
}
27 +
28 +
impl Weigh for String {
29 +
    fn weight(&self) -> usize {
30 +
        self.len() + 32
31 +
    }
32 +
}
33 +
34 +
impl Weigh for Vec<String> {
35 +
    fn weight(&self) -> usize {
36 +
        self.iter().map(|s| s.len() + 24).sum::<usize>() + 32
37 +
    }
38 +
}
39 +
40 +
/// The key for a set of parts: BLAKE3 of each part's length and bytes.
41 +
pub fn key(parts: &[&[u8]]) -> [u8; 32] {
42 +
    let mut h = blake3::Hasher::new();
43 +
    for p in parts {
44 +
        h.update(&(p.len() as u64).to_le_bytes());
45 +
        h.update(p);
46 +
    }
47 +
    *h.finalize().as_bytes()
48 +
}
49 +
50 +
impl<V: Weigh> Lru<V> {
51 +
    pub fn new(budget: usize) -> Lru<V> {
52 +
        Lru { inner: Mutex::new(Inner { budget, used: 0, tick: 0, hits: 0, misses: 0, map: HashMap::new() }) }
53 +
    }
54 +
55 +
    /// The cached value for `key`, or `make()` stored and returned. `make`
56 +
    /// runs without the lock held, so a slow render blocks nobody.
57 +
    pub fn get_or(&self, key: [u8; 32], make: impl FnOnce() -> V) -> Arc<V> {
58 +
        {
59 +
            let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
60 +
            g.tick += 1;
61 +
            let tick = g.tick;
62 +
            if let Some(entry) = g.map.get_mut(&key) {
63 +
                entry.0 = tick;
64 +
                let v = entry.1.clone();
65 +
                g.hits += 1;
66 +
                return v;
67 +
            }
68 +
            g.misses += 1;
69 +
        }
70 +
        let value = Arc::new(make());
71 +
        let weight = value.weight();
72 +
        let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
73 +
        if weight > g.budget / 4 {
74 +
            return value; // too big to be worth keeping
75 +
        }
76 +
        g.tick += 1;
77 +
        let tick = g.tick;
78 +
        if let Some(old) = g.map.insert(key, (tick, value.clone(), weight)) {
79 +
            g.used -= old.2;
80 +
        }
81 +
        g.used += weight;
82 +
        while g.used > g.budget {
83 +
            let Some((&oldest, _)) = g.map.iter().min_by_key(|(_, e)| e.0) else { break };
84 +
            if let Some(e) = g.map.remove(&oldest) {
85 +
                g.used -= e.2;
86 +
            }
87 +
        }
88 +
        value
89 +
    }
90 +
91 +
    /// (hits, misses, bytes held).
92 +
    pub fn stats(&self) -> (u64, u64, usize) {
93 +
        let g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
94 +
        (g.hits, g.misses, g.used)
95 +
    }
96 +
}
97 +
98 +
#[cfg(test)]
99 +
mod tests {
100 +
    use super::*;
101 +
102 +
    #[test]
103 +
    fn hits_misses_and_eviction() {
104 +
        let c: Lru<String> = Lru::new(400);
105 +
        let mut runs = 0;
106 +
        for _ in 0..3 {
107 +
            c.get_or(key(&[b"a"]), || {
108 +
                runs += 1;
109 +
                "x".repeat(50)
110 +
            });
111 +
        }
112 +
        assert_eq!(runs, 1, "rendered once");
113 +
        assert_eq!(c.stats().0, 2);
114 +
        // Filling past the budget drops the least recently used.
115 +
        c.get_or(key(&[b"b"]), || "y".repeat(90));
116 +
        c.get_or(key(&[b"a"]), || unreachable!());
117 +
        c.get_or(key(&[b"c"]), || "z".repeat(90));
118 +
        c.get_or(key(&[b"d"]), || "w".repeat(90));
119 +
        assert!(c.stats().2 <= 400);
120 +
        let mut again = false;
121 +
        c.get_or(key(&[b"b"]), || {
122 +
            again = true;
123 +
            String::new()
124 +
        });
125 +
        assert!(again, "b was the least recently used and went first");
126 +
        // Something larger than a quarter of the budget is not kept.
127 +
        c.get_or(key(&[b"huge"]), || "h".repeat(200));
128 +
        let mut rerun = false;
129 +
        c.get_or(key(&[b"huge"]), || {
130 +
            rerun = true;
131 +
            String::new()
132 +
        });
133 +
        assert!(rerun);
134 +
        // Parts are length-framed: ("ab","c") and ("a","bc") differ.
135 +
        assert_ne!(key(&[b"ab", b"c"]), key(&[b"a", b"bc"]));
136 +
    }
137 +
}
modified crates/hub/src/render/highlight.rs +19 −1
…
25 unchanged lines
26 26
        .map(|s| s.name.as_str())
27 27
}
28 28
29 +
/// Highlighted files by (file name, text): 64 MiB.
30 +
fn cache() -> &'static super::cache::Lru<Vec<String>> {
31 +
    static C: OnceLock<super::cache::Lru<Vec<String>>> = OnceLock::new();
32 +
    C.get_or_init(|| super::cache::Lru::new(64 << 20))
33 +
}
34 +
35 +
/// (hits, misses, bytes) of the highlighted file cache.
36 +
pub fn cache_stats() -> (u64, u64, usize) {
37 +
    cache().stats()
38 +
}
39 +
29 40
/// Highlight `text`, returning one HTML fragment per line (no trailing
30 -
/// newline); plain escaped lines when the language is unknown.
41 +
/// newline); plain escaped lines when the language is unknown. Cached by
42 +
/// the file's name (which picks the syntax) and its text.
31 43
pub fn lines(path: &str, text: &str) -> Vec<String> {
44 +
    let name = path.rsplit('/').next().unwrap_or(path);
45 +
    let key = super::cache::key(&[b"hl1", name.as_bytes(), text.as_bytes()]);
46 +
    cache().get_or(key, || lines_uncached(path, text)).as_ref().clone()
47 +
}
48 +
49 +
fn lines_uncached(path: &str, text: &str) -> Vec<String> {
32 50
    let set = syntaxes();
33 51
    let ext = path.rsplit('.').next().unwrap_or("");
34 52
    let name = path.rsplit('/').next().unwrap_or(path);
…
71 unchanged lines
modified crates/hub/src/render/markdown.rs +17 −1
…
3 unchanged lines
4 4
5 5
use pulldown_cmark::{html, Event, Options, Parser, Tag};
6 6
7 +
/// Rendered Markdown by (text, link bases): 32 MiB.
8 +
fn cache() -> &'static super::cache::Lru<String> {
9 +
    static C: std::sync::OnceLock<super::cache::Lru<String>> = std::sync::OnceLock::new();
10 +
    C.get_or_init(|| super::cache::Lru::new(32 << 20))
11 +
}
12 +
13 +
/// (hits, misses, bytes) of the rendered Markdown cache.
14 +
pub fn cache_stats() -> (u64, u64, usize) {
15 +
    cache().stats()
16 +
}
17 +
7 18
/// `base_file` and `base_raw` are URL prefixes (ending in `/`) for links to
8 -
/// files and for images.
19 +
/// files and for images. Cached by the text and both bases.
9 20
pub fn render(text: &str, base_file: &str, base_raw: &str) -> String {
21 +
    let key = super::cache::key(&[b"md1", text.as_bytes(), base_file.as_bytes(), base_raw.as_bytes()]);
22 +
    cache().get_or(key, || render_uncached(text, base_file, base_raw)).as_ref().clone()
23 +
}
24 +
25 +
fn render_uncached(text: &str, base_file: &str, base_raw: &str) -> String {
10 26
    let mut opts = Options::empty();
11 27
    opts.insert(Options::ENABLE_TABLES);
12 28
    opts.insert(Options::ENABLE_STRIKETHROUGH);
…
50 unchanged lines
modified crates/hub/src/render/mod.rs +1 −0
…
1 unchanged lines
2 2
//! highlighting, diffs, the history graph, and human times. Each is pure
3 3
//! and unit tested.
4 4
5 +
pub mod cache;
5 6
pub mod diff;
6 7
pub mod graph;
7 8
pub mod highlight;
…
48 unchanged lines
modified crates/hub/tests/browse.rs +4 −0
…
125 unchanged lines
126 126
    assert!(page(&h.app, "/andy/demo/tree/v0.1").await.contains("README.md"));
127 127
    assert!(page(&h.app, &format!("/andy/demo/tree/{}", &c1[..10])).await.contains("README.md"));
128 128
    assert_eq!(get(&h.app, "/andy/demo/tree/main/nope").await.0, StatusCode::NOT_FOUND);
129 +
    let (hits_before, _, _) = oxygenhub::render::highlight::cache_stats();
129 130
    let f = page(&h.app, "/andy/demo/file/main/src/main.rs").await;
131 +
    let again = page(&h.app, "/andy/demo/file/main/src/main.rs").await;
132 +
    assert!(oxygenhub::render::highlight::cache_stats().0 > hits_before, "the second view is served from the highlight cache");
133 +
    assert_eq!(f.split("<table").nth(1), again.split("<table").nth(1), "and renders the same");
130 134
    assert!(f.contains("sy-storage sy-type sy-function"), "highlighted: {f}");
131 135
    assert!(f.contains("id=\"L5\""));
132 136
    assert!(f.contains("Rust"));
…
91 unchanged lines
modified docs/spec.md +1 −1
…
438 unchanged lines
439 439
440 440
### 16.3 Caches
441 441
442 -
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 object hash: rendered Markdown, syntax highlighted files. 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).
442 +
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).
443 443
444 444
### 16.4 Large repositories
445 445
…
99 unchanged lines