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, 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
111pub trait Cache: Send + Sync {
113 fn get_lookups(
116 &self,
117 provider: &ProviderId,
118 ids: &[WorkId],
119 ) -> Result<Vec<Option<CachedLookup>>>;
120
121 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
142pub trait Pacing: Send + Sync {
145 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 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 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 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 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 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 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 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 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}