Skip to main content

concinnity_cook/cache/
mod.rs

1//! The build cache: what a cook produced for its own later runs, all of it in
2//! the one segment a host anchors.
3//!
4//! Some assets are expensive to compile -- the EnvironmentMap IBL convolution
5//! alone is hundreds of millions of float ops per build -- and a scene import
6//! re-parses a source file that may run to gigabytes. Both are deterministic
7//! functions of a small set of inputs, so the inputs are hashed into a key
8//! (`key`) and the output is stored under it. A later build that produces the
9//! same key reuses what is stored instead of doing the work again. The baked
10//! asset previews ([`thumbnails`]) ride the same segment on the same terms:
11//! cook renders them, so cook stores them, though the editor is what reads
12//! them back.
13//!
14//! One file per writer role is the rule the layout is built on. A build writes
15//! this segment and nothing else, the running application writes its own, so a
16//! cook against a live editor never touches the file that editor is writing.
17//! Two builds may still share this one, and the segment survives that: an index
18//! reads from the file it indexed, so the loser of a race loses entries rather
19//! than publishing the wrong bytes under the right key.
20//!
21//! The file is touched at the two moments the design allows and no others: the
22//! index is read when the first lookup needs it, and the segment is replaced by
23//! `flush` when the work producing it finishes. In between, a hit seeks to
24//! the one entry it wants and a store lands in memory, so a compile that stores
25//! for every asset costs one write rather than one per asset. That is also what
26//! makes the concurrent-store race structurally impossible: nothing writes the
27//! file while the compile is running.
28//!
29//! What produced an entry is not part of its key. The identity of the cook
30//! binary rides the segment header instead (`identity`), so a segment an
31//! older binary wrote is dropped whole rather than replayed against code that
32//! moved.
33//!
34//! Every operation is best-effort: a miss, an unreadable segment, or a failed
35//! write all leave the caller to compile normally, so the cache can never break
36//! or corrupt a build. Deleting `cache/` at any point costs recomputation and
37//! nothing else.
38//!
39//! Which file the segment is is not this module's business: a host [anchors]
40//! the one it resolved from its own state tree. Until one does, every operation
41//! here is a miss and every asset compiles.
42//!
43//! [anchors]: anchor
44
45mod identity;
46mod key;
47mod segment;
48pub mod thumbnails;
49
50use std::collections::HashMap;
51use std::path::{Path, PathBuf};
52use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
53
54pub(crate) use concinnity_core::blob::CacheEntryKind;
55pub(crate) use key::{bake_key, expand_key, payload_key};
56
57use segment::Index;
58
59/// Read the entry `kind` stored under `key`, if the segment holds one.
60pub(crate) fn load(kind: CacheEntryKind, key: &str) -> Option<Vec<u8>> {
61    // Disabled under `cargo test` so the suite neither creates stray segments
62    // nor lets a stale entry mask a change to a compile path. What a hit has to
63    // reproduce is covered out of process instead, by driving the binary twice
64    // over one world.
65    if cfg!(test) {
66        return None;
67    }
68    let index = {
69        let mut held = lock();
70        let loaded = open(&mut held)?;
71        // A key stored earlier in this same build is served from memory: the
72        // compile is parallel and two assets with identical inputs share a key,
73        // so the second is a hit against bytes the file does not hold yet.
74        if let Some(bytes) = loaded.stored.get(&(kind, key.to_owned())) {
75            return Some(bytes.to_vec());
76        }
77        Arc::clone(&loaded.index)
78    };
79    index.get(kind, key)
80}
81
82/// Whether the segment already holds the entry `kind` stored under `key`,
83/// without reading its bytes. What a producer asks before doing the work an
84/// entry would save.
85pub(crate) fn contains(kind: CacheEntryKind, key: &str) -> bool {
86    if cfg!(test) {
87        return false;
88    }
89    let mut held = lock();
90    let Some(loaded) = open(&mut held) else {
91        return false;
92    };
93    loaded.stored.contains_key(&(kind, key.to_owned())) || loaded.index.contains(kind, key)
94}
95
96/// Hold `bytes` as `key`'s entry until the next [`flush`].
97pub(crate) fn store(kind: CacheEntryKind, key: &str, bytes: &[u8]) {
98    if cfg!(test) {
99        return;
100    }
101    let mut held = lock();
102    let Some(loaded) = open(&mut held) else {
103        return;
104    };
105    loaded
106        .stored
107        .insert((kind, key.to_owned()), bytes.to_vec().into());
108}
109
110/// Write the segment, carrying through what it already held, and report whether
111/// the file was written. Called when the work producing entries finishes -- the
112/// end of an expansion, the end of a compile -- never per entry.
113///
114/// A build that stored nothing writes nothing: the file it would produce is the
115/// one already there.
116pub(crate) fn flush() -> bool {
117    let mut held = lock();
118    let Some(loaded) = held.take() else {
119        return false;
120    };
121    if loaded.stored.is_empty() {
122        *held = Some(loaded);
123        return false;
124    }
125    write(&loaded)
126}
127
128// The segment this process read, the file it came from, and what this build has
129// produced for it.
130struct Loaded {
131    path: PathBuf,
132    token: u32,
133    index: Arc<Index>,
134    stored: HashMap<(CacheEntryKind, String), Arc<[u8]>>,
135}
136
137// Replace `loaded`'s file with what it now holds: what the index still
138// addresses, plus the entries this build produced.
139fn write(loaded: &Loaded) -> bool {
140    let mut stored: Vec<(CacheEntryKind, &str, &[u8])> = loaded
141        .stored
142        .iter()
143        .map(|((kind, key), bytes)| (*kind, key.as_str(), &**bytes))
144        .collect();
145    // Hash iteration order must not reach the file: two builds that stored the
146    // same entries write the same bytes.
147    stored.sort_by(|a, b| (a.1, a.0 as u8).cmp(&(b.1, b.0 as u8)));
148    segment::write(&loaded.path, &loaded.index, &stored, loaded.token)
149}
150
151// The segment file this process was told to build against. A poisoned lock is
152// taken anyway: the anchor is one path, so a panic mid-write leaves it either
153// the old value or the new one and never something in between.
154fn anchor_lock() -> MutexGuard<'static, Option<PathBuf>> {
155    static ANCHOR: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
156    ANCHOR
157        .get_or_init(|| Mutex::new(None))
158        .lock()
159        .unwrap_or_else(|e| e.into_inner())
160}
161
162/// Point the build cache at `segment` for the rest of the process, or until
163/// another anchor replaces it. A host resolves the path from its own state tree
164/// (`StateTree::build_cache_path`); nothing here knows what one looks like.
165pub fn anchor(segment: &Path) {
166    *anchor_lock() = Some(segment.to_path_buf());
167}
168
169/// Drop the anchor, leaving the build with no segment to warm from.
170pub fn clear_anchor() {
171    *anchor_lock() = None;
172}
173
174// The anchored segment file, if a host named one.
175pub(crate) fn anchored_path() -> Option<PathBuf> {
176    anchor_lock().clone()
177}
178
179// The anchored segment, reading its index on the first call. `None` when
180// nothing anchored one, or when the running binary cannot be identified; both
181// turn every operation above into a miss, so payloads are compiled fresh rather
182// than warmed from a segment nothing can invalidate.
183fn open<'a>(held: &'a mut MutexGuard<'static, Option<Loaded>>) -> Option<&'a mut Loaded> {
184    let path = anchored_path()?;
185    let token = identity::token()?;
186    if held.as_ref().is_some_and(|loaded| loaded.path != path) {
187        // A host re-anchored the cache after this segment was read. What this
188        // build produced belongs to the old file, so write it back there before
189        // reading the new one.
190        if let Some(previous) = held.take() {
191            write(&previous);
192        }
193    }
194    Some(held.get_or_insert_with(|| Loaded {
195        index: Arc::new(Index::read(&path, token)),
196        path,
197        token,
198        stored: HashMap::new(),
199    }))
200}
201
202// Serializes this process's access to the one segment it holds. Held only
203// across the index lookup and the store, never across the file read a hit does.
204fn lock() -> MutexGuard<'static, Option<Loaded>> {
205    static LOADED: Mutex<Option<Loaded>> = Mutex::new(None);
206    LOADED.lock().unwrap_or_else(|e| e.into_inner())
207}