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