1use std::collections::{HashMap, HashSet};
18use std::path::{Path, PathBuf};
19
20use rusqlite::{Connection, OptionalExtension, params};
21use serde::{Deserialize, Serialize};
22
23use crate::embedder::ModelIdentity;
24use crate::embedding::{from_bytes, to_bytes};
25use crate::lexical::{FIELDS, LexicalDoc, LexicalQuery, order_free_text};
26use crate::{
27 Error, Modality, ResourceId, ResourceLocator, ResourceMetadata, ResourceVersion, Result,
28};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct CacheProfile {
33 pub model: ModelIdentity,
34 pub segmenter_version: String,
35}
36
37impl CacheProfile {
38 pub fn id(&self) -> String {
40 let json = serde_json::to_vec(self).expect("profile serialises");
41 blake3::hash(&json).to_hex()[..24].to_string()
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct CachedSegment {
48 pub locator: ResourceLocator,
49 pub heading: Option<String>,
50 pub input_hash: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct CachedResource {
56 pub metadata: ResourceMetadata,
57 pub version: ResourceVersion,
58 pub descriptor_hash: Option<String>,
60 pub segments: Vec<CachedSegment>,
61}
62
63#[derive(Debug, Clone)]
66pub struct SegmentWrite<'a> {
67 pub segment: &'a CachedSegment,
68 pub lexical_body: &'a str,
69}
70
71#[derive(Debug, Clone)]
81pub struct ResourceWrite<'a> {
82 pub metadata: &'a ResourceMetadata,
83 pub version: &'a ResourceVersion,
84 pub descriptor_hash: Option<&'a str>,
85 pub segments: Vec<SegmentWrite<'a>>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct CachedResourceState {
93 pub metadata: ResourceMetadata,
94 pub version: ResourceVersion,
95 pub descriptor_hash: Option<String>,
97}
98
99#[derive(Debug, Clone)]
101pub struct StoredVector {
102 pub resource: ResourceId,
103 pub ordinal: usize,
105 pub segment: CachedSegment,
106 pub vector: Vec<f32>,
107}
108
109#[derive(Debug, Clone, PartialEq)]
111pub struct LexicalHit {
112 pub resource: ResourceId,
113 pub ordinal: usize,
114 pub score: f32,
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ProfileStats {
120 pub profile_id: String,
121 pub profile: Option<CacheProfile>,
122 pub resources: u64,
123 pub segments: u64,
124 pub embeddings: u64,
125 #[serde(default)]
127 pub image_codes: u64,
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CacheStats {
132 pub location: Option<PathBuf>,
134 pub size_bytes: Option<u64>,
135 #[serde(skip)]
136 pub scrub_pending: bool,
137 pub profiles: Vec<ProfileStats>,
138 #[serde(default)]
140 pub backup_excluded: Option<bool>,
141 #[serde(default)]
143 pub in_synced_folder: bool,
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148pub struct CacheCleanup {
149 pub profiles_scrubbed: usize,
150 pub pending: bool,
151}
152
153impl CacheCleanup {
154 pub fn is_empty(&self) -> bool {
155 self.profiles_scrubbed == 0 && !self.pending
156 }
157}
158
159pub trait EmbeddingCache: Send {
164 fn scrub(&mut self) -> Result<CacheCleanup> {
166 Ok(CacheCleanup::default())
167 }
168
169 fn scrub_if_idle(&mut self, _quiet: std::time::Duration) -> Result<Option<CacheCleanup>> {
172 Ok(None)
173 }
174
175 fn scrub_delay(&self, quiet: std::time::Duration) -> std::time::Duration {
178 quiet
179 }
180
181 fn register_profile(&mut self, id: &str, profile: &CacheProfile) -> Result<()>;
182
183 fn resource_index(&self, profile: &str) -> Result<HashMap<ResourceId, CachedResourceState>>;
185
186 fn resource(&self, profile: &str, path: &ResourceId) -> Result<Option<CachedResource>>;
187
188 fn put_resource(
190 &mut self,
191 profile: &str,
192 path: &ResourceId,
193 resource: &ResourceWrite<'_>,
194 ) -> Result<()>;
195
196 fn touch_resource(
198 &mut self,
199 profile: &str,
200 path: &ResourceId,
201 metadata: &ResourceMetadata,
202 version: &ResourceVersion,
203 ) -> Result<()>;
204
205 fn remove_resources(&mut self, profile: &str, paths: &[ResourceId]) -> Result<()>;
206
207 fn known_inputs(&self, profile: &str, hashes: &[String]) -> Result<HashSet<String>>;
209
210 fn put_embeddings(&mut self, profile: &str, items: &[(String, Vec<f32>)]) -> Result<()>;
211
212 fn supports_image_codes(&self) -> bool {
214 false
215 }
216
217 fn sync_image_codes(&mut self, _profile: &str, _paths: &HashSet<ResourceId>) -> Result<()> {
219 Err(Error::Config(
220 "q4 image scan requires a persistent SQLite cache; --no-cache is unsupported".into(),
221 ))
222 }
223
224 fn image_scan_rows(
226 &self,
227 _profile: &str,
228 _paths: &HashSet<ResourceId>,
229 ) -> Result<Vec<ScanVector>> {
230 Err(Error::Config(
231 "q4 image scan is unsupported by this cache".into(),
232 ))
233 }
234
235 fn selected_image_vectors(
237 &self,
238 _profile: &str,
239 _hashes: &HashSet<String>,
240 ) -> Result<HashMap<String, Vec<f32>>> {
241 Err(Error::Config(
242 "q4 image scan is unsupported by this cache".into(),
243 ))
244 }
245
246 fn vectors_for(&self, profile: &str, paths: &HashSet<ResourceId>) -> Result<Vec<StoredVector>>;
248
249 fn lexical_search(
252 &self,
253 profile: &str,
254 query: &LexicalQuery,
255 paths: &HashSet<ResourceId>,
256 limit: usize,
257 ) -> Result<Vec<LexicalHit>>;
258
259 fn lexical_matched_terms(
264 &self,
265 profile: &str,
266 query: &LexicalQuery,
267 keys: &[(ResourceId, usize)],
268 ) -> Result<Vec<Vec<String>>>;
269
270 fn prune_orphans(&mut self, profile: &str) -> Result<u64>;
272
273 fn stats(&self) -> Result<CacheStats>;
274
275 fn clear(&mut self) -> Result<()>;
276}
277
278#[derive(Debug, Default)]
284pub struct MemoryCache {
285 profiles: HashMap<String, CacheProfile>,
286 resources: HashMap<String, HashMap<ResourceId, CachedResource>>,
289 vectors: HashMap<String, HashMap<String, Vec<f32>>>,
290 lexical: HashMap<String, HashMap<ResourceId, Vec<LexicalDoc>>>,
292}
293
294impl MemoryCache {
295 pub fn new() -> Self {
296 Self::default()
297 }
298}
299
300impl EmbeddingCache for MemoryCache {
301 fn register_profile(&mut self, id: &str, profile: &CacheProfile) -> Result<()> {
302 self.profiles.insert(id.to_string(), profile.clone());
303 Ok(())
304 }
305
306 fn resource_index(&self, profile: &str) -> Result<HashMap<ResourceId, CachedResourceState>> {
307 Ok(self
308 .resources
309 .get(profile)
310 .into_iter()
311 .flatten()
312 .map(|(path, r)| {
313 (
314 path.clone(),
315 CachedResourceState {
316 metadata: r.metadata.clone(),
317 version: r.version.clone(),
318 descriptor_hash: r.descriptor_hash.clone(),
319 },
320 )
321 })
322 .collect())
323 }
324
325 fn resource(&self, profile: &str, path: &ResourceId) -> Result<Option<CachedResource>> {
326 Ok(self
327 .resources
328 .get(profile)
329 .and_then(|b| b.get(path))
330 .cloned())
331 }
332
333 fn put_resource(
334 &mut self,
335 profile: &str,
336 path: &ResourceId,
337 resource: &ResourceWrite<'_>,
338 ) -> Result<()> {
339 let docs = lexical_docs(path, resource);
340 let stored = CachedResource {
342 metadata: resource.metadata.clone(),
343 version: resource.version.clone(),
344 descriptor_hash: resource.descriptor_hash.map(str::to_owned),
345 segments: resource
346 .segments
347 .iter()
348 .map(|s| s.segment.clone())
349 .collect(),
350 };
351 self.resources
352 .entry(profile.to_string())
353 .or_default()
354 .insert(path.clone(), stored);
355 self.lexical
356 .entry(profile.to_string())
357 .or_default()
358 .insert(path.clone(), docs);
359 Ok(())
360 }
361
362 fn touch_resource(
363 &mut self,
364 profile: &str,
365 path: &ResourceId,
366 metadata: &ResourceMetadata,
367 version: &ResourceVersion,
368 ) -> Result<()> {
369 if let Some(r) = self
370 .resources
371 .get_mut(profile)
372 .and_then(|b| b.get_mut(path))
373 {
374 r.metadata = metadata.clone();
375 r.version = version.clone();
376 }
377 Ok(())
378 }
379
380 fn remove_resources(&mut self, profile: &str, paths: &[ResourceId]) -> Result<()> {
381 if let Some(bucket) = self.resources.get_mut(profile) {
382 for p in paths {
383 bucket.remove(p);
384 }
385 }
386 if let Some(bucket) = self.lexical.get_mut(profile) {
387 for p in paths {
388 bucket.remove(p);
389 }
390 }
391 Ok(())
392 }
393
394 fn known_inputs(&self, profile: &str, hashes: &[String]) -> Result<HashSet<String>> {
395 let Some(vectors) = self.vectors.get(profile) else {
396 return Ok(HashSet::new());
397 };
398 Ok(hashes
399 .iter()
400 .filter(|h| vectors.contains_key(*h))
401 .cloned()
402 .collect())
403 }
404
405 fn put_embeddings(&mut self, profile: &str, items: &[(String, Vec<f32>)]) -> Result<()> {
406 let vectors = self.vectors.entry(profile.to_string()).or_default();
407 for (h, v) in items {
408 vectors.insert(h.clone(), v.clone());
409 }
410 Ok(())
411 }
412
413 fn vectors_for(&self, profile: &str, paths: &HashSet<ResourceId>) -> Result<Vec<StoredVector>> {
414 let (Some(resources), Some(vectors)) =
415 (self.resources.get(profile), self.vectors.get(profile))
416 else {
417 return Ok(Vec::new());
418 };
419 let mut out = Vec::new();
420 for (path, r) in resources {
421 if !paths.contains(path) {
422 continue;
423 }
424 for (ordinal, s) in r.segments.iter().enumerate() {
425 if let Some(v) = vectors.get(&s.input_hash) {
426 out.push(StoredVector {
427 resource: path.clone(),
428 ordinal,
429 segment: s.clone(),
430 vector: v.clone(),
431 });
432 }
433 }
434 }
435 Ok(out)
436 }
437
438 fn lexical_search(
439 &self,
440 profile: &str,
441 query: &LexicalQuery,
442 paths: &HashSet<ResourceId>,
443 limit: usize,
444 ) -> Result<Vec<LexicalHit>> {
445 let Some(bucket) = self.lexical.get(profile) else {
446 return Ok(Vec::new());
447 };
448 let docs = bucket
451 .iter()
452 .flat_map(|(path, docs)| docs.iter().enumerate().map(move |(i, d)| ((path, i), d)));
453 let mut scored = crate::lexical::bm25(docs, query);
454 scored.sort_by(|a, b| {
455 b.1.total_cmp(&a.1)
456 .then_with(|| resource_key(a.0.0).cmp(&resource_key(b.0.0)))
457 .then_with(|| a.0.1.cmp(&b.0.1))
458 });
459 Ok(scored
460 .into_iter()
461 .filter(|((path, _), _)| paths.contains(*path))
462 .take(limit)
463 .map(|((path, ordinal), score)| LexicalHit {
464 resource: path.clone(),
465 ordinal,
466 score: score as f32,
467 })
468 .collect())
469 }
470
471 fn lexical_matched_terms(
472 &self,
473 profile: &str,
474 query: &LexicalQuery,
475 keys: &[(ResourceId, usize)],
476 ) -> Result<Vec<Vec<String>>> {
477 Ok(keys
478 .iter()
479 .map(|(path, ordinal)| {
480 self.lexical
481 .get(profile)
482 .and_then(|bucket| bucket.get(path))
483 .and_then(|docs| docs.get(*ordinal))
484 .map(|doc| doc.matched_terms(query))
485 .unwrap_or_default()
486 })
487 .collect())
488 }
489
490 fn prune_orphans(&mut self, profile: &str) -> Result<u64> {
491 let Some(vectors) = self.vectors.get_mut(profile) else {
492 return Ok(0);
493 };
494 let live: HashSet<&str> = self
495 .resources
496 .get(profile)
497 .into_iter()
498 .flat_map(|b| b.values())
499 .flat_map(|r| r.segments.iter().map(|s| s.input_hash.as_str()))
500 .collect();
501 let before = vectors.len();
502 vectors.retain(|h, _| live.contains(h.as_str()));
503 Ok((before - vectors.len()) as u64)
504 }
505
506 fn stats(&self) -> Result<CacheStats> {
507 let profiles = self
508 .profiles
509 .iter()
510 .map(|(id, p)| {
511 let resources = self.resources.get(id);
512 ProfileStats {
513 profile_id: id.clone(),
514 profile: Some(p.clone()),
515 resources: resources.map_or(0, |b| b.len() as u64),
516 segments: resources
517 .into_iter()
518 .flat_map(|b| b.values())
519 .map(|r| r.segments.len() as u64)
520 .sum(),
521 embeddings: self.vectors.get(id).map_or(0, |b| b.len() as u64),
522 image_codes: 0,
523 }
524 })
525 .collect();
526 Ok(CacheStats {
527 location: None,
528 size_bytes: None,
529 backup_excluded: None,
530 in_synced_folder: false,
531 scrub_pending: false,
532 profiles,
533 })
534 }
535
536 fn clear(&mut self) -> Result<()> {
537 *self = Self::default();
538 Ok(())
539 }
540}
541
542fn lexical_docs(path: &ResourceId, resource: &ResourceWrite<'_>) -> Vec<LexicalDoc> {
544 let (name, full) = path.labels();
545 resource
546 .segments
547 .iter()
548 .map(|s| LexicalDoc::new(&name, &full, s.segment.heading.as_deref(), s.lexical_body))
549 .collect()
550}
551
552pub(crate) mod image_codes;
557pub use image_codes::{IMAGE_CODEC_ID, ScanData, ScanVector};
558mod import;
559pub use import::{CacheImportMapping, CacheImportReport};
560
561pub const SCHEMA_VERSION: &str = "7";
563
564fn fts_table(profile: &str) -> String {
567 let hex: String = profile
568 .as_bytes()
569 .iter()
570 .map(|b| format!("{b:02x}"))
571 .collect();
572 format!("\"segments_fts_p_{hex}\"")
573}
574
575fn create_profile_fts(tx: &rusqlite::Transaction<'_>, profile: &str) -> Result<()> {
576 tx.execute_batch(&format!(
577 "CREATE VIRTUAL TABLE IF NOT EXISTS {} USING fts5(
578 name, path, heading, body, detail=full, columnsize=1,
579 tokenize='unicode61 remove_diacritics 2');",
580 fts_table(profile)
581 ))?;
582 Ok(())
583}
584
585fn migrate_shared_fts(tx: &rusqlite::Transaction<'_>, profiles: &[String]) -> Result<()> {
588 let missing: bool = tx.query_row(
589 "SELECT EXISTS(SELECT 1 FROM segments s JOIN resources r
590 ON r.profile=s.profile AND r.path=s.path
591 WHERE s.rowid NOT IN (SELECT rowid FROM segments_fts)
592 AND NOT (r.content_hash='' AND r.modified_ns IS NULL
593 AND (r.version IS NULL OR r.version='null') AND r.descriptor_hash IS NULL))",
594 [],
595 |r| r.get(0),
596 )?;
597 if missing {
598 return Err(Error::Config(
599 "cache migration: live segment is missing its keyword row".into(),
600 ));
601 }
602 tx.execute_batch(
603 "CREATE VIRTUAL TABLE temp.migration_vocab USING fts5vocab(main, segments_fts, instance);",
604 )?;
605 let mut docs: HashMap<i64, (String, [Vec<String>; 4])> = tx
606 .prepare("SELECT s.rowid,s.profile FROM segments s JOIN segments_fts f ON f.rowid=s.rowid")?
607 .query_map([], |r| {
608 Ok((r.get(0)?, (r.get(1)?, std::array::from_fn(|_| Vec::new()))))
609 })?
610 .collect::<rusqlite::Result<_>>()?;
611 {
612 let mut stmt = tx.prepare("SELECT term,doc,col FROM temp.migration_vocab")?;
613 let mut rows = stmt.query([])?;
614 while let Some(row) = rows.next()? {
615 let doc = row.get::<_, i64>(1)?;
616 let Some((_, fields)) = docs.get_mut(&doc) else {
617 continue;
618 };
619 let col: String = row.get(2)?;
620 let index = ["name", "path", "heading", "body"]
621 .iter()
622 .position(|f| *f == col)
623 .ok_or_else(|| Error::Config("cache migration: invalid keyword field".into()))?;
624 fields[index].push(row.get(0)?);
625 }
626 }
627 for (rowid, (profile, fields)) in &mut docs {
628 let fields = fields.each_mut().map(|terms| {
629 terms.sort();
630 terms.join(" ")
631 });
632 tx.prepare_cached(&format!(
633 "INSERT INTO {}(rowid,name,path,heading,body) VALUES(?1,?2,?3,?4,?5)",
634 fts_table(profile)
635 ))?
636 .execute(params![rowid, fields[0], fields[1], fields[2], fields[3]])?;
637 }
638 for profile in profiles {
641 let table = fts_table(profile);
642 tx.execute_batch(&format!(
643 "CREATE VIRTUAL TABLE temp.migration_new USING fts5vocab(main, {table}, instance);"
644 ))?;
645 let different: bool = tx.query_row(
646 "WITH old AS (SELECT term,doc,col,offset FROM temp.migration_vocab
647 JOIN segments s ON s.rowid=doc WHERE s.profile=?1),
648 missing AS (SELECT * FROM old EXCEPT SELECT * FROM temp.migration_new),
649 added AS (SELECT * FROM temp.migration_new EXCEPT SELECT * FROM old)
650 SELECT EXISTS(SELECT 1 FROM missing UNION ALL SELECT 1 FROM added)",
651 [profile],
652 |r| r.get(0),
653 )?;
654 let count: i64 =
655 tx.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))?;
656 if different || count != docs.values().filter(|(p, _)| p == profile).count() as i64 {
657 return Err(Error::Config(
658 "cache migration: keyword round trip failed".into(),
659 ));
660 }
661 tx.execute_batch("DROP TABLE temp.migration_new;")?;
662 }
663 tx.execute_batch("DROP TABLE temp.migration_vocab; DROP TABLE segments_fts;")?;
664 Ok(())
665}
666
667pub struct SqliteCache {
671 conn: Connection,
672 path: PathBuf,
673 guards: super::cache_guards::GuardStatus,
674 last_change: std::time::Instant,
675 last_attempt: Option<std::time::Instant>,
676 observed_generation: Option<String>,
677 #[cfg(test)]
678 after_vector_read: Option<Box<dyn FnMut() + Send>>,
679}
680
681impl SqliteCache {
682 pub fn default_path() -> Result<PathBuf> {
687 let base = dirs::cache_dir()
688 .ok_or_else(|| Error::Config("no cache directory on this platform".into()))?;
689 Ok(base.join("seyk").join("cache.sqlite3"))
690 }
691
692 pub fn open_default() -> Result<Self> {
693 Self::open(Self::default_path()?)
694 }
695
696 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
699 Self::open_with_guards(path.into(), super::cache_guards::prepare)
700 }
701
702 fn open_with_guards(
703 path: PathBuf,
704 prepare: impl FnOnce(&Path, bool) -> super::cache_guards::GuardStatus,
705 ) -> Result<Self> {
706 Self::open_internal(path, prepare, true)
707 }
708
709 fn open_internal(
710 path: PathBuf,
711 prepare: impl FnOnce(&Path, bool) -> super::cache_guards::GuardStatus,
712 initialize: bool,
713 ) -> Result<Self> {
714 let dir = super::cache_guards::parent_dir(&path);
715 let created = match std::fs::create_dir(dir) {
718 Ok(()) => true,
719 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
720 std::fs::create_dir_all(super::cache_guards::parent_dir(dir))
721 .map_err(|e| Error::io(dir, e))?;
722 match std::fs::create_dir(dir) {
723 Ok(()) => true,
724 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => {
725 false
726 }
727 Err(e) => return Err(Error::io(dir, e)),
728 }
729 }
730 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => false,
731 Err(e) => return Err(Error::io(dir, e)),
732 };
733 let owned = created || super::cache_guards::is_default_dir(dir);
734 if owned {
735 restrict_permissions(dir, 0o700);
736 }
737 let guards = prepare(dir, owned);
739 let conn = Connection::open(&path)?;
740 restrict_permissions(&path, 0o600);
741 conn.pragma_update(None, "journal_mode", "WAL")?;
742 conn.pragma_update(None, "synchronous", "NORMAL")?;
743 conn.pragma_update(None, "foreign_keys", "ON")?;
744 conn.pragma_update(None, "secure_delete", "ON")?;
745 conn.busy_timeout(std::time::Duration::from_secs(10))?;
746 let mut cache = Self {
747 conn,
748 path,
749 guards,
750 last_change: std::time::Instant::now(),
751 last_attempt: None,
752 observed_generation: None,
753 #[cfg(test)]
754 after_vector_read: None,
755 };
756 if !initialize {
757 return Ok(cache);
758 }
759 cache.migrate()?;
760 cache.observed_generation = cache
761 .conn
762 .query_row(
763 "SELECT value FROM meta WHERE key='wal_scrub_pending'",
764 [],
765 |r| r.get(0),
766 )
767 .optional()?;
768 cache.last_change = std::time::Instant::now();
769 Ok(cache)
770 }
771
772 pub fn path(&self) -> &Path {
773 &self.path
774 }
775
776 fn cleanup_pending(&self) -> Result<bool> {
777 Ok(self.conn.query_row(
778 "SELECT EXISTS(SELECT 1 FROM meta WHERE key IN ('wal_scrub_pending', 'compact_pending') OR key GLOB 'fts_scrub_pending:*')",
779 [], |r| r.get(0),
780 )?)
781 }
782
783 fn run_scrub(&mut self) -> Result<CacheCleanup> {
785 self.last_attempt = Some(std::time::Instant::now());
786 if !self.cleanup_pending()? {
787 return Ok(CacheCleanup::default());
788 }
789 self.conn.busy_timeout(std::time::Duration::ZERO)?;
790 let mut report = CacheCleanup::default();
791 let result = self.scrub_pending_inner(&mut report);
792 self.conn.busy_timeout(std::time::Duration::from_secs(10))?;
793 match result {
794 Ok(()) => Ok(report),
795 Err(Error::Cache(error)) if sqlite_busy(&error) => {
796 report.pending = true;
797 Ok(report)
798 }
799 Err(error) => Err(error),
800 }
801 }
802
803 fn scrub_pending_inner(&mut self, report: &mut CacheCleanup) -> Result<()> {
804 let tx = self
805 .conn
806 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
807 let profiles = tx
808 .prepare("SELECT key,value FROM meta WHERE key GLOB 'fts_scrub_pending:*'")?
809 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
810 .collect::<rusqlite::Result<Vec<_>>>()?;
811 for (key, _) in &profiles {
812 optimize_fts(&tx, key.strip_prefix("fts_scrub_pending:").unwrap())?;
813 }
814 let generation: Option<String> = tx
816 .query_row(
817 "SELECT value FROM meta WHERE key='wal_scrub_pending'",
818 [],
819 |r| r.get(0),
820 )
821 .optional()?;
822 tx.commit()?;
823 report.profiles_scrubbed = profiles.len();
824 let Some(generation) = generation else {
825 report.pending = self.cleanup_pending()?;
826 return Ok(());
827 };
828 let compact: bool = self.conn.query_row(
829 "SELECT EXISTS(SELECT 1 FROM meta WHERE key='compact_pending')",
830 [],
831 |r| r.get(0),
832 )?;
833 if compact {
834 self.conn.flush_prepared_statement_cache();
835 self.conn.execute_batch("VACUUM")?;
836 }
837 let busy: i64 = self
839 .conn
840 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(0))?;
841 if busy != 0 {
842 report.pending = true;
843 return Ok(());
844 }
845 let tx = self
848 .conn
849 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
850 for (key, value) in &profiles {
851 tx.execute(
852 "DELETE FROM meta WHERE key=?1 AND value=?2",
853 params![key, value],
854 )?;
855 }
856 let finished = tx.execute(
857 "DELETE FROM meta WHERE key='wal_scrub_pending' AND value=?1",
858 [generation],
859 )?;
860 if finished != 0 {
861 tx.execute("DELETE FROM meta WHERE key='compact_pending'", [])?;
862 }
863 tx.commit()?;
864 report.pending = self.cleanup_pending()?;
865 Ok(())
866 }
867
868 fn record_write(&mut self, changed: bool, generation: Option<String>) {
869 if changed {
870 self.last_change = std::time::Instant::now();
871 self.observed_generation = generation;
874 }
875 }
876
877 fn migrate(&mut self) -> Result<()> {
878 let tx = self
879 .conn
880 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
881 Self::initialize_schema(&tx)?;
882 tx.commit()?;
883 Ok(())
884 }
885
886 fn initialize_schema(tx: &rusqlite::Transaction<'_>) -> Result<()> {
887 let has_meta: bool = tx.query_row(
889 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name = 'meta')",
890 [],
891 |r| r.get(0),
892 )?;
893 let version: Option<String> = if has_meta {
894 tx.query_row(
895 "SELECT value FROM meta WHERE key = 'schema_version'",
896 [],
897 |r| r.get(0),
898 )
899 .optional()?
900 } else {
901 None
902 };
903 if version.as_deref() == Some(SCHEMA_VERSION) {
904 return Ok(());
905 }
906 if version.as_deref() == Some("6") {
907 tx.execute_batch(image_codes::CREATE)?;
908 tx.execute(
909 "UPDATE meta SET value=?1 WHERE key='schema_version'",
910 [SCHEMA_VERSION],
911 )?;
912 return Ok(());
913 }
914 if let Some(version) = &version {
915 if !matches!(version.as_str(), "1" | "2" | "3" | "4" | "5") {
916 return Err(Error::Config(format!(
917 "cache schema {version} is newer than this binary supports; use a newer seyk or a separate --cache-path"
918 )));
919 }
920 } else {
921 let nonempty: bool = tx.query_row(
922 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name NOT LIKE 'sqlite_%')",
923 [],
924 |r| r.get(0),
925 )?;
926 if nonempty {
927 return Err(Error::Config(
928 "nonempty cache is missing schema_version metadata".into(),
929 ));
930 }
931 }
932 tx.execute_batch(
933 "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
934 )?;
935 tx.execute_batch(
936 "CREATE TABLE IF NOT EXISTS profiles (
937 id TEXT PRIMARY KEY,
938 profile TEXT NOT NULL,
939 dimension INTEGER NOT NULL
940 );
941 CREATE TABLE IF NOT EXISTS resources (
942 profile TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
943 path BLOB NOT NULL,
944 modality TEXT NOT NULL,
945 size_bytes INTEGER NOT NULL,
946 modified_ns INTEGER,
947 content_hash TEXT NOT NULL,
948 version TEXT,
949 PRIMARY KEY (profile, path)
950 );
951 CREATE TABLE IF NOT EXISTS segments (
952 id INTEGER PRIMARY KEY,
953 profile TEXT NOT NULL,
954 path BLOB NOT NULL,
955 ordinal INTEGER NOT NULL,
956 locator TEXT NOT NULL,
957 heading TEXT,
958 input_hash TEXT NOT NULL,
959 UNIQUE (profile, path, ordinal),
960 FOREIGN KEY (profile, path) REFERENCES resources(profile, path) ON DELETE CASCADE
961 );
962 CREATE INDEX IF NOT EXISTS segments_by_input ON segments(profile, input_hash);
963 CREATE TABLE IF NOT EXISTS embeddings (
964 profile TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
965 input_hash TEXT NOT NULL,
966 vector BLOB NOT NULL,
967 PRIMARY KEY (profile, input_hash)
968 );
969 ",
970 )?;
971 tx.execute_batch(image_codes::CREATE)?;
972 let has_media_type: bool = tx.query_row(
973 "SELECT EXISTS(SELECT 1 FROM pragma_table_info('resources') WHERE name = 'media_type')",
974 [],
975 |r| r.get(0),
976 )?;
977 if !has_media_type {
978 tx.execute_batch("ALTER TABLE resources ADD COLUMN media_type TEXT;")?;
979 }
980 let has_version: bool = tx.query_row(
983 "SELECT EXISTS(SELECT 1 FROM pragma_table_info('resources') WHERE name = 'version')",
984 [],
985 |r| r.get(0),
986 )?;
987 if !has_version {
988 tx.execute_batch("ALTER TABLE resources ADD COLUMN version TEXT;")?;
989 }
990 let has_descriptor: bool = tx.query_row(
991 "SELECT EXISTS(SELECT 1 FROM pragma_table_info('resources') WHERE name = 'descriptor_hash')",
992 [], |r| r.get(0),
993 )?;
994 if !has_descriptor {
995 tx.execute_batch("ALTER TABLE resources ADD COLUMN descriptor_hash TEXT;")?;
996 }
997 if matches!(version.as_deref(), Some("1" | "2" | "3")) {
998 tx.execute_batch(
999 r#"UPDATE resources SET version = CASE
1000 WHEN modified_ns IS NOT NULL AND content_hash != ''
1001 THEN '"fs:' || size_bytes || ':' || modified_ns || '"'
1002 ELSE 'null' END;"#,
1003 )?;
1004 }
1005 if version.is_some() {
1006 tx.execute_batch(
1008 "CREATE TABLE segments_v6 (
1009 id INTEGER PRIMARY KEY,
1010 profile TEXT NOT NULL, path BLOB NOT NULL, ordinal INTEGER NOT NULL,
1011 locator TEXT NOT NULL, heading TEXT, input_hash TEXT NOT NULL,
1012 UNIQUE(profile, path, ordinal),
1013 FOREIGN KEY(profile, path) REFERENCES resources(profile, path) ON DELETE CASCADE
1014 );
1015 INSERT INTO segments_v6 SELECT rowid, profile, path, ordinal, locator, heading, input_hash FROM segments;
1016 DROP TABLE segments;
1017 ALTER TABLE segments_v6 RENAME TO segments;
1018 CREATE INDEX segments_by_input ON segments(profile, input_hash);",
1019 )?;
1020 }
1021 let profiles = tx
1022 .prepare("SELECT id FROM profiles")?
1023 .query_map([], |r| r.get::<_, String>(0))?
1024 .collect::<rusqlite::Result<Vec<_>>>()?;
1025 for profile in &profiles {
1026 create_profile_fts(tx, profile)?;
1027 }
1028 if matches!(version.as_deref(), Some("1" | "2")) {
1029 tx.execute_batch(
1032 "DROP TABLE IF EXISTS segments_fts;
1033 UPDATE resources SET modified_ns = NULL, content_hash = '', version = 'null', descriptor_hash = NULL;",
1034 )?;
1035 } else if version.is_some() {
1036 migrate_shared_fts(tx, &profiles)?;
1037 }
1038 tx.execute(
1039 "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
1040 [SCHEMA_VERSION],
1041 )?;
1042 if version.is_some() {
1043 tx.execute_batch("INSERT OR IGNORE INTO meta VALUES('compact_pending','1');")?;
1044 mark_cleanup(tx, None)?;
1045 }
1046 Ok(())
1047 }
1048}
1049
1050pub(crate) fn resource_key(id: &ResourceId) -> Vec<u8> {
1053 match id.to_file_path() {
1054 Some(path) => path_key(&path),
1055 None => std::iter::once(0).chain(id.as_str().bytes()).collect(),
1056 }
1057}
1058
1059fn key_resource(bytes: Vec<u8>) -> rusqlite::Result<ResourceId> {
1060 let result = if let Some(uri) = bytes.strip_prefix(&[0]) {
1061 std::str::from_utf8(uri)
1062 .map_err(|e| Error::Config(e.to_string()))
1063 .and_then(str::parse)
1064 } else {
1065 ResourceId::from_path(key_path(bytes))
1066 };
1067 result.map_err(|e| {
1068 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Blob, Box::new(e))
1069 })
1070}
1071
1072fn decode_version(value: Option<String>) -> rusqlite::Result<ResourceVersion> {
1073 value
1074 .map(|v| {
1075 serde_json::from_str(&v).map_err(|e| {
1076 rusqlite::Error::FromSqlConversionFailure(
1077 0,
1078 rusqlite::types::Type::Text,
1079 Box::new(e),
1080 )
1081 })
1082 })
1083 .transpose()
1084 .map(Option::unwrap_or_default)
1085}
1086
1087#[cfg(unix)]
1088pub(crate) fn path_key(p: &Path) -> Vec<u8> {
1089 use std::os::unix::ffi::OsStrExt;
1090 p.as_os_str().as_bytes().to_vec()
1091}
1092
1093#[cfg(unix)]
1094fn key_path(b: Vec<u8>) -> PathBuf {
1095 use std::os::unix::ffi::OsStringExt;
1096 PathBuf::from(std::ffi::OsString::from_vec(b))
1097}
1098
1099#[cfg(windows)]
1100pub(crate) fn path_key(p: &Path) -> Vec<u8> {
1101 use std::path::{Component, Prefix};
1105 let raw = p.to_string_lossy();
1106 match p.components().next() {
1107 Some(Component::Prefix(prefix)) if matches!(prefix.kind(), Prefix::Disk(_)) => {
1108 format!(r"\\?\{raw}").into_bytes()
1109 }
1110 _ => raw.into_owned().into_bytes(),
1111 }
1112}
1113
1114#[cfg(not(any(unix, windows)))]
1115pub(crate) fn path_key(p: &Path) -> Vec<u8> {
1116 p.to_string_lossy().into_owned().into_bytes()
1117}
1118
1119#[cfg(not(unix))]
1120fn key_path(b: Vec<u8>) -> PathBuf {
1121 PathBuf::from(String::from_utf8_lossy(&b).into_owned())
1122}
1123
1124#[cfg(unix)]
1125fn restrict_permissions(p: &Path, mode: u32) {
1126 use std::os::unix::fs::PermissionsExt;
1127 let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode));
1128}
1129
1130#[cfg(not(unix))]
1131fn restrict_permissions(_: &Path, _: u32) {}
1132
1133fn sqlite_busy(error: &rusqlite::Error) -> bool {
1134 matches!(error, rusqlite::Error::SqliteFailure(code, _)
1135 if matches!(code.code, rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked))
1136}
1137
1138fn mark_cleanup(tx: &rusqlite::Transaction<'_>, profile: Option<&str>) -> Result<String> {
1139 let generation: String = tx.query_row("SELECT lower(hex(randomblob(16)))", [], |r| r.get(0))?;
1140 tx.execute(
1141 "INSERT INTO meta VALUES('wal_scrub_pending',?1)
1142 ON CONFLICT(key) DO UPDATE SET value=excluded.value",
1143 [&generation],
1144 )?;
1145 if let Some(profile) = profile {
1146 tx.execute(
1147 "INSERT INTO meta VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
1148 params![format!("fts_scrub_pending:{profile}"), generation],
1149 )?;
1150 }
1151 Ok(generation)
1152}
1153
1154fn refresh_pending_activity(tx: &rusqlite::Transaction<'_>) -> Result<Option<String>> {
1157 Ok(tx.query_row(
1158 "UPDATE meta SET value=lower(hex(randomblob(16))) WHERE key='wal_scrub_pending' RETURNING value",
1159 [], |r| r.get(0),
1160 ).optional()?)
1161}
1162
1163fn delete_fts_rows(tx: &rusqlite::Transaction, profile: &str, key: &[u8]) -> Result<bool> {
1166 let deleted = tx
1167 .prepare_cached(&format!(
1168 "DELETE FROM {} WHERE rowid IN
1169 (SELECT rowid FROM segments WHERE profile = ?1 AND path = ?2)",
1170 fts_table(profile)
1171 ))?
1172 .execute(params![profile, key])?;
1173 Ok(deleted != 0)
1174}
1175
1176fn optimize_fts(tx: &rusqlite::Transaction, profile: &str) -> Result<()> {
1179 let table = fts_table(profile);
1180 tx.execute_batch(&format!("INSERT INTO {table}({table}) VALUES('optimize');"))?;
1181 Ok(())
1182}
1183
1184fn parse_modality(s: &str) -> Modality {
1185 serde_json::from_value(serde_json::Value::String(s.to_string())).unwrap_or(Modality::Text)
1186}
1187
1188fn json_err(e: serde_json::Error) -> Error {
1189 Error::Config(format!("corrupt cache entry: {e}"))
1190}
1191
1192impl EmbeddingCache for SqliteCache {
1193 fn scrub(&mut self) -> Result<CacheCleanup> {
1194 self.run_scrub()
1195 }
1196
1197 fn scrub_if_idle(&mut self, quiet: std::time::Duration) -> Result<Option<CacheCleanup>> {
1198 let now = std::time::Instant::now();
1199 let generation: Option<String> = self
1200 .conn
1201 .query_row(
1202 "SELECT value FROM meta WHERE key='wal_scrub_pending'",
1203 [],
1204 |r| r.get(0),
1205 )
1206 .optional()?;
1207 if generation != self.observed_generation {
1208 self.observed_generation = generation;
1209 self.last_change = now;
1210 }
1211 if now.duration_since(self.last_change) < quiet
1212 || self
1213 .last_attempt
1214 .is_some_and(|attempt| now.duration_since(attempt) < quiet)
1215 || !self.cleanup_pending()?
1216 {
1217 return Ok(None);
1218 }
1219 self.run_scrub().map(Some)
1220 }
1221
1222 fn scrub_delay(&self, quiet: std::time::Duration) -> std::time::Duration {
1223 let remaining = quiet.saturating_sub(self.last_change.elapsed()).max(
1224 self.last_attempt
1225 .map_or(std::time::Duration::ZERO, |attempt| {
1226 quiet.saturating_sub(attempt.elapsed())
1227 }),
1228 );
1229 if remaining.is_zero() {
1230 quiet
1231 } else {
1232 remaining
1233 }
1234 }
1235
1236 fn register_profile(&mut self, id: &str, profile: &CacheProfile) -> Result<()> {
1237 let tx = self
1238 .conn
1239 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1240 let inserted = tx.execute(
1241 "INSERT OR IGNORE INTO profiles (id, profile, dimension) VALUES (?1, ?2, ?3)",
1242 params![
1243 id,
1244 serde_json::to_string(profile).map_err(json_err)?,
1245 profile.model.dimension.get() as i64
1246 ],
1247 )?;
1248 if inserted == 0 {
1249 let table_exists: bool = tx.query_row(
1250 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
1251 [fts_table(id).trim_matches('"')],
1252 |r| r.get(0),
1253 )?;
1254 if !table_exists {
1255 return Err(Error::Config(
1256 "corrupt cache: registered profile is missing its keyword table".into(),
1257 ));
1258 }
1259 } else {
1260 create_profile_fts(&tx, id)?;
1261 }
1262 tx.commit()?;
1263 Ok(())
1264 }
1265
1266 fn resource_index(&self, profile: &str) -> Result<HashMap<ResourceId, CachedResourceState>> {
1267 let mut stmt = self.conn.prepare_cached(
1268 "SELECT path, modality, size_bytes, modified_ns, content_hash, version, descriptor_hash, media_type FROM resources WHERE profile = ?1",
1269 )?;
1270 let rows = stmt.query_map([profile], |r| {
1271 let id = key_resource(r.get(0)?)?;
1272 let metadata = ResourceMetadata {
1273 media_type: r.get(7)?,
1274 modality: parse_modality(&r.get::<_, String>(1)?),
1275 size_bytes: r.get::<_, i64>(2)? as u64,
1276 modified_ns: r.get(3)?,
1277 content_hash: r.get(4)?,
1278 };
1279 let version = decode_version(r.get(5)?)?;
1280 Ok((
1281 id,
1282 CachedResourceState {
1283 metadata,
1284 version,
1285 descriptor_hash: r.get(6)?,
1286 },
1287 ))
1288 })?;
1289 Ok(rows.collect::<rusqlite::Result<_>>()?)
1290 }
1291
1292 fn resource(&self, profile: &str, path: &ResourceId) -> Result<Option<CachedResource>> {
1293 let key = resource_key(path);
1294 let metadata = self
1295 .conn
1296 .query_row(
1297 "SELECT modality, size_bytes, modified_ns, content_hash, version, descriptor_hash, media_type FROM resources WHERE profile = ?1 AND path = ?2",
1298 params![profile, key],
1299 |r| {
1300 Ok((ResourceMetadata {
1301 media_type: r.get(6)?,
1302 modality: parse_modality(&r.get::<_, String>(0)?),
1303 size_bytes: r.get::<_, i64>(1)? as u64,
1304 modified_ns: r.get(2)?,
1305 content_hash: r.get(3)?,
1306 }, decode_version(r.get(4)?)?, r.get::<_, Option<String>>(5)?))
1307 },
1308 )
1309 .optional()?;
1310 let Some((metadata, version, descriptor_hash)) = metadata else {
1311 return Ok(None);
1312 };
1313 let mut stmt = self.conn.prepare_cached(
1314 "SELECT locator, heading, input_hash FROM segments WHERE profile = ?1 AND path = ?2 ORDER BY ordinal",
1315 )?;
1316 let segments = stmt
1317 .query_map(params![profile, key], |r| {
1318 Ok((
1319 r.get::<_, String>(0)?,
1320 r.get::<_, Option<String>>(1)?,
1321 r.get::<_, String>(2)?,
1322 ))
1323 })?
1324 .map(|row| {
1325 let (loc, heading, input_hash) = row?;
1326 Ok(CachedSegment {
1327 locator: serde_json::from_str(&loc).map_err(json_err)?,
1328 heading,
1329 input_hash,
1330 })
1331 })
1332 .collect::<Result<Vec<_>>>()?;
1333 Ok(Some(CachedResource {
1334 metadata,
1335 version,
1336 descriptor_hash,
1337 segments,
1338 }))
1339 }
1340
1341 fn put_resource(
1342 &mut self,
1343 profile: &str,
1344 path: &ResourceId,
1345 resource: &ResourceWrite<'_>,
1346 ) -> Result<()> {
1347 let key = resource_key(path);
1348 let tx = self
1349 .conn
1350 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1351 let deleted = delete_fts_rows(&tx, profile, &key)?;
1352 let replaced = tx.execute(
1353 "DELETE FROM resources WHERE profile = ?1 AND path = ?2",
1354 params![profile, key],
1355 )?;
1356 let m = &resource.metadata;
1357 tx.execute(
1358 "INSERT INTO resources (profile, path, modality, size_bytes, modified_ns, content_hash, version, descriptor_hash, media_type)
1359 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1360 params![
1361 profile,
1362 key,
1363 m.modality.to_string(),
1364 m.size_bytes as i64,
1365 m.modified_ns,
1366 m.content_hash,
1367 serde_json::to_string(&resource.version).map_err(json_err)?,
1368 resource.descriptor_hash,
1369 m.media_type
1370 ],
1371 )?;
1372 {
1373 let mut stmt = tx.prepare_cached(
1374 "INSERT INTO segments (profile, path, ordinal, locator, heading, input_hash)
1375 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1376 )?;
1377 let mut fts = tx.prepare_cached(&format!(
1378 "INSERT INTO {} (rowid, name, path, heading, body) VALUES (?1, ?2, ?3, ?4, ?5)",
1379 fts_table(profile)
1380 ))?;
1381 let (name, full) = path.labels();
1382 let name = order_free_text(&name);
1383 let full = order_free_text(&full);
1384 for (i, s) in resource.segments.iter().enumerate() {
1385 stmt.execute(params![
1386 profile,
1387 key,
1388 i as i64,
1389 serde_json::to_string(&s.segment.locator).map_err(json_err)?,
1390 s.segment.heading,
1391 s.segment.input_hash
1392 ])?;
1393 fts.execute(params![
1394 tx.last_insert_rowid(),
1395 name,
1396 full,
1397 order_free_text(s.segment.heading.as_deref().unwrap_or("")),
1398 order_free_text(s.lexical_body)
1399 ])?;
1400 }
1401 }
1402 let generation = if deleted || replaced != 0 {
1403 Some(mark_cleanup(&tx, deleted.then_some(profile))?)
1404 } else {
1405 refresh_pending_activity(&tx)?
1406 };
1407 tx.commit()?;
1408 self.record_write(true, generation);
1409 Ok(())
1410 }
1411
1412 fn touch_resource(
1413 &mut self,
1414 profile: &str,
1415 path: &ResourceId,
1416 metadata: &ResourceMetadata,
1417 version: &ResourceVersion,
1418 ) -> Result<()> {
1419 let tx = self
1420 .conn
1421 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1422 let changed = tx.execute(
1423 "UPDATE resources SET modified_ns = ?3, size_bytes = ?4, version = ?5, content_hash = ?6, modality = ?7, media_type = ?8 WHERE profile = ?1 AND path = ?2
1424 AND (modified_ns IS NOT ?3 OR size_bytes IS NOT ?4 OR version IS NOT ?5 OR content_hash IS NOT ?6 OR modality IS NOT ?7 OR media_type IS NOT ?8)",
1425 params![profile, resource_key(path), metadata.modified_ns, metadata.size_bytes as i64, serde_json::to_string(version).map_err(json_err)?, metadata.content_hash, metadata.modality.to_string(), metadata.media_type],
1426 )?;
1427 let generation = if changed != 0 {
1428 Some(mark_cleanup(&tx, None)?)
1429 } else {
1430 None
1431 };
1432 tx.commit()?;
1433 self.record_write(changed != 0, generation);
1434 Ok(())
1435 }
1436
1437 fn remove_resources(&mut self, profile: &str, paths: &[ResourceId]) -> Result<()> {
1438 let tx = self
1439 .conn
1440 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1441 if !tx.query_row(
1442 "SELECT EXISTS(SELECT 1 FROM profiles WHERE id = ?1)",
1443 [profile],
1444 |r| r.get::<_, bool>(0),
1445 )? {
1446 return Ok(());
1447 }
1448 let mut deleted = false;
1449 let mut removed = 0;
1450 {
1451 let mut stmt =
1452 tx.prepare_cached("DELETE FROM resources WHERE profile = ?1 AND path = ?2")?;
1453 for p in paths {
1454 let key = resource_key(p);
1455 deleted |= delete_fts_rows(&tx, profile, &key)?;
1456 removed += stmt.execute(params![profile, key])?;
1457 }
1458 }
1459 let generation = if deleted || removed != 0 {
1460 Some(mark_cleanup(&tx, deleted.then_some(profile))?)
1461 } else {
1462 None
1463 };
1464 tx.commit()?;
1465 self.record_write(deleted || removed != 0, generation);
1466 Ok(())
1467 }
1468
1469 fn known_inputs(&self, profile: &str, hashes: &[String]) -> Result<HashSet<String>> {
1470 let mut stmt = self
1471 .conn
1472 .prepare_cached("SELECT 1 FROM embeddings WHERE profile = ?1 AND input_hash = ?2")?;
1473 let mut out = HashSet::new();
1474 for h in hashes {
1475 if stmt.exists(params![profile, h])? {
1476 out.insert(h.clone());
1477 }
1478 }
1479 Ok(out)
1480 }
1481
1482 fn put_embeddings(&mut self, profile: &str, items: &[(String, Vec<f32>)]) -> Result<()> {
1483 let tx = self
1484 .conn
1485 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1486 let mut overwritten = false;
1487 let mut changed = false;
1488 {
1489 let mut read = tx.prepare_cached(
1490 "SELECT vector FROM embeddings WHERE profile=?1 AND input_hash=?2",
1491 )?;
1492 let mut stmt = tx.prepare_cached(
1493 "INSERT OR REPLACE INTO embeddings (profile,input_hash,vector) VALUES(?1,?2,?3)",
1494 )?;
1495 for (hash, vector) in items {
1496 let bytes = to_bytes(vector);
1497 let old: Option<Vec<u8>> = read
1498 .query_row(params![profile, hash], |r| r.get(0))
1499 .optional()?;
1500 #[cfg(test)]
1501 if let Some(observer) = &mut self.after_vector_read {
1502 observer();
1503 }
1504 if old.as_ref() == Some(&bytes) {
1505 continue;
1506 }
1507 overwritten |= old.is_some();
1508 changed = true;
1509 stmt.execute(params![profile, hash, bytes])?;
1510 }
1511 }
1512 let generation = if overwritten {
1513 Some(mark_cleanup(&tx, None)?)
1514 } else if changed {
1515 refresh_pending_activity(&tx)?
1516 } else {
1517 None
1518 };
1519 tx.commit()?;
1520 self.record_write(changed, generation);
1521 Ok(())
1522 }
1523
1524 fn supports_image_codes(&self) -> bool {
1525 true
1526 }
1527
1528 fn sync_image_codes(&mut self, profile: &str, paths: &HashSet<ResourceId>) -> Result<()> {
1529 self.backfill_codes(profile, paths)
1530 }
1531
1532 fn image_scan_rows(
1533 &self,
1534 profile: &str,
1535 paths: &HashSet<ResourceId>,
1536 ) -> Result<Vec<ScanVector>> {
1537 self.scan_rows(profile, paths)
1538 }
1539
1540 fn selected_image_vectors(
1541 &self,
1542 profile: &str,
1543 hashes: &HashSet<String>,
1544 ) -> Result<HashMap<String, Vec<f32>>> {
1545 self.selected_vectors(profile, hashes)
1546 }
1547
1548 fn vectors_for(&self, profile: &str, paths: &HashSet<ResourceId>) -> Result<Vec<StoredVector>> {
1549 let mut stmt = self.conn.prepare_cached(
1550 "SELECT s.path, s.locator, s.heading, s.input_hash, e.vector, s.ordinal
1551 FROM segments s JOIN embeddings e ON e.profile = s.profile AND e.input_hash = s.input_hash
1552 WHERE s.profile = ?1
1553 ORDER BY s.path, s.ordinal",
1554 )?;
1555 let mut rows = stmt.query([profile])?;
1556 let mut out = Vec::new();
1557 while let Some(r) = rows.next()? {
1558 let path = key_resource(r.get(0)?)?;
1559 if !paths.contains(&path) {
1560 continue;
1561 }
1562 let locator: String = r.get(1)?;
1563 out.push(StoredVector {
1564 resource: path,
1565 ordinal: r.get::<_, i64>(5)? as usize,
1566 segment: CachedSegment {
1567 locator: serde_json::from_str(&locator).map_err(json_err)?,
1568 heading: r.get(2)?,
1569 input_hash: r.get(3)?,
1570 },
1571 vector: from_bytes(&r.get::<_, Vec<u8>>(4)?).map_err(|error| {
1572 rusqlite::Error::FromSqlConversionFailure(
1573 4,
1574 rusqlite::types::Type::Blob,
1575 Box::new(error),
1576 )
1577 })?,
1578 });
1579 }
1580 Ok(out)
1581 }
1582
1583 fn lexical_search(
1584 &self,
1585 profile: &str,
1586 query: &LexicalQuery,
1587 paths: &HashSet<ResourceId>,
1588 limit: usize,
1589 ) -> Result<Vec<LexicalHit>> {
1590 let Some(expr) = query.fts5() else {
1591 return Ok(Vec::new());
1592 };
1593 if !self.conn.query_row(
1594 "SELECT EXISTS(SELECT 1 FROM profiles WHERE id = ?1)",
1595 [profile],
1596 |r| r.get::<_, bool>(0),
1597 )? {
1598 return Ok(Vec::new());
1599 }
1600 let table = fts_table(profile);
1601 let weights = FIELDS.map(|(_, w)| w.to_string()).join(", ");
1602 let sql = format!(
1605 "SELECT s.path, s.ordinal, -bm25({table}, {weights})
1606 FROM {table} JOIN segments s ON s.rowid = {table}.rowid
1607 WHERE {table} MATCH ?1 AND s.profile = ?2
1608 ORDER BY bm25({table}, {weights}), s.path, s.ordinal"
1609 );
1610 let mut stmt = self.conn.prepare_cached(&sql)?;
1611 let mut rows = stmt.query(params![expr, profile])?;
1612 let mut out = Vec::new();
1613 while out.len() < limit
1614 && let Some(r) = rows.next()?
1615 {
1616 let path = key_resource(r.get(0)?)?;
1617 if paths.contains(&path) {
1618 out.push(LexicalHit {
1619 resource: path,
1620 ordinal: r.get::<_, i64>(1)? as usize,
1621 score: r.get::<_, f64>(2)? as f32,
1622 });
1623 }
1624 }
1625 Ok(out)
1626 }
1627
1628 fn lexical_matched_terms(
1629 &self,
1630 profile: &str,
1631 query: &LexicalQuery,
1632 keys: &[(ResourceId, usize)],
1633 ) -> Result<Vec<Vec<String>>> {
1634 let mut terms = vec![Vec::new(); keys.len()];
1635 if keys.is_empty() || query.groups.is_empty() {
1636 return Ok(terms);
1637 }
1638 let mut positions = HashMap::new();
1643 for (batch, chunk) in keys.chunks(400).enumerate() {
1644 let values = (0..chunk.len())
1645 .map(|i| format!("(?, ?, {})", batch * 400 + i))
1646 .collect::<Vec<_>>()
1647 .join(",");
1648 let sql = format!(
1649 "WITH requested(path, ordinal, position) AS (VALUES {values})
1650 SELECT s.rowid, r.position FROM requested r
1651 CROSS JOIN segments s ON s.path = r.path AND s.ordinal = r.ordinal
1652 WHERE s.profile = ?"
1653 );
1654 let mut params = Vec::with_capacity(chunk.len() * 2 + 1);
1655 for (path, ordinal) in chunk {
1656 params.push(rusqlite::types::Value::Blob(resource_key(path)));
1657 params.push(rusqlite::types::Value::Integer(*ordinal as i64));
1658 }
1659 params.push(rusqlite::types::Value::Text(profile.to_owned()));
1660 let mut stmt = self.conn.prepare(&sql)?;
1661 let mut rows = stmt.query(rusqlite::params_from_iter(params))?;
1662 while let Some(row) = rows.next()? {
1663 positions
1664 .entry(row.get::<_, i64>(0)?)
1665 .or_insert_with(Vec::new)
1666 .push(row.get::<_, i64>(1)? as usize);
1667 }
1668 }
1669 if positions.is_empty() {
1670 return Ok(terms);
1671 }
1672 let rowids = serde_json::to_string(&positions.keys().collect::<Vec<_>>())
1673 .expect("integer rowids serialize to JSON");
1674 let table = fts_table(profile);
1675 let mut stmt = self.conn.prepare_cached(&format!(
1679 "SELECT rowid FROM {table} WHERE {table} MATCH ?1
1680 AND +rowid IN (SELECT value FROM json_each(?2))"
1681 ))?;
1682 for group in &query.groups {
1683 let expr = LexicalQuery {
1684 groups: vec![group.clone()],
1685 }
1686 .fts5()
1687 .unwrap();
1688 let mut rows = stmt.query(params![expr, rowids])?;
1689 while let Some(row) = rows.next()? {
1690 for &position in &positions[&row.get::<_, i64>(0)?] {
1691 let matched = &mut terms[position];
1692 for term in group {
1693 if !matched.contains(term) {
1694 matched.push(term.clone());
1695 }
1696 }
1697 }
1698 }
1699 }
1700 Ok(terms)
1701 }
1702
1703 fn prune_orphans(&mut self, profile: &str) -> Result<u64> {
1704 let tx = self
1705 .conn
1706 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1707 let n = tx.execute(
1708 "DELETE FROM embeddings WHERE profile = ?1 AND NOT EXISTS (
1709 SELECT 1 FROM segments s WHERE s.profile = embeddings.profile AND s.input_hash = embeddings.input_hash)",
1710 [profile],
1711 )?;
1712 let generation = if n != 0 {
1713 Some(mark_cleanup(&tx, None)?)
1714 } else {
1715 None
1716 };
1717 tx.commit()?;
1718 self.record_write(n != 0, generation);
1719 Ok(n as u64)
1720 }
1721
1722 fn stats(&self) -> Result<CacheStats> {
1723 let mut stmt = self.conn.prepare(
1724 "SELECT p.id, p.profile,
1725 (SELECT COUNT(*) FROM resources r WHERE r.profile = p.id),
1726 (SELECT COUNT(*) FROM segments s WHERE s.profile = p.id),
1727 (SELECT COUNT(*) FROM embeddings e WHERE e.profile = p.id),
1728 (SELECT COUNT(*) FROM image_codes c WHERE c.profile = p.id)
1729 FROM profiles p ORDER BY p.id",
1730 )?;
1731 let profiles = stmt
1732 .query_map([], |r| {
1733 Ok(ProfileStats {
1734 profile_id: r.get(0)?,
1735 profile: serde_json::from_str(&r.get::<_, String>(1)?).ok(),
1736 resources: r.get::<_, i64>(2)? as u64,
1737 segments: r.get::<_, i64>(3)? as u64,
1738 embeddings: r.get::<_, i64>(4)? as u64,
1739 image_codes: r.get::<_, i64>(5)? as u64,
1740 })
1741 })?
1742 .collect::<rusqlite::Result<Vec<_>>>()?;
1743 let size_bytes = ["", "-wal", "-shm"]
1744 .iter()
1745 .filter_map(|s| std::fs::metadata(format!("{}{s}", self.path.display())).ok())
1746 .map(|m| m.len())
1747 .sum();
1748 Ok(CacheStats {
1749 location: Some(self.path.clone()),
1750 size_bytes: Some(size_bytes),
1751 backup_excluded: self.guards.backup_excluded,
1752 in_synced_folder: self.guards.in_synced_folder,
1753 scrub_pending: self.cleanup_pending()?,
1754 profiles,
1755 })
1756 }
1757
1758 fn clear(&mut self) -> Result<()> {
1759 self.conn.flush_prepared_statement_cache();
1760 let tx = self
1761 .conn
1762 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1763 let tables = tx.prepare("SELECT name FROM sqlite_master WHERE name GLOB 'segments_fts_p_*' AND sql LIKE 'CREATE VIRTUAL TABLE%'")?
1765 .query_map([], |r| r.get::<_, String>(0))?
1766 .collect::<rusqlite::Result<Vec<_>>>()?;
1767 for table in tables {
1768 tx.execute_batch(&format!("DROP TABLE \"{}\"", table.replace('"', "\"\"")))?;
1769 }
1770 tx.execute_batch("DELETE FROM embeddings; DELETE FROM segments; DELETE FROM resources; DELETE FROM profiles;")?;
1771 tx.execute("DELETE FROM meta WHERE key GLOB 'fts_scrub_pending:*'", [])?;
1772 tx.execute_batch("INSERT OR IGNORE INTO meta VALUES('compact_pending','1');")?;
1773 mark_cleanup(&tx, None)?;
1774 tx.commit()?;
1775 if self.run_scrub()?.pending {
1776 return Err(Error::CacheCleanupPending);
1777 }
1778 let busy: i64 = self
1780 .conn
1781 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(0))?;
1782 if busy != 0 {
1783 let tx = self
1784 .conn
1785 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1786 mark_cleanup(&tx, None)?;
1787 tx.commit()?;
1788 return Err(Error::CacheCleanupPending);
1789 }
1790 Ok(())
1791 }
1792}
1793
1794#[cfg(all(test, windows))]
1795mod windows_tests {
1796 use super::*;
1797
1798 #[test]
1799 fn file_keys_keep_the_legacy_canonical_drive_prefix() {
1800 let id = ResourceId::from_path(r"C:\notes\chapter.md").unwrap();
1801 let legacy = br"\\?\C:\notes\chapter.md".to_vec();
1802 assert_eq!(resource_key(&id), legacy);
1803 assert_eq!(key_resource(legacy).unwrap(), id);
1804 }
1805}
1806
1807#[cfg(test)]
1808mod guard_tests {
1809 use super::*;
1810 use std::cell::{Cell, RefCell};
1811
1812 #[test]
1813 fn stats_reuses_one_verification_even_after_directory_changes() {
1814 let temp = tempfile::tempdir().unwrap();
1815 let calls = Cell::new(0);
1816 let cache =
1817 SqliteCache::open_with_guards(temp.path().join("cache.sqlite3"), |dir, owned| {
1818 assert!(!owned);
1819 super::super::cache_guards::prepare_with(
1820 dir,
1821 owned,
1822 |_, _| panic!("foreign directory publication"),
1823 |_| {
1824 calls.set(calls.get() + 1);
1825 Some(false)
1826 },
1827 |_| {},
1828 )
1829 })
1830 .unwrap();
1831 std::fs::create_dir(temp.path().join(".stfolder")).unwrap();
1832 for _ in 0..4 {
1833 let stats = cache.stats().unwrap();
1834 assert_eq!(stats.backup_excluded, Some(false));
1835 assert!(!stats.in_synced_folder);
1836 }
1837 assert_eq!(calls.get(), 1);
1838 assert!(!temp.path().join("CACHEDIR.TAG").exists());
1839 }
1840
1841 #[test]
1842 fn unsupported_tag_publication_warns_and_cache_still_opens() {
1843 let temp = tempfile::tempdir().unwrap();
1844 let warnings = RefCell::new(Vec::new());
1845 let path = temp.path().join("dedicated/cache.sqlite3");
1846 let cache = SqliteCache::open_with_guards(path, |dir, owned| {
1847 assert!(owned);
1848 super::super::cache_guards::prepare_with(
1849 dir,
1850 owned,
1851 |_, _| {
1852 Err(std::io::Error::new(
1853 std::io::ErrorKind::Unsupported,
1854 "hard links unsupported",
1855 ))
1856 },
1857 |_| None,
1858 |warning| warnings.borrow_mut().push(warning),
1859 )
1860 })
1861 .unwrap();
1862 assert!(cache.stats().is_ok());
1863 assert!(
1864 warnings
1865 .borrow()
1866 .iter()
1867 .any(|w| w.contains("hard links unsupported"))
1868 );
1869 assert!(!cache.path().parent().unwrap().join("CACHEDIR.TAG").exists());
1870 assert!(
1871 std::fs::read_dir(cache.path().parent().unwrap())
1872 .unwrap()
1873 .flatten()
1874 .all(|e| !e
1875 .file_name()
1876 .to_string_lossy()
1877 .starts_with(".CACHEDIR.TAG-"))
1878 );
1879 }
1880}
1881
1882#[cfg(test)]
1883mod scrub_tests {
1884 use super::*;
1885 use std::time::{Duration, Instant};
1886
1887 #[test]
1888 fn sync_serializes_external_writer_between_vector_read_and_insert() {
1889 use std::sync::{
1890 Arc, Mutex,
1891 atomic::{AtomicUsize, Ordering},
1892 };
1893
1894 struct SyntheticEmbedder(crate::ModelIdentity);
1895 impl crate::Embedder for SyntheticEmbedder {
1896 fn identity(&self) -> &crate::ModelIdentity {
1897 &self.0
1898 }
1899 fn supports(&self, modality: Modality) -> bool {
1900 modality == Modality::Text
1901 }
1902 fn embed_batch(
1903 &self,
1904 requests: &[seyk_embed::EmbeddingRequest<'_>],
1905 ) -> seyk_embed::Result<Vec<crate::Embedding>> {
1906 requests
1907 .iter()
1908 .map(|_| crate::Embedding::from_raw(vec![1.0; 128], crate::Dimension::D128))
1909 .collect()
1910 }
1911 }
1912 let temp = tempfile::tempdir().unwrap();
1913 let path = temp.path().join("cache.sqlite3");
1914 let file = temp.path().join("synthetic.txt");
1915 std::fs::write(&file, "synthetic alpha garden words").unwrap();
1916 let mut cache = SqliteCache::open(&path).unwrap();
1917 let writer = Arc::new(Mutex::new(Connection::open(&path).unwrap()));
1918 writer.lock().unwrap().busy_timeout(Duration::ZERO).unwrap();
1919
1920 let tx = cache.conn.transaction().unwrap();
1923 tx.query_row("SELECT count(*) FROM embeddings", [], |r| {
1924 r.get::<_, i64>(0)
1925 })
1926 .unwrap();
1927 writer
1928 .lock()
1929 .unwrap()
1930 .execute(
1931 "INSERT INTO meta(key,value) VALUES('interleaving_control','committed')",
1932 [],
1933 )
1934 .unwrap();
1935 let error = tx
1936 .execute(
1937 "INSERT INTO meta(key,value) VALUES('stale_snapshot','write')",
1938 [],
1939 )
1940 .unwrap_err();
1941 match error {
1942 rusqlite::Error::SqliteFailure(code, _) => {
1943 assert_eq!(code.extended_code, rusqlite::ffi::SQLITE_BUSY_SNAPSHOT)
1944 }
1945 other => panic!("expected BUSY_SNAPSHOT, got {other}"),
1946 }
1947 tx.rollback().unwrap();
1948
1949 let attempts = Arc::new(AtomicUsize::new(0));
1952 let observed = attempts.clone();
1953 let external = writer.clone();
1954 cache.after_vector_read = Some(Box::new(move || {
1955 let error = external
1956 .lock()
1957 .unwrap()
1958 .execute(
1959 "INSERT INTO meta(key,value) VALUES('external_writer','committed')",
1960 [],
1961 )
1962 .unwrap_err();
1963 assert!(sqlite_busy(&error));
1964 observed.fetch_add(1, Ordering::SeqCst);
1965 }));
1966 let engine = crate::SearchEngine::builder()
1967 .embedder(SyntheticEmbedder(crate::ModelIdentity {
1968 model_id: "test/interleaving".into(),
1969 revision: "1".into(),
1970 variant: "synthetic".into(),
1971 dimension: crate::Dimension::D128,
1972 normalization: "l2".into(),
1973 query_prompt: String::new(),
1974 document_prompt: String::new(),
1975 max_tokens: 512,
1976 }))
1977 .cache(cache)
1978 .build()
1979 .unwrap();
1980 let result = engine.index().paths([&file]).execute().unwrap();
1981 assert_eq!(result.sync.resources_updated, 1);
1982 assert!(attempts.load(Ordering::SeqCst) > 0);
1983 writer
1985 .lock()
1986 .unwrap()
1987 .execute(
1988 "INSERT INTO meta(key,value) VALUES('external_writer','committed')",
1989 [],
1990 )
1991 .unwrap();
1992 assert_eq!(
1993 engine.with_cache(|c| c.stats()).unwrap().profiles[0].embeddings,
1994 1
1995 );
1996 }
1997
1998 #[test]
1999 fn scrub_scheduler_uses_quiet_and_retry_deadlines_without_busy_polling() {
2000 let temp = tempfile::tempdir().unwrap();
2001 let mut cache = SqliteCache::open(temp.path().join("cache.sqlite3")).unwrap();
2002 let quiet = Duration::from_secs(60);
2003 cache.last_change = Instant::now() - Duration::from_secs(40);
2004 let delay = cache.scrub_delay(quiet);
2005 assert!(delay > Duration::from_secs(19) && delay <= Duration::from_secs(20));
2006 cache.last_attempt = Some(Instant::now() - Duration::from_secs(5));
2007 let retry = cache.scrub_delay(quiet);
2008 assert!(retry > Duration::from_secs(54) && retry <= Duration::from_secs(55));
2009 cache.last_change = Instant::now() - Duration::from_secs(61);
2010 cache.last_attempt = None;
2011 assert_eq!(
2012 cache.scrub_delay(quiet),
2013 quiet,
2014 "idle worker checks once per minute"
2015 );
2016 assert_eq!(MemoryCache::new().scrub_delay(quiet), quiet);
2017 }
2018
2019 #[test]
2020 fn local_writes_keep_their_deadline_but_external_writes_restart_it() {
2021 let temp = tempfile::tempdir().unwrap();
2022 let path = temp.path().join("cache.sqlite3");
2023 let mut cache = SqliteCache::open(&path).unwrap();
2024 let profile = CacheProfile {
2025 model: ModelIdentity {
2026 model_id: "test/deadline".into(),
2027 revision: "1".into(),
2028 variant: "synthetic".into(),
2029 dimension: crate::Dimension::D128,
2030 normalization: "l2".into(),
2031 query_prompt: String::new(),
2032 document_prompt: String::new(),
2033 max_tokens: 512,
2034 },
2035 segmenter_version: "test".into(),
2036 };
2037 let pid = profile.id();
2038 cache.register_profile(&pid, &profile).unwrap();
2039 cache
2040 .put_embeddings(&pid, &[("input".into(), vec![1.0; 128])])
2041 .unwrap();
2042 cache
2043 .put_embeddings(&pid, &[("input".into(), vec![2.0; 128])])
2044 .unwrap();
2045 assert!(cache.observed_generation.is_some());
2046 let quiet = Duration::from_secs(60);
2047 cache.last_change = Instant::now() - Duration::from_secs(61);
2048 assert!(
2049 !cache.scrub_if_idle(quiet).unwrap().unwrap().pending,
2050 "own marker must not add a second quiet period at the first wake"
2051 );
2052 cache
2053 .put_embeddings(&pid, &[("input".into(), vec![3.0; 128])])
2054 .unwrap();
2055 cache
2057 .put_embeddings(&pid, &[("new-input".into(), vec![4.0; 128])])
2058 .unwrap();
2059 let mut writer = Connection::open(&path).unwrap();
2060 let tx = writer
2061 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
2062 .unwrap();
2063 refresh_pending_activity(&tx).unwrap();
2064 tx.commit().unwrap();
2065 cache.last_change = Instant::now() - Duration::from_secs(61);
2066 cache.last_attempt = None;
2067 assert!(cache.scrub_if_idle(quiet).unwrap().is_none());
2068 assert!(cache.last_change.elapsed() < Duration::from_secs(1));
2069 let reopened = SqliteCache::open(&path).unwrap();
2070 assert_eq!(
2071 reopened.observed_generation, cache.observed_generation,
2072 "open observes pending generation before starting its quiet period"
2073 );
2074 }
2075
2076 #[test]
2077 fn idle_scrub_observes_other_writers_and_throttles_busy_retries() {
2078 let temp = tempfile::tempdir().unwrap();
2079 let path = temp.path().join("cache.sqlite3");
2080 let mut cache = SqliteCache::open(&path).unwrap();
2081 let tx = cache.conn.transaction().unwrap();
2082 mark_cleanup(&tx, None).unwrap();
2083 tx.commit().unwrap();
2084 let quiet = Duration::from_secs(60);
2085 assert!(cache.scrub_if_idle(quiet).unwrap().is_none());
2086 cache.last_change = Instant::now() - Duration::from_secs(61);
2087 let mut writer = Connection::open(&path).unwrap();
2089 let tx = writer.transaction().unwrap();
2090 refresh_pending_activity(&tx).unwrap();
2091 tx.commit().unwrap();
2092 assert!(cache.scrub_if_idle(quiet).unwrap().is_none());
2093 assert!(cache.last_change.elapsed() < quiet);
2094 let reader = Connection::open(&path).unwrap();
2095 reader.execute_batch("BEGIN; SELECT * FROM meta;").unwrap();
2096 cache.last_change = Instant::now() - Duration::from_secs(61);
2097 assert!(cache.scrub_if_idle(quiet).unwrap().unwrap().pending);
2098 assert!(
2099 cache.scrub_if_idle(quiet).unwrap().is_none(),
2100 "do not optimize again on every tick with a busy reader"
2101 );
2102 assert!(cache.cleanup_pending().unwrap());
2103 reader.execute_batch("ROLLBACK").unwrap();
2104 cache.last_attempt = Some(Instant::now() - Duration::from_secs(61));
2105 assert!(!cache.scrub_if_idle(quiet).unwrap().unwrap().pending);
2106 assert!(!cache.cleanup_pending().unwrap());
2107 }
2108}