Skip to main content

gqls/load/
record_cache.rs

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