crates/gunnar-ui-server/src/readme/cache.rs
//! **The rendered SVG, held by the object it was rendered from.**
//!
//! # Why this cache never invalidates
//!
//! The key is `(namespace, name, blob oid, path)`. A git blob is immutable
//! under its id, so the answer for a key can never become wrong: a push that
//! changes the README changes the oid and asks a different question. That makes
//! this the rare cache with no invalidation path at all — only eviction, and
//! eviction costs one recompile rather than a wrong page.
//!
//! It is keyed by the BLOB and not by the commit on purpose. A repository with
//! a thousand commits and one README has one entry here, not a thousand, and a
//! reader who lands on an old revision gets the warm answer.
//!
//! # Why eviction is by insertion order and not by use
//!
//! A true LRU has to write on every read, and the write is a redb transaction —
//! which is the exact cost this cache exists to remove, on the exact path it
//! exists to make fast. Since no entry can go stale, the price of evicting the
//! wrong one is a single recompile (measured at a few milliseconds, see
//! [`crate::readme`]), and that is cheaper than a commit on every hit. So the
//! order is the order they arrived in, and it is written down here rather than
//! called an LRU.
//!
//! # Why it is in the state store rather than in memory
//!
//! Because a restart is when this matters most: a console that came back up
//! after a deploy would otherwise recompile every README anybody opens. The
//! entries are large and few, which is what redb is good at, and they are
//! opaque bytes under a string key, which is all [`crate::state::StateStore`]
//! has ever been.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use crate::state::{CacheStats, StateStore};
/// How many rendered READMEs to hold.
pub const MAX_ENTRIES: usize = 256;
/// …and how many bytes, whichever binds first.
///
/// The figure is not arbitrary: a typeset SVG costs about **105 bytes per
/// character of README** (every glyph is a `<use>` with its own coordinates),
/// so a 3 KB README is roughly 350 KB here. 64 MB is on the order of two
/// hundred of those, which is the same order as [`MAX_ENTRIES`] — the two caps
/// bind at about the same place for a typical repository, and the byte cap is
/// what catches the one README that is a book.
pub const MAX_BYTES: u64 = 64 * 1024 * 1024;
/// **What one cache entry can hold**: the bytes as they are stored, and the way
/// back.
///
/// Two implementations and no more. A typeset page is a `String` and must round
/// trip exactly — `from_stored` refusing invalid UTF-8 turns a corrupt entry
/// into a recompile rather than into a page full of replacement characters — and
/// a PDF is bytes that were never text in the first place.
pub trait Stored: Sized {
/// The value a stored entry holds, or `None` when those bytes are not one.
fn from_stored(bytes: Vec<u8>) -> Option<Self>;
/// The bytes to store.
fn as_stored(&self) -> &[u8];
}
impl Stored for String {
fn from_stored(bytes: Vec<u8>) -> Option<String> {
String::from_utf8(bytes).ok()
}
fn as_stored(&self) -> &[u8] {
self.as_bytes()
}
}
impl Stored for Vec<u8> {
fn from_stored(bytes: Vec<u8>) -> Option<Vec<u8>> {
Some(bytes)
}
fn as_stored(&self) -> &[u8] {
self
}
}
/// The rendered READMEs, and the promise that two tabs do not compile twice.
/// The renderer whose output the cache holds — nornir-docbook's version, read
/// off the workspace lock by build.rs, so a bump of that one line is what
/// retires every cached page.
///
/// `+pictures2` is this console's half of the renderer: how a page's pictures
/// are placed (`readme::pictures`, 2026-10-15). A page cached before an LFS
/// picture was followed is a page with no picture, valid under its oid and
/// stale — the same trap as a docbook bump, so the same remedy.
const RENDERER: &str = concat!("docbook-", env!("DOCBOOK_VERSION"), "+pictures2");
pub struct Cache {
/// `None` disables the cache entirely: every call renders. Only the tests
/// that are not about caching are built that way — [`crate::app::App`]
/// always has a store.
store: Option<StateStore>,
/// One lock per key, so ten browsers opening the same repository queue
/// behind one compile instead of starting ten.
///
/// The map holds the lock only while somebody wants it: the last holder
/// drops the entry, so a console that has served a million repositories is
/// not holding a million mutexes.
inflight: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// How many times the expensive path actually ran. Read by the tests that
/// assert a second call did not compile; a counter and not a log line
/// because "it did not happen" is not something a log can show.
compiles: AtomicU64,
}
impl Default for Cache {
fn default() -> Cache {
Cache::new(None)
}
}
impl std::fmt::Debug for Cache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("readme::Cache")
.field("enabled", &self.store.is_some())
.field("compiles", &self.compiles.load(Ordering::Relaxed))
.finish()
}
}
impl Cache {
pub fn new(store: Option<StateStore>) -> Cache {
Cache {
store,
inflight: Mutex::new(HashMap::new()),
compiles: AtomicU64::new(0),
}
}
/// **The state store this cache lives in**, for the other small things that
/// belong in the same database.
///
/// `None` is a cache built without one, which is half the unit tests. See
/// [`crate::reading::store`] for why a handler asks the render cache for
/// the console's state store rather than asking `App` — the short version
/// is that `App` has never had a field for it, and every subsystem that
/// needs it has been handed one.
pub fn store(&self) -> Option<&StateStore> {
self.store.as_ref()
}
/// How many renders have actually been compiled since start.
pub fn compiles(&self) -> u64 {
self.compiles.load(Ordering::Relaxed)
}
/// What the store holds right now, for `GET /api/health`-style curiosity
/// and for the tests.
pub fn stats(&self) -> CacheStats {
self.store
.as_ref()
.and_then(|store| store.cache_stats().ok())
.unwrap_or_default()
}
/// The cache key. `\u{1f}` because it cannot occur in a path, a namespace,
/// a repository name or a hex oid, so no two different tuples can spell the
/// same key.
///
/// The SHAPE is part of it: the same blob renders to a sanitised HTML
/// fragment and to a typeset SVG, and a key that did not say which would
/// serve one under the other's name the first time a reader pressed the
/// toggle. [`Cache::pdf_key`] is the same key family with the third format
/// in that slot.
///
/// The RENDERER is part of it too: a blob never changes under its oid, but
/// what the engine makes of it does. Measured 2026-09-09: nornir-docbook
/// went 0.1.1 → 0.1.2 (the selectable text layer) and the front kept
/// serving the 0.1.1 picture for every README it had ever seen — the
/// oid-keyed entries were all valid and all stale. So the docbook version
/// string is in the key and a renderer upgrade is a cache miss, never a
/// stale hit; the old entries age out under the size cap.
pub fn key(namespace: &str, name: &str, oid: &str, path: &str, shape: super::Shape) -> String {
Cache::keyed(namespace, name, oid, path, shape.name())
}
/// The same blob's PDF.
///
/// A separate constructor rather than a third [`super::Shape`], because a
/// shape is one of the two grades one function renders into a string and
/// this is bytes from another one ([`super::render_pdf`]). What matters for
/// the cache is the same either way and is asserted: the FORMAT is in the
/// key, so the PDF of a document and its typeset page are two entries and
/// neither can be served under the other's name.
pub fn pdf_key(namespace: &str, name: &str, oid: &str, path: &str) -> String {
Cache::keyed(namespace, name, oid, path, "pdf")
}
fn keyed(namespace: &str, name: &str, oid: &str, path: &str, format: &str) -> String {
let engine = RENDERER;
format!(
"readme\u{1f}{engine}\u{1f}{format}\u{1f}{namespace}\u{1f}{name}\u{1f}{oid}\u{1f}{path}"
)
}
/// Serve `key` from the store, or run `render` exactly once and keep what
/// it returns.
///
/// `render` is `FnOnce` returning a future because it does the blocking
/// compile on a worker thread; it is called at most once per key even when
/// a hundred requests arrive together.
///
/// Generic over what is held because a PDF is not a `String` and the
/// single-flight promise above is not something to have two copies of. The
/// store has always been opaque bytes; [`Stored`] is the two ways this
/// crate reads them back, and a `String` entry that is somehow not UTF-8 is
/// a MISS rather than a lossy page.
///
/// # Errors
///
/// Whatever `render` fails with. A store-level failure is **not** an error:
/// a cache that cannot be read is a cache that misses, and a console that
/// refused to draw a README because redb was unhappy would be trading a
/// working page for a broken one.
pub async fn get_or_render<V, F, Fut, E>(&self, key: &str, render: F) -> Result<V, E>
where
V: Stored,
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<V, E>>,
{
if let Some(held) = self.peek(key) {
return Ok(held);
}
let Some(store) = self.store.as_ref() else {
self.compiles.fetch_add(1, Ordering::Relaxed);
return render().await;
};
let lock = self.lock_for(key);
let result = {
let _held = lock.lock().await;
// Somebody else may have compiled it while this task waited, which
// is the whole point of the lock.
match self.peek(key) {
Some(held) => Ok(held),
None => {
self.compiles.fetch_add(1, Ordering::Relaxed);
match render().await {
Ok(rendered) => {
if let Err(e) =
store.cache_put(key, rendered.as_stored(), MAX_ENTRIES, MAX_BYTES)
{
tracing::warn!(
error = %e,
"the rendered README could not be cached"
);
}
Ok(rendered)
}
Err(e) => Err(e),
}
}
}
};
self.release(key, &lock);
result
}
/// The held value, or nothing. Never an error — see above.
fn peek<V: Stored>(&self, key: &str) -> Option<V> {
let bytes = self.store.as_ref()?.cache_get(key).ok().flatten()?;
V::from_stored(bytes)
}
/// The lock for one key, created on demand.
fn lock_for(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
self.inflight
.lock()
.expect("the in-flight map")
.entry(key.to_owned())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
/// Drop a key's lock once nobody else is holding one.
///
/// Two references means the map's and this caller's; anything more means
/// another task is waiting on it and the entry has to stay, or the waiter
/// would be queued behind a mutex nobody can reach. Leaving one behind
/// costs a few dozen bytes and nothing else, so the check errs towards
/// keeping it.
fn release(&self, key: &str, lock: &Arc<tokio::sync::Mutex<()>>) {
let mut inflight = self.inflight.lock().expect("the in-flight map");
if Arc::strong_count(lock) <= 2 {
inflight.remove(key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A renderer that must never run. A named async fn rather than an inline
/// block so the error type is stated once.
async fn never() -> Result<String, ()> {
panic!("this call must not reach the renderer")
}
/// The same, for the grade that is bytes.
async fn never_bytes() -> Result<Vec<u8>, ()> {
panic!("this call must not reach the renderer")
}
fn store() -> (StateStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("a temp dir");
let store = StateStore::open(dir.path().join("state.redb")).expect("a store");
(store, dir)
}
#[test]
fn a_key_cannot_be_spelled_two_ways() {
// `ns/name` and `ns`,`name` must not collide, and neither must a path
// that contains the separator's neighbours.
assert_ne!(
Cache::key("a/b", "c", "oid", "README.md", crate::readme::Shape::Html),
Cache::key("a", "b/c", "oid", "README.md", crate::readme::Shape::Html)
);
}
/// ★ **The PDF of a document is a different entry from its typeset page**,
/// and the bytes come back as bytes.
///
/// Red-first: with the format left out of the key, the first reader to ask
/// for a PDF of a README somebody had already opened was handed the cached
/// SVG under `application/pdf` — a viewer's error message, from a cache hit,
/// with nothing in the log.
#[tokio::test]
async fn a_pdf_is_its_own_entry_and_keeps_bytes_that_are_not_text() {
let (store, _dir) = store();
let cache = Cache::new(Some(store));
let page = Cache::key("ns", "n", "oid", "a.md", crate::readme::Shape::Svg);
let pdf = Cache::pdf_key("ns", "n", "oid", "a.md");
assert_ne!(page, pdf);
cache
.get_or_render(&page, || async { Ok::<_, ()>("<svg/>".to_owned()) })
.await
.expect("the page");
// A real PDF header: bytes that are not UTF-8 at all.
let bytes = b"%PDF-1.7\n\xff\xfe\x00binary\n%%EOF\n".to_vec();
let held = cache
.get_or_render(&pdf, || async { Ok::<_, ()>(bytes.clone()) })
.await
.expect("the pdf");
assert_eq!(held, bytes);
assert_eq!(cache.compiles(), 2);
// Warm, and still byte for byte.
assert_eq!(
cache
.get_or_render(&pdf, never_bytes)
.await
.expect("a warm read"),
bytes
);
assert_eq!(cache.compiles(), 2);
}
#[tokio::test]
async fn a_second_call_for_the_same_key_does_not_compile_again() {
let (store, _dir) = store();
let cache = Cache::new(Some(store));
let key = Cache::key("ns", "name", "oid", "README.md", crate::readme::Shape::Html);
let first = cache
.get_or_render(&key, || async { Ok::<_, ()>("<svg/>".to_owned()) })
.await
.expect("the first render");
let second = cache
.get_or_render(&key, never)
.await
.expect("the second read");
assert_eq!(first, second);
assert_eq!(cache.compiles(), 1, "exactly one compile for two reads");
}
#[tokio::test]
async fn a_different_oid_is_a_different_answer() {
let (store, _dir) = store();
let cache = Cache::new(Some(store));
let old = cache
.get_or_render(
&Cache::key("ns", "n", "oid1", "README.md", crate::readme::Shape::Html),
|| async { Ok::<_, ()>("old".to_owned()) },
)
.await
.expect("render");
let new = cache
.get_or_render(
&Cache::key("ns", "n", "oid2", "README.md", crate::readme::Shape::Html),
|| async { Ok::<_, ()>("new".to_owned()) },
)
.await
.expect("render");
assert_eq!((old.as_str(), new.as_str()), ("old", "new"));
assert_eq!(cache.compiles(), 2);
}
/// **Ten tabs, one compile.** The lock is per key, so the other nine wait
/// and then read what the first one wrote.
#[tokio::test]
async fn concurrent_readers_of_one_key_compile_once() {
let (store, _dir) = store();
let cache = Arc::new(Cache::new(Some(store)));
let key = Cache::key("ns", "n", "oid", "README.md", crate::readme::Shape::Html);
let mut tasks = Vec::new();
for _ in 0..10 {
let cache = cache.clone();
let key = key.clone();
tasks.push(tokio::spawn(async move {
cache
.get_or_render(&key, || async {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
Ok::<_, ()>("<svg/>".to_owned())
})
.await
}));
}
for task in tasks {
assert_eq!(task.await.expect("the task").expect("a render"), "<svg/>");
}
assert_eq!(cache.compiles(), 1, "ten tabs must cost one compile");
}
#[tokio::test]
async fn without_a_store_every_call_renders_and_nothing_breaks() {
let cache = Cache::default();
let key = Cache::key("ns", "n", "oid", "README.md", crate::readme::Shape::Html);
for _ in 0..3 {
assert_eq!(
cache
.get_or_render(&key, || async { Ok::<_, ()>("x".to_owned()) })
.await
.expect("a render"),
"x"
);
}
assert_eq!(cache.compiles(), 3);
}
#[tokio::test]
async fn the_store_evicts_rather_than_growing_without_a_bound() {
let (store, _dir) = store();
let cache = Cache::new(Some(store));
for i in 0..(MAX_ENTRIES + 8) {
let key = Cache::key(
"ns",
"n",
&i.to_string(),
"README.md",
crate::readme::Shape::Html,
);
cache
.get_or_render(&key, || async { Ok::<_, ()>("x".repeat(64)) })
.await
.expect("a render");
}
let stats = cache.stats();
assert!(
stats.entries <= MAX_ENTRIES,
"the cache held {} entries, over the {MAX_ENTRIES} cap",
stats.entries
);
// The oldest key is gone and the newest is there.
assert!(cache
.peek::<String>(&Cache::key(
"ns",
"n",
"0",
"README.md",
crate::readme::Shape::Html
))
.is_none());
assert!(cache
.peek::<String>(&Cache::key(
"ns",
"n",
&(MAX_ENTRIES + 7).to_string(),
"README.md",
crate::readme::Shape::Html,
))
.is_some());
}
}
#[cfg(test)]
mod renderer_key_tests {
use super::*;
/// The key names the renderer: two renderers never share an entry.
#[test]
fn the_key_carries_the_renderer_version() {
let key = Cache::key("ns", "n", "oid", "README.md", crate::readme::Shape::Svg);
assert!(key.contains("docbook-"), "{key}");
assert!(key.contains(env!("DOCBOOK_VERSION")), "{key}");
assert_ne!(env!("DOCBOOK_VERSION"), "unlocked", "the lock names nornir-docbook");
}
}