Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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");
    }
}