Skip to main content

concinnity_host/store/cache/
mod.rs

1//! The runtime cache segment: regenerable artifacts the running application
2//! writes for its own later launches, all of them in `cache/0`.
3//!
4//! One file per writer role is the rule the layout is built on. The application
5//! writes this segment and nothing else, a build writes its own, so a build
6//! running against a live editor never touches the file the editor is writing.
7//! Within the segment an index keyed by producer and key separates the entries,
8//! which is what lets two adapters of one producer share a file, and the shader
9//! cache share it with the driver pipeline blobs.
10//!
11//! Which files those are is not this module's business: a host [anchors] the
12//! two it wants, having resolved them from its own state tree. Until one does,
13//! every operation here is a miss.
14//!
15//! A lookup has a second tier behind it: the segment a bundle ships, which `cn
16//! export` warms so a player's first launch does not pay the compile. A host
17//! resolves it against the content root rather than the writable one, so the
18//! two are one file except on an install that cannot write beside its data --
19//! which is the whole reason the tier exists.
20//!
21//! [anchors]: anchor
22//!
23//! The file is touched twice: once when the first lookup reads it, and once per
24//! [`flush`]. The bundled tier is read once and never written. Everything
25//! between is memory, so a producer that stores in a loop costs one write
26//! rather than one per entry. A crash before a flush costs the recompute of
27//! whatever had not been written, which is the same price deleting `cache/`
28//! already carries.
29//!
30//! Every operation is best-effort: a miss, an unreadable segment, or a failed
31//! write leaves the caller to produce the artifact the slow way. Two
32//! applications running against one checkout do share this file, and the later
33//! flush wins; what the loser had cached is recomputed on its next launch.
34//!
35//! The container format is `concinnity_core::blob`, which is I/O-free; the file
36//! reads and writes live in `segment`.
37
38mod segment;
39
40use std::path::PathBuf;
41use std::sync::{Mutex, MutexGuard, OnceLock};
42
43pub use concinnity_core::blob::CacheEntryKind;
44pub use segment::Segment;
45
46/// How much payload the segment may hold. Every shader edit orphans the
47/// artifact it replaces and neither driver pipeline blob evicts internally, so
48/// a long-lived checkout would otherwise accumulate forever. Generous next to
49/// the ~100 live entries one build needs; [`flush`] evicts down to it.
50pub const CACHE_BUDGET_BYTES: u64 = 64 * 1024 * 1024;
51
52/// The bytes `kind` stored under `key`, or `None` when there is no such entry
53/// (or nothing anchored a segment to look in).
54pub fn load(kind: CacheEntryKind, key: &str) -> Option<Vec<u8>> {
55    with(|segment| segment.get(kind, key).map(<[u8]>::to_vec)).flatten()
56}
57
58/// The same lookup against the read-only segment a bundle ships, for a caller
59/// [`load`] missed. Read once like the writable one, so a run that consults it
60/// fifty times reads the file once.
61///
62/// Reports a miss when the bundle is one the application can write to: both
63/// roles then name one file, the writable tier already holds its entries, and
64/// answering from a second copy would only risk [`flush`] writing back a view
65/// that was never the shipped one.
66pub fn load_bundled(kind: CacheEntryKind, key: &str) -> Option<Vec<u8>> {
67    bundled(|segment| segment.get(kind, key).map(<[u8]>::to_vec)).flatten()
68}
69
70/// Hold `bytes` under `key` until the next [`flush`], reporting whether the
71/// segment took them: an entry already holding at least as many bytes is left
72/// alone, so a driver blob whose serialization only reshuffles does not make
73/// the flush rewrite the file.
74pub fn store(kind: CacheEntryKind, key: &str, bytes: &[u8]) -> bool {
75    with(|segment| segment.put(kind, key, bytes)).unwrap_or(false)
76}
77
78/// Drop `key`'s entry, for a caller whose artifact turned out unusable.
79pub fn delete(kind: CacheEntryKind, key: &str) {
80    with(|segment| segment.remove(kind, key));
81}
82
83/// Adopt `id` as the host shader toolchain the segment's entries were produced
84/// by, discarding every entry when the segment names another one. Reports
85/// whether it discarded, which the caller logs.
86///
87/// An artifact is a function of its source, not of what compiled it, so an
88/// external compiler upgrade (or one shadowed by another install earlier on
89/// PATH) moves no key: without this its predecessor's output would be replayed
90/// forever.
91pub fn verify_toolchain(id: &str) -> bool {
92    with(|segment| segment.adopt_toolchain(id)).unwrap_or(false)
93}
94
95/// Write the segment to disk, if anything changed it since it was read, and
96/// report whether the file was written. Called when the work producing entries
97/// finishes -- the end of a renderer init, a clean shutdown -- never per entry.
98pub fn flush() -> bool {
99    match lock().as_mut() {
100        Some(loaded) => loaded.segment.write_to(&loaded.path, CACHE_BUDGET_BYTES),
101        None => false,
102    }
103}
104
105/// The two segment files a run consults, named by whatever anchored them.
106///
107/// A host builds this from its [`StateTree`](super::paths::StateTree) --
108/// `runtime_cache_path` and `bundled_runtime_cache_path` -- so nothing here
109/// knows what a cache path looks like. Process state because artifacts are
110/// produced deep inside a renderer init, with no caller to carry the paths
111/// down from.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct CacheAnchor {
114    writable: PathBuf,
115    bundled: Option<PathBuf>,
116}
117
118impl CacheAnchor {
119    /// An anchor naming the file this run writes.
120    pub fn new<P: Into<PathBuf>>(writable: P) -> Self {
121        Self {
122            writable: writable.into(),
123            bundled: None,
124        }
125    }
126
127    /// Also read the read-only segment a bundle ships, for the tier behind a
128    /// miss. Ignored when it names the very file this run writes.
129    #[must_use]
130    pub fn with_bundled<P: Into<PathBuf>>(mut self, bundled: P) -> Self {
131        self.bundled = Some(bundled.into());
132        self
133    }
134}
135
136// The segment files this process was told about.
137fn anchored() -> &'static Mutex<Option<CacheAnchor>> {
138    static ANCHOR: OnceLock<Mutex<Option<CacheAnchor>>> = OnceLock::new();
139    ANCHOR.get_or_init(|| Mutex::new(None))
140}
141
142/// Point the runtime cache at the files `anchor` names for the rest of the
143/// process, or until another anchor replaces it. Until a host calls this every
144/// operation above is a miss: artifacts are produced fresh rather than warmed
145/// from disk.
146pub fn anchor(anchor: CacheAnchor) {
147    *anchored().lock().unwrap() = Some(anchor);
148}
149
150/// Drop the anchor, leaving the process with no cache to warm from.
151pub fn clear_anchor() {
152    *anchored().lock().unwrap() = None;
153}
154
155// The file this run writes, when one is anchored.
156fn writable_path() -> Option<PathBuf> {
157    anchored()
158        .lock()
159        .unwrap()
160        .as_ref()
161        .map(|a| a.writable.clone())
162}
163
164// The segment this process read, and the file it came from.
165struct Loaded {
166    path: PathBuf,
167    segment: Segment,
168}
169
170// Run `f` against the loaded segment, reading the file on the first call.
171// `None` when nothing anchored the cache, which turns every operation above
172// into a miss: artifacts are produced fresh rather than warmed from disk.
173fn with<R>(f: impl FnOnce(&mut Segment) -> R) -> Option<R> {
174    let path = writable_path()?;
175    let mut held = lock();
176    if held.as_ref().is_some_and(|loaded| loaded.path != path)
177        && let Some(mut previous) = held.take()
178    {
179        // A host moved the writable state root after this segment was read (a
180        // world's own `home` overriding the launcher's). What it holds belongs
181        // to the old root, so write it back there before reading the new one.
182        previous
183            .segment
184            .write_to(&previous.path, CACHE_BUDGET_BYTES);
185    }
186    let loaded = held.get_or_insert_with(|| Loaded {
187        segment: Segment::read_from(&path),
188        path,
189    });
190    Some(f(&mut loaded.segment))
191}
192
193// Serializes this process's access to the one segment it holds. Lookups take it
194// too: a lookup marks the entry it found as one this run needs, so eviction
195// spares it.
196fn lock() -> MutexGuard<'static, Option<Loaded>> {
197    static LOADED: Mutex<Option<Loaded>> = Mutex::new(None);
198    LOADED.lock().unwrap_or_else(|e| e.into_inner())
199}
200
201// Run `f` against the bundled segment, reading the file on the first call.
202// Nothing writes this tier, so a root move just drops what was read rather
203// than writing it back.
204fn bundled<R>(f: impl FnOnce(&mut Segment) -> R) -> Option<R> {
205    let held = anchored().lock().unwrap().clone()?;
206    let path = shipped_path(held.bundled?, Some(held.writable))?;
207    static LOADED: Mutex<Option<Loaded>> = Mutex::new(None);
208    let mut held = LOADED.lock().unwrap_or_else(|e| e.into_inner());
209    if held.as_ref().is_some_and(|loaded| loaded.path != path) {
210        *held = None;
211    }
212    let loaded = held.get_or_insert_with(|| Loaded {
213        segment: Segment::read_from(&path),
214        path,
215    });
216    Some(f(&mut loaded.segment))
217}
218
219// `bundled` unless the application writes that same file, in which case the
220// writable tier is already serving its entries and reading a second copy would
221// only put a stale view in front of what `flush` writes back.
222fn shipped_path(shipped: PathBuf, writable: Option<PathBuf>) -> Option<PathBuf> {
223    (writable.as_deref() != Some(shipped.as_path())).then_some(shipped)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use std::path::Path;
230
231    // The portable-folder case: one file in both roles, so only the writable
232    // tier reads it and the shipped entries ride its flush back to disk.
233    #[test]
234    fn a_writable_bundle_has_no_second_tier() {
235        let one = PathBuf::from("/bundle/cache/0");
236        assert_eq!(shipped_path(one.clone(), Some(one.clone())), None);
237    }
238
239    // A read-only install: the two roots diverge, so the shipped segment is a
240    // tier of its own.
241    #[test]
242    fn a_read_only_install_reads_the_shipped_segment() {
243        let shipped = PathBuf::from("/opt/app/cache/0");
244        let writable = PathBuf::from("/home/u/.local/share/app/cache/0");
245        assert_eq!(
246            shipped_path(shipped.clone(), Some(writable)),
247            Some(shipped.clone())
248        );
249        // No writable root at all leaves the shipped one readable.
250        assert_eq!(shipped_path(shipped.clone(), None), Some(shipped));
251    }
252
253    // The anchor is two named files and nothing more: it carries no layout, so
254    // a host is free to point the two tiers at unrelated places.
255    #[test]
256    fn an_anchor_names_the_files_it_was_given() {
257        let plain = CacheAnchor::new("/run/segment");
258        assert_eq!(plain.writable, Path::new("/run/segment"));
259        assert_eq!(plain.bundled, None);
260
261        let tiered = CacheAnchor::new("/run/segment").with_bundled("/opt/shipped");
262        assert_eq!(tiered.writable, Path::new("/run/segment"));
263        assert_eq!(tiered.bundled.as_deref(), Some(Path::new("/opt/shipped")));
264    }
265
266    // The tree is what a host builds an anchor from, and the portable layout
267    // (one folder) is the case where both tiers name one file.
268    #[test]
269    fn a_tree_builds_the_anchor_its_layout_implies() {
270        let tree = super::super::paths::StateTree::at("/bundle");
271        let anchor = CacheAnchor::new(tree.runtime_cache_path())
272            .with_bundled(tree.bundled_runtime_cache_path());
273        assert_eq!(
274            shipped_path(anchor.bundled.clone().unwrap(), Some(anchor.writable)),
275            None,
276            "one file in both roles has no second tier"
277        );
278    }
279}