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