1use 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
29const 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
67const PACING: &str = "
70CREATE TABLE IF NOT EXISTS pacing (
71 provider TEXT PRIMARY KEY,
72 next_slot INTEGER NOT NULL -- unix milliseconds
73);
74";
75
76const 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#[derive(Debug, Clone, PartialEq)]
95pub struct Entry<T> {
96 pub value: T,
97 pub stored_at: Timestamp,
98}
99
100impl<T> Entry<T> {
101 pub fn is_fresh(&self, now: Timestamp, ttl: Duration) -> bool {
103 now.duration_since(self.stored_at).unsigned_abs() <= ttl
104 }
105}
106
107pub type CachedLookup = Entry<Option<SourceRecord>>;
110
111type LookupRow = (Option<(String, i64)>, Option<i64>);
114
115pub trait Cache: Send + Sync {
117 fn get_lookups(
120 &self,
121 provider: &ProviderId,
122 ids: &[WorkId],
123 ) -> Result<Vec<Option<CachedLookup>>>;
124
125 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
146pub trait Pacing: Send + Sync {
149 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 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 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
207 if version(&tx)? != SCHEMA_VERSION {
208 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 self.conn.lock().unwrap_or_else(|e| e.into_inner())
231 }
232
233 fn lookup_rows(&self, provider: &ProviderId, ids: &[WorkId]) -> Result<Vec<LookupRow>> {
235 let mut conn = self.conn();
236 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 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
281fn 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 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 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 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 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}