Skip to main content

stryke/
script_cache.rs

1//! SQLite-backed bytecode cache for scripts.
2//!
3//! Stores compiled bytecode indexed by (canonical_path, mtime). On 2+ runs,
4//! skips lex/parse/compile entirely — just deserialize and eval into fusevm.
5//!
6//! Cache location: `~/.cache/stryke/scripts.db`
7//!
8//! Invalidation: mtime mismatch → recompile, update cache.
9
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use rusqlite::{params, Connection, OptionalExtension};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16use crate::ast::Program;
17use crate::bytecode::Chunk;
18use crate::error::{PerlError, PerlResult};
19use crate::value::PerlValue;
20
21// ── Constant pool codec for serializing PerlValues in bytecode cache ───────────
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25enum CacheConst {
26    Undef,
27    Int(i64),
28    Float(f64),
29    Str(String),
30}
31
32fn cache_const_from_perl(v: &PerlValue) -> Result<CacheConst, String> {
33    if v.is_undef() {
34        return Ok(CacheConst::Undef);
35    }
36    if let Some(n) = v.as_integer() {
37        return Ok(CacheConst::Int(n));
38    }
39    if let Some(f) = v.as_float() {
40        return Ok(CacheConst::Float(f));
41    }
42    if let Some(s) = v.as_str() {
43        return Ok(CacheConst::Str(s.to_string()));
44    }
45    Err(format!(
46        "constant pool value cannot be cached (type {})",
47        v.ref_type()
48    ))
49}
50
51fn perl_from_cache_const(c: CacheConst) -> PerlValue {
52    match c {
53        CacheConst::Undef => PerlValue::UNDEF,
54        CacheConst::Int(n) => PerlValue::integer(n),
55        CacheConst::Float(f) => PerlValue::float(f),
56        CacheConst::Str(s) => PerlValue::string(s),
57    }
58}
59
60/// Serde codec for serializing Vec<PerlValue> in bytecode Chunk.
61pub mod constants_pool_codec {
62    use super::*;
63
64    pub fn serialize<S>(values: &Vec<PerlValue>, ser: S) -> Result<S::Ok, S::Error>
65    where
66        S: Serializer,
67    {
68        let mut out = Vec::with_capacity(values.len());
69        for v in values {
70            let c = cache_const_from_perl(v).map_err(serde::ser::Error::custom)?;
71            out.push(c);
72        }
73        out.serialize(ser)
74    }
75
76    pub fn deserialize<'de, D>(de: D) -> Result<Vec<PerlValue>, D::Error>
77    where
78        D: Deserializer<'de>,
79    {
80        let v: Vec<CacheConst> = Vec::deserialize(de)?;
81        Ok(v.into_iter().map(perl_from_cache_const).collect())
82    }
83}
84
85/// Cached script bundle: AST + compiled bytecode.
86#[derive(Debug, Clone)]
87pub struct CachedScript {
88    pub program: Program,
89    pub chunk: Chunk,
90}
91
92/// SQLite-backed script cache.
93pub struct ScriptCache {
94    conn: Connection,
95}
96
97impl ScriptCache {
98    /// Open (or create) the cache database.
99    pub fn open(path: &Path) -> rusqlite::Result<Self> {
100        if let Some(parent) = path.parent() {
101            let _ = std::fs::create_dir_all(parent);
102        }
103        let conn = Connection::open(path)?;
104        conn.execute_batch(
105            "PRAGMA journal_mode=WAL;
106             PRAGMA synchronous=NORMAL;
107             PRAGMA cache_size=-64000;
108             PRAGMA mmap_size=268435456;
109             PRAGMA temp_store=MEMORY;",
110        )?;
111        let cache = Self { conn };
112        cache.init_schema()?;
113        Ok(cache)
114    }
115
116    fn init_schema(&self) -> rusqlite::Result<()> {
117        self.conn.execute_batch(
118            r#"
119            CREATE TABLE IF NOT EXISTS scripts (
120                id INTEGER PRIMARY KEY,
121                path TEXT NOT NULL UNIQUE,
122                mtime_secs INTEGER NOT NULL,
123                mtime_nsecs INTEGER NOT NULL,
124                stryke_version TEXT NOT NULL,
125                pointer_width INTEGER NOT NULL,
126                program_blob BLOB NOT NULL,
127                chunk_blob BLOB NOT NULL,
128                cached_at INTEGER NOT NULL
129            );
130            CREATE INDEX IF NOT EXISTS idx_scripts_path ON scripts(path);
131            "#,
132        )?;
133        Ok(())
134    }
135
136    /// Check cache for a script. Returns cached bundle if mtime matches.
137    pub fn get(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<CachedScript> {
138        let (program_blob, chunk_blob, version, ptr_width) = self
139            .conn
140            .query_row(
141                "SELECT program_blob, chunk_blob, stryke_version, pointer_width
142                 FROM scripts
143                 WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
144                params![path, mtime_secs, mtime_nsecs],
145                |row| {
146                    Ok((
147                        row.get::<_, Vec<u8>>(0)?,
148                        row.get::<_, Vec<u8>>(1)?,
149                        row.get::<_, String>(2)?,
150                        row.get::<_, i64>(3)?,
151                    ))
152                },
153            )
154            .optional()
155            .ok()
156            .flatten()?;
157
158        if version != env!("CARGO_PKG_VERSION") {
159            return None;
160        }
161        if ptr_width != std::mem::size_of::<usize>() as i64 {
162            return None;
163        }
164
165        let program_decompressed = zstd::stream::decode_all(&program_blob[..]).ok()?;
166        let chunk_decompressed = zstd::stream::decode_all(&chunk_blob[..]).ok()?;
167
168        let program: Program = bincode::deserialize(&program_decompressed).ok()?;
169        let chunk: Chunk = bincode::deserialize(&chunk_decompressed).ok()?;
170
171        Some(CachedScript { program, chunk })
172    }
173
174    /// Store a compiled script in the cache.
175    pub fn put(
176        &self,
177        path: &str,
178        mtime_secs: i64,
179        mtime_nsecs: i64,
180        program: &Program,
181        chunk: &Chunk,
182    ) -> PerlResult<()> {
183        let program_bytes =
184            bincode::serialize(program).map_err(|e| PerlError::runtime(e.to_string(), 0))?;
185        let chunk_bytes =
186            bincode::serialize(chunk).map_err(|e| PerlError::runtime(e.to_string(), 0))?;
187
188        let program_compressed = zstd::stream::encode_all(&program_bytes[..], 3)
189            .map_err(|e| PerlError::runtime(e.to_string(), 0))?;
190        let chunk_compressed = zstd::stream::encode_all(&chunk_bytes[..], 3)
191            .map_err(|e| PerlError::runtime(e.to_string(), 0))?;
192
193        let now = SystemTime::now()
194            .duration_since(std::time::UNIX_EPOCH)
195            .map(|d| d.as_secs() as i64)
196            .unwrap_or(0);
197
198        self.conn
199            .execute("DELETE FROM scripts WHERE path = ?1", params![path])
200            .map_err(|e| PerlError::runtime(e.to_string(), 0))?;
201
202        self.conn
203            .execute(
204                "INSERT INTO scripts (path, mtime_secs, mtime_nsecs, stryke_version, pointer_width, program_blob, chunk_blob, cached_at)
205                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
206                params![
207                    path,
208                    mtime_secs,
209                    mtime_nsecs,
210                    env!("CARGO_PKG_VERSION"),
211                    std::mem::size_of::<usize>() as i64,
212                    program_compressed,
213                    chunk_compressed,
214                    now,
215                ],
216            )
217            .map_err(|e| PerlError::runtime(e.to_string(), 0))?;
218
219        Ok(())
220    }
221
222    /// Get cache stats: (total_scripts, total_bytes).
223    pub fn stats(&self) -> (i64, i64) {
224        let count: i64 = self
225            .conn
226            .query_row("SELECT COUNT(*) FROM scripts", [], |r| r.get(0))
227            .unwrap_or(0);
228        let bytes: i64 = self
229            .conn
230            .query_row(
231                "SELECT COALESCE(SUM(LENGTH(program_blob) + LENGTH(chunk_blob)), 0) FROM scripts",
232                [],
233                |r| r.get(0),
234            )
235            .unwrap_or(0);
236        (count, bytes)
237    }
238
239    /// List all cached scripts: (path, program_kb, chunk_kb, version, cached_at).
240    pub fn list_scripts(&self) -> Vec<(String, f64, f64, String, String)> {
241        let mut stmt = match self.conn.prepare(
242            "SELECT path, LENGTH(program_blob)/1024.0, LENGTH(chunk_blob)/1024.0, stryke_version, datetime(cached_at, 'unixepoch', 'localtime')
243             FROM scripts ORDER BY cached_at DESC",
244        ) {
245            Ok(s) => s,
246            Err(_) => return Vec::new(),
247        };
248        stmt.query_map([], |row| {
249            Ok((
250                row.get::<_, String>(0)?,
251                row.get::<_, f64>(1)?,
252                row.get::<_, f64>(2)?,
253                row.get::<_, String>(3)?,
254                row.get::<_, String>(4)?,
255            ))
256        })
257        .ok()
258        .map(|rows| rows.filter_map(|r| r.ok()).collect())
259        .unwrap_or_default()
260    }
261
262    /// Evict stale entries (file deleted or mtime changed).
263    pub fn evict_stale(&self) -> usize {
264        let paths: Vec<(i64, String, i64, i64)> = {
265            let mut stmt = match self
266                .conn
267                .prepare("SELECT id, path, mtime_secs, mtime_nsecs FROM scripts")
268            {
269                Ok(s) => s,
270                Err(_) => return 0,
271            };
272            stmt.query_map([], |row| {
273                Ok((
274                    row.get::<_, i64>(0)?,
275                    row.get::<_, String>(1)?,
276                    row.get::<_, i64>(2)?,
277                    row.get::<_, i64>(3)?,
278                ))
279            })
280            .ok()
281            .map(|rows| rows.filter_map(|r| r.ok()).collect())
282            .unwrap_or_default()
283        };
284
285        let mut evicted = 0;
286        for (id, path, cached_s, cached_ns) in paths {
287            let stale = match file_mtime(Path::new(&path)) {
288                Some((s, ns)) => s != cached_s || ns != cached_ns,
289                None => true,
290            };
291            if stale {
292                let _ = self
293                    .conn
294                    .execute("DELETE FROM scripts WHERE id = ?1", params![id]);
295                evicted += 1;
296            }
297        }
298        evicted
299    }
300
301    /// Clear entire cache.
302    pub fn clear(&self) -> rusqlite::Result<()> {
303        self.conn.execute("DELETE FROM scripts", [])?;
304        self.conn.execute("VACUUM", [])?;
305        Ok(())
306    }
307}
308
309/// Get mtime from file metadata as (secs, nsecs).
310pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
311    use std::os::unix::fs::MetadataExt;
312    let meta = std::fs::metadata(path).ok()?;
313    Some((meta.mtime(), meta.mtime_nsec()))
314}
315
316/// Default path for the script cache db.
317pub fn default_cache_path() -> PathBuf {
318    dirs::home_dir()
319        .unwrap_or_else(|| PathBuf::from("/tmp"))
320        .join(".cache/stryke/scripts.db")
321}
322
323/// Global cache instance (lazy-initialized, Mutex-protected for thread safety).
324pub static CACHE: once_cell::sync::Lazy<Option<std::sync::Mutex<ScriptCache>>> =
325    once_cell::sync::Lazy::new(|| {
326        if !cache_enabled() {
327            return None;
328        }
329        ScriptCache::open(&default_cache_path())
330            .ok()
331            .map(std::sync::Mutex::new)
332    });
333
334/// Check if SQLite cache is enabled (default: true, disable with `STRYKE_SQLITE_CACHE=0`).
335pub fn cache_enabled() -> bool {
336    !matches!(
337        std::env::var("STRYKE_SQLITE_CACHE").as_deref(),
338        Ok("0") | Ok("false") | Ok("no")
339    )
340}
341
342/// Try to load a cached script by path. Returns None on miss.
343pub fn try_load(path: &Path) -> Option<CachedScript> {
344    let cache = CACHE.as_ref()?.lock().ok()?;
345    let canonical = path.canonicalize().ok()?;
346    let path_str = canonical.to_string_lossy();
347    let (mtime_s, mtime_ns) = file_mtime(&canonical)?;
348    cache.get(&path_str, mtime_s, mtime_ns)
349}
350
351/// Store a compiled script in the cache.
352pub fn try_save(path: &Path, program: &Program, chunk: &Chunk) -> PerlResult<()> {
353    let cache = match CACHE.as_ref() {
354        Some(c) => match c.lock() {
355            Ok(guard) => guard,
356            Err(_) => return Ok(()),
357        },
358        None => return Ok(()),
359    };
360    let canonical = match path.canonicalize() {
361        Ok(p) => p,
362        Err(_) => return Ok(()),
363    };
364    let path_str = canonical.to_string_lossy();
365    let (mtime_s, mtime_ns) = match file_mtime(&canonical) {
366        Some(m) => m,
367        None => return Ok(()),
368    };
369    cache.put(&path_str, mtime_s, mtime_ns, program, chunk)
370}
371
372/// Get global cache stats.
373pub fn stats() -> Option<(i64, i64)> {
374    CACHE
375        .as_ref()
376        .and_then(|c| c.lock().ok())
377        .map(|c| c.stats())
378}
379
380/// Evict stale entries from global cache.
381pub fn evict_stale() -> usize {
382    CACHE
383        .as_ref()
384        .and_then(|c| c.lock().ok())
385        .map(|c| c.evict_stale())
386        .unwrap_or(0)
387}
388
389/// Clear global cache.
390pub fn clear() -> bool {
391    CACHE
392        .as_ref()
393        .and_then(|c| c.lock().ok())
394        .map(|c| c.clear().is_ok())
395        .unwrap_or(false)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use tempfile::tempdir;
402
403    #[test]
404    fn round_trip() {
405        let dir = tempdir().unwrap();
406        let db_path = dir.path().join("test.db");
407        let cache = ScriptCache::open(&db_path).unwrap();
408
409        let script_path = dir.path().join("test.stk");
410        std::fs::write(&script_path, "p 42").unwrap();
411
412        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
413        let path_str = script_path.to_string_lossy().to_string();
414
415        let program = crate::parse("p 42").unwrap();
416        let chunk = crate::compiler::Compiler::new()
417            .compile_program(&program)
418            .unwrap();
419
420        cache
421            .put(&path_str, mtime_s, mtime_ns, &program, &chunk)
422            .unwrap();
423
424        let loaded = cache.get(&path_str, mtime_s, mtime_ns).unwrap();
425        assert_eq!(loaded.chunk.ops.len(), chunk.ops.len());
426
427        let (count, _bytes) = cache.stats();
428        assert_eq!(count, 1);
429    }
430
431    #[test]
432    fn mtime_invalidation() {
433        let dir = tempdir().unwrap();
434        let db_path = dir.path().join("test.db");
435        let cache = ScriptCache::open(&db_path).unwrap();
436
437        let script_path = dir.path().join("test.stk");
438        std::fs::write(&script_path, "p 42").unwrap();
439
440        let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
441        let path_str = script_path.to_string_lossy().to_string();
442
443        let program = crate::parse("p 42").unwrap();
444        let chunk = crate::compiler::Compiler::new()
445            .compile_program(&program)
446            .unwrap();
447
448        cache
449            .put(&path_str, mtime_s, mtime_ns, &program, &chunk)
450            .unwrap();
451
452        assert!(cache.get(&path_str, mtime_s + 1, mtime_ns).is_none());
453    }
454}