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