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. The same file paces them: [`Pacing`] hands out
19//! request slots per provider across every process using it.
20
21use std::path::Path;
22use std::sync::Mutex;
23use std::time::Duration;
24
25use aspero_core::{Page, ProviderId, SearchQuery, SourceRecord, WorkId};
26use jiff::Timestamp;
27use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
28
29/// Bump on any schema change, *and* when cached records' shape or mapping
30/// changes (records are stored as mapped); older caches are dropped and
31/// rebuilt. 2: records gained `updates` (retractions). 3: `other_years`.
32/// 4: Crossref and Europe PMC map print/online years into `other_years`.
33/// 5: `update_to` (what a notice updates). 6: `open_access`. 7: Crossref
34/// relations map to `new_version` updates. 8: notices are typed as such.
35const SCHEMA_VERSION: i64 = 8;
36
37const SCHEMA: &str = "
38CREATE TABLE record (
39    provider   TEXT NOT NULL,
40    native_id  TEXT NOT NULL,
41    record     TEXT NOT NULL,      -- SourceRecord as JSON, raw response included
42    stored_at  INTEGER NOT NULL,   -- unix seconds
43    PRIMARY KEY (provider, native_id)
44);
45-- Every identifier (asserted or requested) that leads to a record.
46CREATE TABLE record_id (
47    provider   TEXT NOT NULL,
48    work_id    TEXT NOT NULL,
49    native_id  TEXT NOT NULL,
50    PRIMARY KEY (provider, work_id)
51);
52CREATE TABLE miss (
53    provider   TEXT NOT NULL,
54    work_id    TEXT NOT NULL,
55    stored_at  INTEGER NOT NULL,
56    PRIMARY KEY (provider, work_id)
57);
58CREATE TABLE search (
59    provider   TEXT NOT NULL,
60    query      TEXT NOT NULL,      -- SearchQuery as JSON
61    page       TEXT NOT NULL,      -- Page as JSON
62    stored_at  INTEGER NOT NULL,
63    PRIMARY KEY (provider, query)
64);
65";
66
67/// Pacing state, kept apart from the versioned schema: rebuilds keep it,
68/// since processes still running depend on it.
69const PACING: &str = "
70CREATE TABLE IF NOT EXISTS pacing (
71    provider   TEXT PRIMARY KEY,
72    next_slot  INTEGER NOT NULL    -- unix milliseconds
73);
74";
75
76/// A claimed slot further ahead than this means the clock went back (or a
77/// process misbehaved): start afresh rather than wait.
78const MAX_AHEAD: Duration = Duration::from_secs(300);
79
80#[derive(Debug, thiserror::Error)]
81#[non_exhaustive]
82pub enum StoreError {
83    #[error("cache database error: {0}")]
84    Sqlite(#[from] rusqlite::Error),
85    #[error("cache entry is corrupt: {0}")]
86    Corrupt(#[from] serde_json::Error),
87    #[error("could not create cache directory: {0}")]
88    Io(#[from] std::io::Error),
89}
90
91pub type Result<T, E = StoreError> = std::result::Result<T, E>;
92
93/// A stored value and when it was stored.
94#[derive(Debug, Clone, PartialEq)]
95pub struct Entry<T> {
96    pub value: T,
97    pub stored_at: Timestamp,
98}
99
100impl<T> Entry<T> {
101    /// Whether the entry is at most `ttl` old at `now`.
102    pub fn is_fresh(&self, now: Timestamp, ttl: Duration) -> bool {
103        now.duration_since(self.stored_at).unsigned_abs() <= ttl
104    }
105}
106
107/// A provider's cached answer for one ID: `Some(record)`, or `None` for a
108/// cached "not found".
109pub type CachedLookup = Entry<Option<SourceRecord>>;
110
111/// One ID's stored rows: its record (JSON, stored_at) and its miss
112/// (stored_at).
113type LookupRow = (Option<(String, i64)>, Option<i64>);
114
115/// What the engine needs from a cache; it depends on this, not on SQLite.
116pub trait Cache: Send + Sync {
117    /// The cached answer for each of `ids` at `provider`, in order; `None`
118    /// where nothing is cached.
119    fn get_lookups(
120        &self,
121        provider: &ProviderId,
122        ids: &[WorkId],
123    ) -> Result<Vec<Option<CachedLookup>>>;
124
125    /// Stores `provider`'s answers in one transaction. A found record is
126    /// also reachable by every identifier it asserts.
127    fn put_lookups(
128        &self,
129        provider: &ProviderId,
130        answers: &[(&WorkId, Option<&SourceRecord>)],
131        at: Timestamp,
132    ) -> Result<()>;
133
134    fn get_search(&self, provider: &ProviderId, query: &SearchQuery)
135    -> Result<Option<Entry<Page>>>;
136
137    fn put_search(
138        &self,
139        provider: &ProviderId,
140        query: &SearchQuery,
141        page: &Page,
142        at: Timestamp,
143    ) -> Result<()>;
144}
145
146/// Request slots shared between processes, so parallel agents (and any
147/// other process using the same file) keep to one rate limit per provider.
148pub trait Pacing: Send + Sync {
149    /// Claims the next start time for a request to `provider`: `now` if
150    /// free, else `interval` after the latest slot anyone claimed.
151    fn claim_slot(&self, provider: &str, interval: Duration, now: Timestamp) -> Result<Timestamp>;
152}
153
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
155#[non_exhaustive]
156pub struct Stats {
157    pub records: u64,
158    pub identifiers: u64,
159    pub misses: u64,
160    pub searches: u64,
161}
162
163pub struct SqliteCache {
164    conn: Mutex<Connection>,
165}
166
167impl SqliteCache {
168    /// Opens (creating if needed) the cache at `path`. A file that isn't a
169    /// readable SQLite database is deleted and started afresh.
170    pub fn open(path: &Path) -> Result<Self> {
171        if let Some(dir) = path.parent() {
172            std::fs::create_dir_all(dir)?;
173        }
174        match Connection::open(path)
175            .map_err(StoreError::from)
176            .and_then(Self::init)
177        {
178            Err(StoreError::Sqlite(e)) if is_unreadable(&e) => {
179                for suffix in ["", "-wal", "-shm"] {
180                    let mut file = path.as_os_str().to_owned();
181                    file.push(suffix);
182                    match std::fs::remove_file(&file) {
183                        Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
184                        _ => {}
185                    }
186                }
187                Self::init(Connection::open(path)?)
188            }
189            opened => opened,
190        }
191    }
192
193    pub fn open_in_memory() -> Result<Self> {
194        Self::init(Connection::open_in_memory()?)
195    }
196
197    fn init(mut conn: Connection) -> Result<Self> {
198        conn.busy_timeout(Duration::from_secs(5))?;
199        conn.pragma_update(None, "journal_mode", "WAL")?;
200        let version = |c: &Connection| -> rusqlite::Result<i64> {
201            c.pragma_query_value(None, "user_version", |r| r.get(0))
202        };
203        if version(&conn)? != SCHEMA_VERSION {
204            // Take the write lock, then check again: another process may
205            // have rebuilt it meanwhile.
206            let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
207            if version(&tx)? != SCHEMA_VERSION {
208                // Disposable: drop whatever is there (older or newer) and rebuild.
209                let tables: Vec<String> = tx
210                    .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'pacing'")?
211                    .query_map([], |r| r.get(0))?
212                    .collect::<rusqlite::Result<_>>()?;
213                for table in tables {
214                    tx.execute_batch(&format!("DROP TABLE \"{}\";", table.replace('"', "\"\"")))?;
215                }
216                tx.execute_batch(SCHEMA)?;
217                tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
218            }
219            tx.commit()?;
220        }
221        conn.execute_batch(PACING)?;
222        Ok(SqliteCache {
223            conn: Mutex::new(conn),
224        })
225    }
226
227    fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
228        // A panic while holding the lock leaves SQLite consistent (writes
229        // are transactional), so poisoning is ignorable.
230        self.conn.lock().unwrap_or_else(|e| e.into_inner())
231    }
232
233    /// The stored rows for each of `ids`, unparsed.
234    fn lookup_rows(&self, provider: &ProviderId, ids: &[WorkId]) -> Result<Vec<LookupRow>> {
235        let mut conn = self.conn();
236        // One read transaction: a consistent snapshot, one lock.
237        let tx = conn.transaction()?;
238        let mut found = tx.prepare_cached(
239            "SELECT r.record, r.stored_at FROM record_id i
240             JOIN record r ON r.provider = i.provider AND r.native_id = i.native_id
241             WHERE i.provider = ?1 AND i.work_id = ?2",
242        )?;
243        let mut missed =
244            tx.prepare_cached("SELECT stored_at FROM miss WHERE provider = ?1 AND work_id = ?2")?;
245        let mut rows = Vec::with_capacity(ids.len());
246        for id in ids {
247            let key = params![provider.as_str(), id.to_string()];
248            let record = found
249                .query_row(key, |r| Ok((r.get(0)?, r.get(1)?)))
250                .optional()?;
251            let miss = missed.query_row(key, |r| r.get(0)).optional()?;
252            rows.push((record, miss));
253        }
254        Ok(rows)
255    }
256
257    pub fn stats(&self) -> Result<Stats> {
258        let conn = self.conn();
259        let count = |table: &str| -> Result<u64> {
260            let n: i64 =
261                conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?;
262            Ok(n as u64)
263        };
264        Ok(Stats {
265            records: count("record")?,
266            identifiers: count("record_id")?,
267            misses: count("miss")?,
268            searches: count("search")?,
269        })
270    }
271
272    /// Empties the cache.
273    pub fn clear(&self) -> Result<()> {
274        self.conn().execute_batch(
275            "DELETE FROM record; DELETE FROM record_id; DELETE FROM miss; DELETE FROM search; VACUUM;",
276        )?;
277        Ok(())
278    }
279}
280
281/// Whether `e` says the file isn't a database, or is a damaged one.
282fn is_unreadable(e: &rusqlite::Error) -> bool {
283    matches!(
284        e.sqlite_error_code(),
285        Some(rusqlite::ErrorCode::NotADatabase | rusqlite::ErrorCode::DatabaseCorrupt)
286    )
287}
288
289fn timestamp(seconds: i64) -> Timestamp {
290    Timestamp::from_second(seconds).unwrap_or(Timestamp::UNIX_EPOCH)
291}
292
293impl Pacing for SqliteCache {
294    fn claim_slot(&self, provider: &str, interval: Duration, now: Timestamp) -> Result<Timestamp> {
295        let mut conn = self.conn();
296        // Take the write lock up front, so claims never interleave.
297        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
298        let now_ms = now.as_millisecond();
299        let next: Option<i64> = tx
300            .query_row(
301                "SELECT next_slot FROM pacing WHERE provider = ?1",
302                params![provider],
303                |r| r.get(0),
304            )
305            .optional()?;
306        let ahead = MAX_AHEAD.as_millis() as i64;
307        let slot = next
308            .filter(|n| *n - now_ms <= ahead)
309            .map_or(now_ms, |n| n.max(now_ms));
310        tx.execute(
311            "INSERT INTO pacing (provider, next_slot) VALUES (?1, ?2)
312             ON CONFLICT (provider) DO UPDATE SET next_slot = excluded.next_slot",
313            params![provider, slot + interval.as_millis() as i64],
314        )?;
315        tx.commit()?;
316        Ok(Timestamp::from_millisecond(slot).unwrap_or(now))
317    }
318}
319
320impl Cache for SqliteCache {
321    fn get_lookups(
322        &self,
323        provider: &ProviderId,
324        ids: &[WorkId],
325    ) -> Result<Vec<Option<CachedLookup>>> {
326        let rows = self.lookup_rows(provider, ids)?;
327        // Parsed after the lock is released.
328        let mut out = Vec::with_capacity(ids.len());
329        for (record, miss) in rows {
330            let not_found = |at: i64| Entry {
331                value: None,
332                stored_at: timestamp(at),
333            };
334            // The newer answer wins if both exist.
335            out.push(match (record, miss) {
336                (Some((_, found_at)), Some(missed_at)) if missed_at > found_at => {
337                    Some(not_found(missed_at))
338                }
339                (Some((json, at)), _) => Some(Entry {
340                    value: Some(serde_json::from_str(&json)?),
341                    stored_at: timestamp(at),
342                }),
343                (None, Some(at)) => Some(not_found(at)),
344                (None, None) => None,
345            });
346        }
347        Ok(out)
348    }
349
350    fn put_lookups(
351        &self,
352        provider: &ProviderId,
353        answers: &[(&WorkId, Option<&SourceRecord>)],
354        at: Timestamp,
355    ) -> Result<()> {
356        let mut conn = self.conn();
357        let tx = conn.transaction()?;
358        {
359            let (p, at) = (provider.as_str(), at.as_second());
360            let mut put_record = tx.prepare_cached(
361                "INSERT OR REPLACE INTO record (provider, native_id, record, stored_at) VALUES (?1, ?2, ?3, ?4)",
362            )?;
363            let mut put_id = tx.prepare_cached(
364                "INSERT OR REPLACE INTO record_id (provider, work_id, native_id) VALUES (?1, ?2, ?3)",
365            )?;
366            let mut clear_miss =
367                tx.prepare_cached("DELETE FROM miss WHERE provider = ?1 AND work_id = ?2")?;
368            let mut put_miss = tx.prepare_cached(
369                "INSERT OR REPLACE INTO miss (provider, work_id, stored_at) VALUES (?1, ?2, ?3)",
370            )?;
371
372            for (requested, record) in answers {
373                match record {
374                    Some(record) => {
375                        // A record without any identifier can't be found again.
376                        let Some(native) = record.native_id().map(ToString::to_string) else {
377                            continue;
378                        };
379                        put_record.execute(params![
380                            p,
381                            native,
382                            serde_json::to_string(record)?,
383                            at
384                        ])?;
385                        let ids = record
386                            .ids
387                            .iter()
388                            .map(ToString::to_string)
389                            .chain([requested.to_string()]);
390                        for work_id in ids {
391                            put_id.execute(params![p, work_id, native])?;
392                            clear_miss.execute(params![p, work_id])?;
393                        }
394                    }
395                    None => {
396                        put_miss.execute(params![p, requested.to_string(), at])?;
397                    }
398                }
399            }
400        }
401        tx.commit()?;
402        Ok(())
403    }
404
405    fn get_search(
406        &self,
407        provider: &ProviderId,
408        query: &SearchQuery,
409    ) -> Result<Option<Entry<Page>>> {
410        let row: Option<(String, i64)> = self
411            .conn()
412            .query_row(
413                "SELECT page, stored_at FROM search WHERE provider = ?1 AND query = ?2",
414                params![provider.as_str(), serde_json::to_string(query)?],
415                |r| Ok((r.get(0)?, r.get(1)?)),
416            )
417            .optional()?;
418        row.map(|(json, at)| {
419            Ok(Entry {
420                value: serde_json::from_str(&json)?,
421                stored_at: timestamp(at),
422            })
423        })
424        .transpose()
425    }
426
427    fn put_search(
428        &self,
429        provider: &ProviderId,
430        query: &SearchQuery,
431        page: &Page,
432        at: Timestamp,
433    ) -> Result<()> {
434        self.conn().execute(
435            "INSERT OR REPLACE INTO search (provider, query, page, stored_at) VALUES (?1, ?2, ?3, ?4)",
436            params![
437                provider.as_str(),
438                serde_json::to_string(query)?,
439                serde_json::to_string(page)?,
440                at.as_second()
441            ],
442        )?;
443        Ok(())
444    }
445}