fallow_graph/cache/store.rs
1//! Persisted graph-cache store: coarse all-or-nothing load / save of a
2//! previously-built [`ModuleGraph`].
3//!
4//! Mirrors the extraction cache store (`fallow_extract::cache::store`): the
5//! payload is postcard-encoded, written atomically via a sibling `.tmp` file
6//! plus best-effort fsync and rename, and a `.gitignore` is written alongside
7//! so `.fallow/` is never committed. Every IO error is swallowed (the graph
8//! cache is best-effort and must never fail analysis); a corrupt or
9//! version-mismatched file misses and the graph is rebuilt fresh, but the
10//! loader names WHY it missed through [`CacheRejection`] so the run can report
11//! a refusal instead of leaving it indistinguishable from a first run.
12
13use std::path::Path;
14
15use fallow_types::cache_rejection::CacheRejection;
16use serde::{Deserialize, Serialize};
17
18use super::{CachedResolvedProject, GRAPH_CACHE_VERSION, GraphCacheManifest};
19use crate::graph::ModuleGraph;
20
21/// Filename of the persisted graph cache inside the cache directory.
22pub const GRAPH_CACHE_FILE: &str = "graph-cache.bin";
23
24/// On-disk graph cache entry: a manifest plus the graph it validates.
25#[derive(Serialize, Deserialize)]
26pub struct GraphCacheStore {
27 /// Schema version. Checked on load; a mismatch misses so a stale file from
28 /// an older binary is never deserialized into the wrong shape.
29 pub version: u32,
30 /// Inputs that must match the current run for the graph to be trusted.
31 pub manifest: GraphCacheManifest,
32 /// The previously-built graph. Its `namespace_imported` bitset is
33 /// `#[serde(skip)]`, so the loader reconstructs it from the edge set.
34 pub graph: ModuleGraph,
35 /// Resolver output aligned with the cached graph. Exact manifest hits use
36 /// it alongside the graph; stable-key resolver hits remap it and rebuild
37 /// the graph with current `FileId`s.
38 pub resolved_project: CachedResolvedProject,
39}
40
41impl GraphCacheStore {
42 /// Load the persisted graph cache from `cache_dir`.
43 ///
44 /// # Errors
45 ///
46 /// Returns the [`CacheRejection`] that decided against reuse: the file is
47 /// missing, undecodable, or written for a different
48 /// `GRAPH_CACHE_VERSION`. The caller compares the loaded manifest against
49 /// the current inputs before trusting the graph or resolver payload, and
50 /// reports its own rejection reason for that comparison.
51 ///
52 /// The version is read from the file header BEFORE the payload is
53 /// decoded. A format bump changes the encoded shape, so a blob
54 /// from the previous release fails to decode and a version comparison made
55 /// afterwards is unreachable on the one event that triggers it most: an
56 /// upgrade. `fallow doctor` reports this reason verbatim, so a routine
57 /// version bump must not read as corruption, and a file that is not a
58 /// fallow cache at all must not read as a version bump.
59 ///
60 /// A file that existed and was then refused logs at warn: the run paid the
61 /// read and the decode and reused nothing. A missing file stays quiet.
62 pub fn load(cache_dir: &Path) -> Result<Self, CacheRejection> {
63 let cache_file = cache_dir.join(GRAPH_CACHE_FILE);
64 let data = std::fs::read(&cache_file).map_err(|error| {
65 if error.kind() == std::io::ErrorKind::NotFound {
66 return CacheRejection::Absent;
67 }
68 tracing::warn!("Cache file could not be read; check the path and permissions");
69 CacheRejection::Unreadable
70 })?;
71 let payload = read_header(&data)?;
72 let mut store: Self = match postcard::from_bytes(payload) {
73 Ok(store) => store,
74 Err(_) => {
75 tracing::warn!(
76 "Graph cache carries the current format version but its payload could not be \
77 decoded, rebuilding"
78 );
79 return Err(CacheRejection::Undecodable);
80 }
81 };
82 // The header already agreed with `GRAPH_CACHE_VERSION`, so this catches
83 // only a file whose header and payload disagree.
84 if store.version != GRAPH_CACHE_VERSION {
85 tracing::warn!(
86 cached_version = store.version,
87 expected_version = GRAPH_CACHE_VERSION,
88 "Graph cache header and payload declare different format versions, rebuilding"
89 );
90 return Err(CacheRejection::VersionMismatch);
91 }
92 // `namespace_imported` is `#[serde(skip)]`; rebuild it from the persisted
93 // edges so the loaded graph is byte-identical to a fresh build.
94 store.graph.reconstruct_namespace_imported();
95 Ok(store)
96 }
97
98 /// Persist this graph cache to `cache_dir`, best-effort.
99 ///
100 /// Creates the cache directory, writes a `.gitignore`, encodes the store
101 /// with postcard, and writes `graph-cache.bin` atomically. Every IO error
102 /// is logged at debug and swallowed; the graph cache must never fail the
103 /// surrounding analysis run.
104 pub fn save(&self, cache_dir: &Path) {
105 if let Err(error) = std::fs::create_dir_all(cache_dir) {
106 tracing::debug!("Failed to create graph cache dir: {error}");
107 return;
108 }
109 if let Err(error) = write_cache_gitignore(cache_dir) {
110 tracing::debug!("Failed to write graph cache .gitignore: {error}");
111 // Continue: a missing .gitignore does not invalidate the cache file.
112 }
113
114 let encoded = match postcard::to_allocvec(self) {
115 Ok(bytes) => bytes,
116 Err(error) => {
117 tracing::debug!("Failed to encode graph cache: {error}");
118 return;
119 }
120 };
121
122 let cache_file = cache_dir.join(GRAPH_CACHE_FILE);
123 if let Err(error) = atomic_write(&cache_file, &framed(self.version, &encoded)) {
124 tracing::debug!("Failed to write graph cache: {error}");
125 }
126 }
127}
128
129/// Marker written ahead of new graph-cache payload so the format version can
130/// be read without decoding the payload it describes.
131///
132/// Constant across format bumps: only the version field beside it moves. That
133/// lets future upgrades report an explicit version mismatch; older unframed
134/// caches still report an ambiguous decode failure.
135const GRAPH_CACHE_MAGIC: [u8; 4] = *b"FLWG";
136
137/// Bytes the framing adds ahead of the payload: the magic plus a little-endian
138/// `u32` format version.
139const GRAPH_CACHE_HEADER_LEN: usize = GRAPH_CACHE_MAGIC.len() + 4;
140
141/// Prepend the format header to an encoded payload.
142///
143/// The version comes from the store being written rather than from the
144/// constant, so the header always describes the payload behind it.
145fn framed(version: u32, payload: &[u8]) -> Vec<u8> {
146 let mut framed = Vec::with_capacity(GRAPH_CACHE_HEADER_LEN + payload.len());
147 framed.extend_from_slice(&GRAPH_CACHE_MAGIC);
148 framed.extend_from_slice(&version.to_le_bytes());
149 framed.extend_from_slice(payload);
150 framed
151}
152
153/// Split a cache file into its declared version and its payload, refusing
154/// anything this binary cannot read WITHOUT decoding it first.
155///
156/// A recognized header exposes a version mismatch without decoding. Releases
157/// before framing wrote raw payloads, so a missing header cannot distinguish
158/// an older cache from foreign or damaged data. `Undecodable` keeps that
159/// uncertainty explicit and the next successful run replaces the blob.
160fn read_header(data: &[u8]) -> Result<&[u8], CacheRejection> {
161 let Some((header, payload)) = data.split_at_checked(GRAPH_CACHE_HEADER_LEN) else {
162 tracing::warn!("Graph cache is too short to carry a format header, rebuilding");
163 return Err(CacheRejection::Undecodable);
164 };
165 let (declared_magic, declared_version) = header.split_at(GRAPH_CACHE_MAGIC.len());
166 if declared_magic != GRAPH_CACHE_MAGIC {
167 tracing::warn!("Graph cache does not carry fallow's cache framing, rebuilding");
168 return Err(CacheRejection::Undecodable);
169 }
170 // The slice is exactly four bytes; the fallback only has to be a version
171 // this binary never writes, so an impossible header is refused rather than
172 // trusted.
173 let declared = declared_version.try_into().map_or(0, u32::from_le_bytes);
174 if declared != GRAPH_CACHE_VERSION {
175 tracing::warn!(
176 cached_version = declared,
177 expected_version = GRAPH_CACHE_VERSION,
178 "Graph cache format upgraded, rebuilding (one-time cost after version bump)"
179 );
180 return Err(CacheRejection::VersionMismatch);
181 }
182 Ok(payload)
183}
184
185/// Write `.fallow/.gitignore` (`*\n`) so the cache directory is never committed.
186fn write_cache_gitignore(cache_dir: &Path) -> std::io::Result<()> {
187 std::fs::write(cache_dir.join(".gitignore"), "*\n")
188}
189
190/// Write `data` atomically via a sibling `.tmp` file, best-effort fsync, then
191/// rename. Copied from the extraction cache store so the two caches share the
192/// same crash-safe write semantics.
193fn atomic_write(cache_file: &Path, data: &[u8]) -> std::io::Result<()> {
194 let tmp_file = match cache_file.file_name() {
195 Some(name) => cache_file.with_file_name({
196 let mut s = name.to_os_string();
197 s.push(".tmp");
198 s
199 }),
200 None => {
201 return Err(std::io::Error::new(
202 std::io::ErrorKind::InvalidInput,
203 "graph cache file path has no filename component",
204 ));
205 }
206 };
207
208 {
209 use std::io::Write as _;
210 let mut f = std::fs::File::create(&tmp_file)?;
211 f.write_all(data)?;
212 let _ = f.sync_all();
213 }
214
215 std::fs::rename(&tmp_file, cache_file)
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 /// Unframed data may come from a release predating the header. Its origin
223 /// is unknown, so the error does not establish corruption.
224 #[test]
225 fn a_blob_without_fallows_framing_is_undecodable() {
226 assert_eq!(
227 read_header(b"written-by-an-older-build").err(),
228 Some(CacheRejection::Undecodable)
229 );
230 }
231
232 #[test]
233 fn a_blob_too_short_to_carry_a_header_is_undecodable() {
234 assert_eq!(
235 read_header(&[0_u8; 3]).err(),
236 Some(CacheRejection::Undecodable)
237 );
238 }
239
240 #[test]
241 fn a_header_declaring_another_version_is_refused_without_reading_the_payload() {
242 let blob = framed(GRAPH_CACHE_VERSION + 1, b"payload");
243
244 assert_eq!(
245 read_header(&blob).err(),
246 Some(CacheRejection::VersionMismatch)
247 );
248 }
249
250 #[test]
251 fn a_header_at_the_current_version_hands_back_the_payload_it_frames() {
252 let blob = framed(GRAPH_CACHE_VERSION, b"payload");
253
254 assert_eq!(read_header(&blob), Ok(b"payload".as_slice()));
255 }
256}