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, 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/// What the engine needs from a cache; it depends on this, not on SQLite.
112pub trait Cache: Send + Sync {
113    /// The cached answer for each of `ids` at `provider`, in order; `None`
114    /// where nothing is cached.
115    fn get_lookups(
116        &self,
117        provider: &ProviderId,
118        ids: &[WorkId],
119    ) -> Result<Vec<Option<CachedLookup>>>;
120
121    /// Stores `provider`'s answers in one transaction. A found record is
122    /// also reachable by every identifier it asserts.
123    fn put_lookups(
124        &self,
125        provider: &ProviderId,
126        answers: &[(&WorkId, Option<&SourceRecord>)],
127        at: Timestamp,
128    ) -> Result<()>;
129
130    fn get_search(&self, provider: &ProviderId, query: &SearchQuery)
131    -> Result<Option<Entry<Page>>>;
132
133    fn put_search(
134        &self,
135        provider: &ProviderId,
136        query: &SearchQuery,
137        page: &Page,
138        at: Timestamp,
139    ) -> Result<()>;
140}
141
142/// Request slots shared between processes, so parallel agents (and any
143/// other process using the same file) keep to one rate limit per provider.
144pub trait Pacing: Send + Sync {
145    /// Claims the next start time for a request to `provider`: `now` if
146    /// free, else `interval` after the latest slot anyone claimed.
147    fn claim_slot(&self, provider: &str, interval: Duration, now: Timestamp) -> Result<Timestamp>;
148}
149
150#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct Stats {
153    pub records: u64,
154    pub identifiers: u64,
155    pub misses: u64,
156    pub searches: u64,
157}
158
159pub struct SqliteCache {
160    conn: Mutex<Connection>,
161}
162
163impl SqliteCache {
164    /// Opens (creating if needed) the cache at `path`.
165    pub fn open(path: &Path) -> Result<Self> {
166        if let Some(dir) = path.parent() {
167            std::fs::create_dir_all(dir)?;
168        }
169        Self::init(Connection::open(path)?)
170    }
171
172    pub fn open_in_memory() -> Result<Self> {
173        Self::init(Connection::open_in_memory()?)
174    }
175
176    fn init(mut conn: Connection) -> Result<Self> {
177        conn.busy_timeout(Duration::from_secs(5))?;
178        conn.pragma_update(None, "journal_mode", "WAL")?;
179        let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
180        if version != SCHEMA_VERSION {
181            // Disposable: drop whatever is there (older or newer) and rebuild.
182            let tx = conn.transaction()?;
183            let tables: Vec<String> = tx
184                .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'pacing'")?
185                .query_map([], |r| r.get(0))?
186                .collect::<rusqlite::Result<_>>()?;
187            for table in tables {
188                tx.execute_batch(&format!("DROP TABLE \"{}\";", table.replace('"', "\"\"")))?;
189            }
190            tx.execute_batch(SCHEMA)?;
191            tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
192            tx.commit()?;
193        }
194        conn.execute_batch(PACING)?;
195        Ok(SqliteCache {
196            conn: Mutex::new(conn),
197        })
198    }
199
200    fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
201        // A panic while holding the lock leaves SQLite consistent (writes
202        // are transactional), so poisoning is ignorable.
203        self.conn.lock().unwrap_or_else(|e| e.into_inner())
204    }
205
206    pub fn stats(&self) -> Result<Stats> {
207        let conn = self.conn();
208        let count = |table: &str| -> Result<u64> {
209            let n: i64 =
210                conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?;
211            Ok(n as u64)
212        };
213        Ok(Stats {
214            records: count("record")?,
215            identifiers: count("record_id")?,
216            misses: count("miss")?,
217            searches: count("search")?,
218        })
219    }
220
221    /// Empties the cache.
222    pub fn clear(&self) -> Result<()> {
223        self.conn().execute_batch(
224            "DELETE FROM record; DELETE FROM record_id; DELETE FROM miss; DELETE FROM search; VACUUM;",
225        )?;
226        Ok(())
227    }
228}
229
230fn timestamp(seconds: i64) -> Timestamp {
231    Timestamp::from_second(seconds).unwrap_or(Timestamp::UNIX_EPOCH)
232}
233
234impl Pacing for SqliteCache {
235    fn claim_slot(&self, provider: &str, interval: Duration, now: Timestamp) -> Result<Timestamp> {
236        let mut conn = self.conn();
237        // Take the write lock up front, so claims never interleave.
238        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
239        let now_ms = now.as_millisecond();
240        let next: Option<i64> = tx
241            .query_row(
242                "SELECT next_slot FROM pacing WHERE provider = ?1",
243                params![provider],
244                |r| r.get(0),
245            )
246            .optional()?;
247        let ahead = MAX_AHEAD.as_millis() as i64;
248        let slot = next
249            .filter(|n| *n - now_ms <= ahead)
250            .map_or(now_ms, |n| n.max(now_ms));
251        tx.execute(
252            "INSERT INTO pacing (provider, next_slot) VALUES (?1, ?2)
253             ON CONFLICT (provider) DO UPDATE SET next_slot = excluded.next_slot",
254            params![provider, slot + interval.as_millis() as i64],
255        )?;
256        tx.commit()?;
257        Ok(Timestamp::from_millisecond(slot).unwrap_or(now))
258    }
259}
260
261impl Cache for SqliteCache {
262    fn get_lookups(
263        &self,
264        provider: &ProviderId,
265        ids: &[WorkId],
266    ) -> Result<Vec<Option<CachedLookup>>> {
267        let mut conn = self.conn();
268        // One read transaction: a consistent snapshot, one lock.
269        let tx = conn.transaction()?;
270        let mut found = tx.prepare_cached(
271            "SELECT r.record, r.stored_at FROM record_id i
272             JOIN record r ON r.provider = i.provider AND r.native_id = i.native_id
273             WHERE i.provider = ?1 AND i.work_id = ?2",
274        )?;
275        let mut missed =
276            tx.prepare_cached("SELECT stored_at FROM miss WHERE provider = ?1 AND work_id = ?2")?;
277
278        let mut out = Vec::with_capacity(ids.len());
279        for id in ids {
280            let key = params![provider.as_str(), id.to_string()];
281            let record: Option<(String, i64)> = found
282                .query_row(key, |r| Ok((r.get(0)?, r.get(1)?)))
283                .optional()?;
284            let miss: Option<i64> = missed.query_row(key, |r| r.get(0)).optional()?;
285            let not_found = |at: i64| Entry {
286                value: None,
287                stored_at: timestamp(at),
288            };
289            // The newer answer wins if both exist.
290            out.push(match (record, miss) {
291                (Some((_, found_at)), Some(missed_at)) if missed_at > found_at => {
292                    Some(not_found(missed_at))
293                }
294                (Some((json, at)), _) => Some(Entry {
295                    value: Some(serde_json::from_str(&json)?),
296                    stored_at: timestamp(at),
297                }),
298                (None, Some(at)) => Some(not_found(at)),
299                (None, None) => None,
300            });
301        }
302        Ok(out)
303    }
304
305    fn put_lookups(
306        &self,
307        provider: &ProviderId,
308        answers: &[(&WorkId, Option<&SourceRecord>)],
309        at: Timestamp,
310    ) -> Result<()> {
311        let mut conn = self.conn();
312        let tx = conn.transaction()?;
313        {
314            let (p, at) = (provider.as_str(), at.as_second());
315            let mut put_record = tx.prepare_cached(
316                "INSERT OR REPLACE INTO record (provider, native_id, record, stored_at) VALUES (?1, ?2, ?3, ?4)",
317            )?;
318            let mut put_id = tx.prepare_cached(
319                "INSERT OR REPLACE INTO record_id (provider, work_id, native_id) VALUES (?1, ?2, ?3)",
320            )?;
321            let mut clear_miss =
322                tx.prepare_cached("DELETE FROM miss WHERE provider = ?1 AND work_id = ?2")?;
323            let mut put_miss = tx.prepare_cached(
324                "INSERT OR REPLACE INTO miss (provider, work_id, stored_at) VALUES (?1, ?2, ?3)",
325            )?;
326
327            for (requested, record) in answers {
328                match record {
329                    Some(record) => {
330                        // A record without any identifier can't be found again.
331                        let Some(native) = record.native_id().map(ToString::to_string) else {
332                            continue;
333                        };
334                        put_record.execute(params![
335                            p,
336                            native,
337                            serde_json::to_string(record)?,
338                            at
339                        ])?;
340                        let ids = record
341                            .ids
342                            .iter()
343                            .map(ToString::to_string)
344                            .chain([requested.to_string()]);
345                        for work_id in ids {
346                            put_id.execute(params![p, work_id, native])?;
347                            clear_miss.execute(params![p, work_id])?;
348                        }
349                    }
350                    None => {
351                        put_miss.execute(params![p, requested.to_string(), at])?;
352                    }
353                }
354            }
355        }
356        tx.commit()?;
357        Ok(())
358    }
359
360    fn get_search(
361        &self,
362        provider: &ProviderId,
363        query: &SearchQuery,
364    ) -> Result<Option<Entry<Page>>> {
365        let row: Option<(String, i64)> = self
366            .conn()
367            .query_row(
368                "SELECT page, stored_at FROM search WHERE provider = ?1 AND query = ?2",
369                params![provider.as_str(), serde_json::to_string(query)?],
370                |r| Ok((r.get(0)?, r.get(1)?)),
371            )
372            .optional()?;
373        row.map(|(json, at)| {
374            Ok(Entry {
375                value: serde_json::from_str(&json)?,
376                stored_at: timestamp(at),
377            })
378        })
379        .transpose()
380    }
381
382    fn put_search(
383        &self,
384        provider: &ProviderId,
385        query: &SearchQuery,
386        page: &Page,
387        at: Timestamp,
388    ) -> Result<()> {
389        self.conn().execute(
390            "INSERT OR REPLACE INTO search (provider, query, page, stored_at) VALUES (?1, ?2, ?3, ?4)",
391            params![
392                provider.as_str(),
393                serde_json::to_string(query)?,
394                serde_json::to_string(page)?,
395                at.as_second()
396            ],
397        )?;
398        Ok(())
399    }
400}