Skip to main content

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}