Skip to main content

aspero_store/
lib.rs

1//! SQLite storage.
2//!
3//! [`SqliteCache`] is the **disposable** cache of provider answers: records,
4//! "not found" answers and search pages. Everything in it can be fetched
5//! again, so it lives in the platform cache directory (which the OS may
6//! wipe) and has no migrations: a schema mismatch simply rebuilds it.
7//!
8//! Durable data that is costly to recompute (history; derived data such as
9//! embeddings or extracted text) will live in a separate store in the data
10//! directory, with real migrations (DESIGN ยง5).
11//!
12//! The cache keeps entries and when they were stored; it applies no policy.
13//! Freshness, field coverage and stale-on-error are the engine's decisions.
14//! The API is batch-first: lookups of many IDs are one read, and storing
15//! many answers is one transaction (one disk sync, not one per ID).
16//!
17//! SQLite in WAL mode with a busy timeout, so several agent processes can
18//! share one cache file.
19
20use std::path::Path;
21use std::sync::Mutex;
22use std::time::Duration;
23
24use aspero_core::{Page, ProviderId, SearchQuery, SourceRecord, WorkId};
25use jiff::Timestamp;
26use rusqlite::{Connection, OptionalExtension, params};
27
28/// Bump on any schema change, *and* when cached records' shape or mapping
29/// changes (records are stored as mapped); older caches are dropped and
30/// rebuilt. 2: records gained `updates` (retractions). 3: `other_years`.
31const SCHEMA_VERSION: i64 = 3;
32
33const SCHEMA: &str = "
34CREATE TABLE record (
35    provider   TEXT NOT NULL,
36    native_id  TEXT NOT NULL,
37    record     TEXT NOT NULL,      -- SourceRecord as JSON, raw response included
38    stored_at  INTEGER NOT NULL,   -- unix seconds
39    PRIMARY KEY (provider, native_id)
40);
41-- Every identifier (asserted or requested) that leads to a record.
42CREATE TABLE record_id (
43    provider   TEXT NOT NULL,
44    work_id    TEXT NOT NULL,
45    native_id  TEXT NOT NULL,
46    PRIMARY KEY (provider, work_id)
47);
48CREATE TABLE miss (
49    provider   TEXT NOT NULL,
50    work_id    TEXT NOT NULL,
51    stored_at  INTEGER NOT NULL,
52    PRIMARY KEY (provider, work_id)
53);
54CREATE TABLE search (
55    provider   TEXT NOT NULL,
56    query      TEXT NOT NULL,      -- SearchQuery as JSON
57    page       TEXT NOT NULL,      -- Page as JSON
58    stored_at  INTEGER NOT NULL,
59    PRIMARY KEY (provider, query)
60);
61";
62
63#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum StoreError {
66    #[error("cache database error: {0}")]
67    Sqlite(#[from] rusqlite::Error),
68    #[error("cache entry is corrupt: {0}")]
69    Corrupt(#[from] serde_json::Error),
70    #[error("could not create cache directory: {0}")]
71    Io(#[from] std::io::Error),
72}
73
74pub type Result<T, E = StoreError> = std::result::Result<T, E>;
75
76/// A stored value and when it was stored.
77#[derive(Debug, Clone, PartialEq)]
78pub struct Entry<T> {
79    pub value: T,
80    pub stored_at: Timestamp,
81}
82
83impl<T> Entry<T> {
84    /// Whether the entry is at most `ttl` old at `now`.
85    pub fn is_fresh(&self, now: Timestamp, ttl: Duration) -> bool {
86        now.duration_since(self.stored_at).unsigned_abs() <= ttl
87    }
88}
89
90/// A provider's cached answer for one ID: `Some(record)`, or `None` for a
91/// cached "not found".
92pub type CachedLookup = Entry<Option<SourceRecord>>;
93
94/// What the engine needs from a cache; it depends on this, not on SQLite.
95pub trait Cache: Send + Sync {
96    /// The cached answer for each of `ids` at `provider`, in order; `None`
97    /// where nothing is cached.
98    fn get_lookups(
99        &self,
100        provider: &ProviderId,
101        ids: &[WorkId],
102    ) -> Result<Vec<Option<CachedLookup>>>;
103
104    /// Stores `provider`'s answers in one transaction. A found record is
105    /// also reachable by every identifier it asserts.
106    fn put_lookups(
107        &self,
108        provider: &ProviderId,
109        answers: &[(&WorkId, Option<&SourceRecord>)],
110        at: Timestamp,
111    ) -> Result<()>;
112
113    fn get_search(&self, provider: &ProviderId, query: &SearchQuery)
114    -> Result<Option<Entry<Page>>>;
115
116    fn put_search(
117        &self,
118        provider: &ProviderId,
119        query: &SearchQuery,
120        page: &Page,
121        at: Timestamp,
122    ) -> Result<()>;
123}
124
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
126#[non_exhaustive]
127pub struct Stats {
128    pub records: u64,
129    pub identifiers: u64,
130    pub misses: u64,
131    pub searches: u64,
132}
133
134pub struct SqliteCache {
135    conn: Mutex<Connection>,
136}
137
138impl SqliteCache {
139    /// Opens (creating if needed) the cache at `path`.
140    pub fn open(path: &Path) -> Result<Self> {
141        if let Some(dir) = path.parent() {
142            std::fs::create_dir_all(dir)?;
143        }
144        Self::init(Connection::open(path)?)
145    }
146
147    pub fn open_in_memory() -> Result<Self> {
148        Self::init(Connection::open_in_memory()?)
149    }
150
151    fn init(mut conn: Connection) -> Result<Self> {
152        conn.busy_timeout(Duration::from_secs(5))?;
153        conn.pragma_update(None, "journal_mode", "WAL")?;
154        let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
155        if version != SCHEMA_VERSION {
156            // Disposable: drop whatever is there (older or newer) and rebuild.
157            let tx = conn.transaction()?;
158            let tables: Vec<String> = tx
159                .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")?
160                .query_map([], |r| r.get(0))?
161                .collect::<rusqlite::Result<_>>()?;
162            for table in tables {
163                tx.execute_batch(&format!("DROP TABLE \"{}\";", table.replace('"', "\"\"")))?;
164            }
165            tx.execute_batch(SCHEMA)?;
166            tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
167            tx.commit()?;
168        }
169        Ok(SqliteCache {
170            conn: Mutex::new(conn),
171        })
172    }
173
174    fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
175        // A panic while holding the lock leaves SQLite consistent (writes
176        // are transactional), so poisoning is ignorable.
177        self.conn.lock().unwrap_or_else(|e| e.into_inner())
178    }
179
180    pub fn stats(&self) -> Result<Stats> {
181        let conn = self.conn();
182        let count = |table: &str| -> Result<u64> {
183            let n: i64 =
184                conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?;
185            Ok(n as u64)
186        };
187        Ok(Stats {
188            records: count("record")?,
189            identifiers: count("record_id")?,
190            misses: count("miss")?,
191            searches: count("search")?,
192        })
193    }
194
195    /// Empties the cache.
196    pub fn clear(&self) -> Result<()> {
197        self.conn().execute_batch(
198            "DELETE FROM record; DELETE FROM record_id; DELETE FROM miss; DELETE FROM search; VACUUM;",
199        )?;
200        Ok(())
201    }
202}
203
204fn timestamp(seconds: i64) -> Timestamp {
205    Timestamp::from_second(seconds).unwrap_or(Timestamp::UNIX_EPOCH)
206}
207
208impl Cache for SqliteCache {
209    fn get_lookups(
210        &self,
211        provider: &ProviderId,
212        ids: &[WorkId],
213    ) -> Result<Vec<Option<CachedLookup>>> {
214        let mut conn = self.conn();
215        // One read transaction: a consistent snapshot, one lock.
216        let tx = conn.transaction()?;
217        let mut found = tx.prepare_cached(
218            "SELECT r.record, r.stored_at FROM record_id i
219             JOIN record r ON r.provider = i.provider AND r.native_id = i.native_id
220             WHERE i.provider = ?1 AND i.work_id = ?2",
221        )?;
222        let mut missed =
223            tx.prepare_cached("SELECT stored_at FROM miss WHERE provider = ?1 AND work_id = ?2")?;
224
225        let mut out = Vec::with_capacity(ids.len());
226        for id in ids {
227            let key = params![provider.as_str(), id.to_string()];
228            let record: Option<(String, i64)> = found
229                .query_row(key, |r| Ok((r.get(0)?, r.get(1)?)))
230                .optional()?;
231            let miss: Option<i64> = missed.query_row(key, |r| r.get(0)).optional()?;
232            let not_found = |at: i64| Entry {
233                value: None,
234                stored_at: timestamp(at),
235            };
236            // The newer answer wins if both exist.
237            out.push(match (record, miss) {
238                (Some((_, found_at)), Some(missed_at)) if missed_at > found_at => {
239                    Some(not_found(missed_at))
240                }
241                (Some((json, at)), _) => Some(Entry {
242                    value: Some(serde_json::from_str(&json)?),
243                    stored_at: timestamp(at),
244                }),
245                (None, Some(at)) => Some(not_found(at)),
246                (None, None) => None,
247            });
248        }
249        Ok(out)
250    }
251
252    fn put_lookups(
253        &self,
254        provider: &ProviderId,
255        answers: &[(&WorkId, Option<&SourceRecord>)],
256        at: Timestamp,
257    ) -> Result<()> {
258        let mut conn = self.conn();
259        let tx = conn.transaction()?;
260        {
261            let (p, at) = (provider.as_str(), at.as_second());
262            let mut put_record = tx.prepare_cached(
263                "INSERT OR REPLACE INTO record (provider, native_id, record, stored_at) VALUES (?1, ?2, ?3, ?4)",
264            )?;
265            let mut put_id = tx.prepare_cached(
266                "INSERT OR REPLACE INTO record_id (provider, work_id, native_id) VALUES (?1, ?2, ?3)",
267            )?;
268            let mut clear_miss =
269                tx.prepare_cached("DELETE FROM miss WHERE provider = ?1 AND work_id = ?2")?;
270            let mut put_miss = tx.prepare_cached(
271                "INSERT OR REPLACE INTO miss (provider, work_id, stored_at) VALUES (?1, ?2, ?3)",
272            )?;
273
274            for (requested, record) in answers {
275                match record {
276                    Some(record) => {
277                        // A record without any identifier can't be found again.
278                        let Some(native) = record.native_id().map(ToString::to_string) else {
279                            continue;
280                        };
281                        put_record.execute(params![
282                            p,
283                            native,
284                            serde_json::to_string(record)?,
285                            at
286                        ])?;
287                        let ids = record
288                            .ids
289                            .iter()
290                            .map(ToString::to_string)
291                            .chain([requested.to_string()]);
292                        for work_id in ids {
293                            put_id.execute(params![p, work_id, native])?;
294                            clear_miss.execute(params![p, work_id])?;
295                        }
296                    }
297                    None => {
298                        put_miss.execute(params![p, requested.to_string(), at])?;
299                    }
300                }
301            }
302        }
303        tx.commit()?;
304        Ok(())
305    }
306
307    fn get_search(
308        &self,
309        provider: &ProviderId,
310        query: &SearchQuery,
311    ) -> Result<Option<Entry<Page>>> {
312        let row: Option<(String, i64)> = self
313            .conn()
314            .query_row(
315                "SELECT page, stored_at FROM search WHERE provider = ?1 AND query = ?2",
316                params![provider.as_str(), serde_json::to_string(query)?],
317                |r| Ok((r.get(0)?, r.get(1)?)),
318            )
319            .optional()?;
320        row.map(|(json, at)| {
321            Ok(Entry {
322                value: serde_json::from_str(&json)?,
323                stored_at: timestamp(at),
324            })
325        })
326        .transpose()
327    }
328
329    fn put_search(
330        &self,
331        provider: &ProviderId,
332        query: &SearchQuery,
333        page: &Page,
334        at: Timestamp,
335    ) -> Result<()> {
336        self.conn().execute(
337            "INSERT OR REPLACE INTO search (provider, query, page, stored_at) VALUES (?1, ?2, ?3, ?4)",
338            params![
339                provider.as_str(),
340                serde_json::to_string(query)?,
341                serde_json::to_string(page)?,
342                at.as_second()
343            ],
344        )?;
345        Ok(())
346    }
347}