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 = 5;
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#[derive(Debug, Clone, PartialEq)]
80pub struct Entry<T> {
81 pub value: T,
82 pub stored_at: Timestamp,
83}
84
85impl<T> Entry<T> {
86 pub fn is_fresh(&self, now: Timestamp, ttl: Duration) -> bool {
88 now.duration_since(self.stored_at).unsigned_abs() <= ttl
89 }
90}
91
92pub type CachedLookup = Entry<Option<SourceRecord>>;
95
96pub trait Cache: Send + Sync {
98 fn get_lookups(
101 &self,
102 provider: &ProviderId,
103 ids: &[WorkId],
104 ) -> Result<Vec<Option<CachedLookup>>>;
105
106 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 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 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 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 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 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 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 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}