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}