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 |