1use 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#[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
60pub 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#[derive(Debug, Clone)]
87pub struct CachedScript {
88 pub program: Program,
89 pub chunk: Chunk,
90}
91
92pub struct ScriptCache {
94 conn: Connection,
95}
96
97impl ScriptCache {
98 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 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 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 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 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 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 pub fn clear(&self) -> rusqlite::Result<()> {
303 self.conn.execute("DELETE FROM scripts", [])?;
304 self.conn.execute("VACUUM", [])?;
305 Ok(())
306 }
307}
308
309pub 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
316pub fn default_cache_path() -> PathBuf {
318 dirs::home_dir()
319 .unwrap_or_else(|| PathBuf::from("/tmp"))
320 .join(".cache/stryke/scripts.db")
321}
322
323pub 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
334pub 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
342pub 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
351pub 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
372pub fn stats() -> Option<(i64, i64)> {
374 CACHE
375 .as_ref()
376 .and_then(|c| c.lock().ok())
377 .map(|c| c.stats())
378}
379
380pub 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
389pub 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}