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