Skip to main content

gqls/load/
record_cache.rs

1//! On-disk cache of parsed schema records.
2//!
3//! Parsing dominates the fuzzy path on a large schema (~28ms of a ~45ms query
4//! at 48k records for SDL; more for introspection JSON), and the records
5//! depend only on the source bytes — SDL text or an introspection response —
6//! so cache them keyed by a hash of those bytes. Same idea and same house
7//! format rules as the vector cache: a small length-prefixed binary layout,
8//! dependency-free. A miss (schema edited, format bumped) simply re-parses
9//! and overwrites.
10
11use std::collections::hash_map::DefaultHasher;
12use std::hash::{Hash, Hasher};
13use std::io::Read;
14use std::path::PathBuf;
15
16use crate::model::{Kind, SchemaRecord};
17
18/// Bump when the encoding or `SchemaRecord` shape changes — old files then
19/// fail the magic check and re-parse.
20const MAGIC: u32 = 0x47_51_52_31; // "GQR1"
21
22/// Keep at most this many cache files (LRU by mtime, pruned on write).
23const MAX_FILES: usize = 32;
24
25/// Cached records for this source (SDL text or introspection JSON bytes), or
26/// `None` on any miss (absent, stale magic, corrupt).
27pub fn load(source: &[u8]) -> Option<Vec<SchemaRecord>> {
28    let p = path(source)?;
29    let mut buf = Vec::new();
30    std::fs::File::open(&p).ok()?.read_to_end(&mut buf).ok()?;
31    let records = decode(&buf)?;
32    crate::detail!("schema cache hit: {}", p.display());
33    touch(&p);
34    Some(records)
35}
36
37/// Write records for this source (best-effort — a cache write failure is
38/// never fatal), then prune the least-recently-used files.
39pub fn store(source: &[u8], records: &[SchemaRecord]) {
40    let Some(p) = path(source) else {
41        return;
42    };
43    let Some(parent) = p.parent() else {
44        return;
45    };
46    if std::fs::create_dir_all(parent).is_err() {
47        return;
48    }
49    let _ = std::fs::write(&p, encode(records));
50    prune(MAX_FILES);
51}
52
53/// Delete every record cache file; returns how many were removed.
54pub fn clear() -> usize {
55    let Some(dir) = crate::paths::cache_dir() else {
56        return 0;
57    };
58    let mut removed = 0;
59    if let Ok(rd) = std::fs::read_dir(&dir) {
60        for e in rd.flatten() {
61            if e.path().extension().is_some_and(|x| x == "rcds")
62                && std::fs::remove_file(e.path()).is_ok()
63            {
64                removed += 1;
65            }
66        }
67    }
68    removed
69}
70
71fn path(source: &[u8]) -> Option<PathBuf> {
72    let mut h = DefaultHasher::new();
73    MAGIC.hash(&mut h);
74    source.hash(&mut h);
75    Some(crate::paths::cache_dir()?.join(format!("{:016x}.rcds", h.finish())))
76}
77
78/// Refresh mtime on a hit so an actively-used schema survives LRU pruning.
79fn touch(p: &std::path::Path) {
80    if let Ok(f) = std::fs::File::open(p) {
81        let _ = f.set_modified(std::time::SystemTime::now());
82    }
83}
84
85/// Delete all but the `keep` most-recently-used record cache files.
86fn prune(keep: usize) {
87    let Some(dir) = crate::paths::cache_dir() else {
88        return;
89    };
90    let mut files: Vec<(std::time::SystemTime, PathBuf)> = match std::fs::read_dir(&dir) {
91        Ok(rd) => rd
92            .flatten()
93            .filter(|e| e.path().extension().is_some_and(|x| x == "rcds"))
94            .filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
95            .collect(),
96        Err(_) => return,
97    };
98    if files.len() <= keep {
99        return;
100    }
101    files.sort_by_key(|f| std::cmp::Reverse(f.0)); // newest first
102    for (_, p) in files.into_iter().skip(keep) {
103        let _ = std::fs::remove_file(p);
104    }
105}
106
107// --- encoding: MAGIC u32 | count u64 | records, all little-endian ---
108// Strings are u32-length-prefixed UTF-8; Option is a 0/1 byte then the value;
109// Vec is a u32 count then values; Kind round-trips via its stable string form.
110
111fn encode(records: &[SchemaRecord]) -> Vec<u8> {
112    let mut buf = Vec::with_capacity(64 * records.len() + 16);
113    buf.extend_from_slice(&MAGIC.to_le_bytes());
114    buf.extend_from_slice(&(records.len() as u64).to_le_bytes());
115    for r in records {
116        put_str(&mut buf, &r.path);
117        put_str(&mut buf, &r.name);
118        put_str(&mut buf, r.kind.as_str());
119        put_opt(&mut buf, r.parent.as_deref());
120        put_opt(&mut buf, r.type_ref.as_deref());
121        put_vec(&mut buf, &r.args);
122        put_opt(&mut buf, r.description.as_deref());
123        put_opt(&mut buf, r.deprecated.as_deref());
124        put_vec(&mut buf, &r.directives);
125    }
126    buf
127}
128
129fn decode(buf: &[u8]) -> Option<Vec<SchemaRecord>> {
130    let mut rd = Reader { buf, off: 0 };
131    if rd.u32()? != MAGIC {
132        return None;
133    }
134    let n = rd.u64()? as usize;
135    // Guard the capacity against a corrupt count: every record costs ≥ 9 bytes.
136    if n > buf.len() / 9 {
137        return None;
138    }
139    let mut out = Vec::with_capacity(n);
140    for _ in 0..n {
141        out.push(SchemaRecord {
142            path: rd.str()?,
143            name: rd.str()?,
144            kind: rd.str()?.parse::<Kind>().ok()?,
145            parent: rd.opt()?,
146            type_ref: rd.opt()?,
147            args: rd.vec()?,
148            description: rd.opt()?,
149            deprecated: rd.opt()?,
150            directives: rd.vec()?,
151        });
152    }
153    (rd.off == buf.len()).then_some(out)
154}
155
156fn put_str(buf: &mut Vec<u8>, s: &str) {
157    buf.extend_from_slice(&(s.len() as u32).to_le_bytes());
158    buf.extend_from_slice(s.as_bytes());
159}
160
161fn put_opt(buf: &mut Vec<u8>, s: Option<&str>) {
162    match s {
163        Some(s) => {
164            buf.push(1);
165            put_str(buf, s);
166        }
167        None => buf.push(0),
168    }
169}
170
171fn put_vec(buf: &mut Vec<u8>, v: &[String]) {
172    buf.extend_from_slice(&(v.len() as u32).to_le_bytes());
173    for s in v {
174        put_str(buf, s);
175    }
176}
177
178struct Reader<'a> {
179    buf: &'a [u8],
180    off: usize,
181}
182
183impl Reader<'_> {
184    fn take(&mut self, n: usize) -> Option<&[u8]> {
185        let end = self.off.checked_add(n)?;
186        let s = self.buf.get(self.off..end)?;
187        self.off = end;
188        Some(s)
189    }
190
191    fn u32(&mut self) -> Option<u32> {
192        Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
193    }
194
195    fn u64(&mut self) -> Option<u64> {
196        Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
197    }
198
199    fn str(&mut self) -> Option<String> {
200        let n = self.u32()? as usize;
201        Some(std::str::from_utf8(self.take(n)?).ok()?.to_string())
202    }
203
204    fn opt(&mut self) -> Option<Option<String>> {
205        match self.take(1)?[0] {
206            0 => Some(None),
207            1 => Some(Some(self.str()?)),
208            _ => None,
209        }
210    }
211
212    fn vec(&mut self) -> Option<Vec<String>> {
213        let n = self.u32()? as usize;
214        // ≥ 4 bytes per element; reject a corrupt count before allocating.
215        if n > self.buf.len() / 4 {
216            return None;
217        }
218        let mut out = Vec::with_capacity(n);
219        for _ in 0..n {
220            out.push(self.str()?);
221        }
222        Some(out)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn rec(name: &str) -> SchemaRecord {
231        SchemaRecord {
232            path: format!("User.{name}"),
233            name: name.into(),
234            kind: Kind::Field,
235            parent: Some("User".into()),
236            type_ref: Some("String!".into()),
237            args: vec!["first: Int".into(), "after: String".into()],
238            description: None,
239            deprecated: Some("use other".into()),
240            directives: vec![],
241        }
242    }
243
244    #[test]
245    fn round_trips_records() {
246        let records = vec![rec("email"), rec("name")];
247        let decoded = decode(&encode(&records)).unwrap();
248        assert_eq!(decoded.len(), 2);
249        let (a, b) = (&records[0], &decoded[0]);
250        assert_eq!(a.path, b.path);
251        assert_eq!(a.kind, b.kind);
252        assert_eq!(a.parent, b.parent);
253        assert_eq!(a.type_ref, b.type_ref);
254        assert_eq!(a.args, b.args);
255        assert_eq!(a.description, b.description);
256        assert_eq!(a.deprecated, b.deprecated);
257        assert_eq!(a.directives, b.directives);
258    }
259
260    #[test]
261    fn rejects_corrupt_data() {
262        let mut buf = encode(&[rec("email")]);
263        assert!(decode(&buf[..buf.len() - 1]).is_none()); // truncated
264        buf[0] ^= 0xFF; // bad magic
265        assert!(decode(&buf).is_none());
266        assert!(decode(&[]).is_none());
267    }
268
269    #[test]
270    fn rejects_trailing_garbage() {
271        let mut buf = encode(&[rec("email")]);
272        buf.push(0);
273        assert!(decode(&buf).is_none());
274    }
275}