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