Skip to main content

rustlavel_model_cache/
cache.rs

1//! The cache itself.
2
3use crate::keys;
4use crate::region::{Region, Strategy};
5use crate::stats::{Event, Stats};
6use rustlavel_cache::{Cache, CacheStore};
7use rustlavel_core::{Json, Result};
8use rustlavel_db::row::Columns;
9use rustlavel_db::{Database, Model, ModelExt, QueryBuilder, Row, Value};
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13/// What is stored under a query key.
14///
15/// The statement is kept beside the rows and compared on the way out. The key
16/// is a 64-bit fingerprint, and a collision would otherwise serve one query's
17/// rows as another's — see [`keys::fingerprint`]. This makes a collision a
18/// miss.
19struct Entry {
20    sql: String,
21    generation: i64,
22    rows: Vec<Json>,
23}
24
25impl Entry {
26    fn to_json(&self) -> Json {
27        Json::object([
28            ("sql", Json::from(self.sql.as_str())),
29            ("g", Json::from(self.generation)),
30            ("rows", Json::Array(self.rows.clone())),
31        ])
32    }
33
34    fn from_json(value: &Json) -> Option<Entry> {
35        Some(Entry {
36            sql: value.get("sql")?.as_str()?.to_string(),
37            generation: value.get("g")?.as_i64()?,
38            rows: value.get("rows")?.as_array()?.to_vec(),
39        })
40    }
41}
42
43/// A second-level cache for models.
44///
45/// Cheap to clone; every clone shares one backend and one set of counters.
46#[derive(Clone)]
47pub struct ModelCache {
48    store: Arc<dyn Cache>,
49    regions: Arc<BTreeMap<&'static str, Region>>,
50    default_region: Arc<Region>,
51    stats: Arc<Stats>,
52    /// Whether an unregistered model is cached at all.
53    ///
54    /// Off. A cache that quietly starts holding every table the moment it is
55    /// registered is a cache nobody chose, and the first thing it does wrong
56    /// is serve a stale row from a table the author never thought about.
57    opt_in: bool,
58}
59
60impl ModelCache {
61    /// A cache over any [`Cache`] backend — memory, file, or Redis.
62    pub fn new(store: impl Cache) -> ModelCache {
63        ModelCache::from_arc(Arc::new(store))
64    }
65
66    /// The store the application already built, shared rather than duplicated.
67    pub fn shared(store: &CacheStore) -> ModelCache {
68        ModelCache::from_arc(store.driver_handle())
69    }
70
71    fn from_arc(store: Arc<dyn Cache>) -> ModelCache {
72        ModelCache {
73            store,
74            regions: Arc::new(BTreeMap::new()),
75            default_region: Arc::new(Region::default()),
76            stats: Arc::new(Stats::default()),
77            opt_in: true,
78        }
79    }
80
81    /// Register a model, with settings.
82    ///
83    /// Panics on settings that contradict each other — a read-only region with
84    /// no expiry, say. That is a startup failure by design: the alternative is
85    /// a stale row six weeks from now with nothing to trace it to.
86    pub fn region<M: Model>(mut self, region: Region) -> ModelCache {
87        region.check(M::TABLE).expect("the cache region settings contradict each other");
88        Arc::make_mut(&mut self.regions).insert(M::TABLE, region);
89        self
90    }
91
92    /// Cache every model, registered or not, with these settings.
93    ///
94    /// For an application that has decided it wants this everywhere. A model
95    /// with its own [`region`](Self::region) still uses that.
96    pub fn cache_everything(mut self, region: Region) -> ModelCache {
97        region.check("<default>").expect("the default region settings contradict each other");
98        self.default_region = Arc::new(region);
99        self.opt_in = false;
100        self
101    }
102
103    pub fn stats(&self) -> &Stats {
104        &self.stats
105    }
106
107    /// The settings for a table, or `None` when it is not cached.
108    fn region_for(&self, table: &str) -> Option<&Region> {
109        match self.regions.get(table) {
110            Some(region) => Some(region),
111            None if self.opt_in => None,
112            None => Some(&self.default_region),
113        }
114    }
115
116    // --- Reading ---------------------------------------------------------
117
118    /// A model by primary key, from the cache when it is there.
119    pub async fn find<M: Model + Send>(&self, db: &Database, key: M::Key) -> Result<Option<M>> {
120        let Some(region) = self.region_for(M::TABLE).filter(|r| r.entities) else {
121            return M::find(db, key).await;
122        };
123
124        let text = Json::from(key.clone().into()).to_string();
125        let cache_key = keys::entity(M::TABLE, &text);
126
127        if let Some(cached) = self.store.get(&cache_key).await? {
128            self.stats.record(M::TABLE, Event::EntityHit);
129            // A row that no longer parses — the model gained a column since it
130            // was cached — is a miss, not an error. Refusing to serve the page
131            // because an old cache entry has the wrong shape would make a
132            // deployment an outage.
133            if let Some(model) = row_from_json(&cached).and_then(|row| M::from_row(&row).ok()) {
134                return Ok(Some(model));
135            }
136        } else {
137            self.stats.record(M::TABLE, Event::EntityMiss);
138        }
139
140        let found = M::find(db, key).await?;
141        if let Some(model) = &found {
142            let row = self.select_one(db, M::TABLE, M::PRIMARY_KEY, model.key().into()).await?;
143            if let Some(json) = row.as_ref().and_then(row_to_json) {
144                self.put(&cache_key, json, region).await?;
145            }
146        }
147        Ok(found)
148    }
149
150    /// A query's results, from the cache when they are there and still current.
151    pub async fn get<M: Model + Send>(&self, db: &Database, query: QueryBuilder) -> Result<Vec<M>> {
152        let Some(region) = self.region_for(M::TABLE).filter(|r| r.queries) else {
153            return M::get(db, query).await;
154        };
155
156        let (sql, bindings) = query.to_sql(db.dialect())?;
157        let cache_key = keys::query(M::TABLE, keys::fingerprint(&sql, &bindings));
158        let generation = self.generation(M::TABLE, region).await?;
159
160        // Three ways this is a miss rather than a hit, and all three matter: a
161        // different statement means the fingerprint collided, an older
162        // generation means somebody wrote to the table, and a row that will
163        // not parse means the model changed shape since it was cached.
164        let usable = self
165            .store
166            .get(&cache_key)
167            .await?
168            .as_ref()
169            .and_then(Entry::from_json)
170            .filter(|entry| entry.sql == sql && entry.generation == generation);
171
172        // A row that will not parse — the model gained a column since it was
173        // cached — is the third way this is a miss, and it is why the hit is
174        // only recorded once every row has come back.
175        if let Some(models) = usable.and_then(|entry| {
176            entry
177                .rows
178                .iter()
179                .map(|value| row_from_json(value).and_then(|row| M::from_row(&row).ok()))
180                .collect::<Option<Vec<M>>>()
181        }) {
182            self.stats.record(M::TABLE, Event::QueryHit);
183            return Ok(models);
184        }
185        self.stats.record(M::TABLE, Event::QueryMiss);
186
187        let rows = query.get(db).await?;
188        let models = M::hydrate(&rows)?;
189
190        if rows.len() > region.max_rows {
191            self.stats.record(M::TABLE, Event::TooLarge);
192            return Ok(models);
193        }
194
195        // A row this cache cannot carry back exactly — a binary column — means
196        // the whole result set goes unstored rather than half-stored.
197        let Some(encoded) = rows.iter().map(row_to_json).collect::<Option<Vec<Json>>>() else {
198            self.stats.record(M::TABLE, Event::Unsupported);
199            return Ok(models);
200        };
201
202        let entry = Entry { sql, generation, rows: encoded };
203        self.put(&cache_key, entry.to_json(), region).await?;
204        Ok(models)
205    }
206
207    /// The first result, from the cache when it is there.
208    pub async fn first<M: Model + Send>(
209        &self,
210        db: &Database,
211        query: QueryBuilder,
212    ) -> Result<Option<M>> {
213        Ok(self.get::<M>(db, query.limit(1)).await?.into_iter().next())
214    }
215
216    // --- Writing ---------------------------------------------------------
217
218    /// Insert, and invalidate what the insert makes stale.
219    pub async fn insert<M: Model + Send + Sync>(&self, db: &Database, model: &mut M) -> Result<()> {
220        model.insert(db).await?;
221        self.invalidate_table::<M>().await
222    }
223
224    /// Update, and invalidate the entity and the table's cached queries.
225    pub async fn update<M: Model + Send + Sync>(&self, db: &Database, model: &M) -> Result<u64> {
226        let affected = model.update(db).await?;
227        self.forget::<M>(model.key()).await?;
228        self.invalidate_table::<M>().await?;
229        Ok(affected)
230    }
231
232    /// Delete, and the same.
233    pub async fn delete<M: Model + Send + Sync>(&self, db: &Database, model: &M) -> Result<u64> {
234        let affected = model.delete(db).await?;
235        self.forget::<M>(model.key()).await?;
236        self.invalidate_table::<M>().await?;
237        Ok(affected)
238    }
239
240    /// Drop one entity from the cache. The table's queries are left alone.
241    ///
242    /// For a write this cache did not make — a raw `UPDATE`, a migration, a
243    /// change from another process. Almost always wanted together with
244    /// [`invalidate_table`](Self::invalidate_table).
245    pub async fn forget<M: Model>(&self, key: M::Key) -> Result<()> {
246        let text = Json::from(key.into()).to_string();
247        self.store.forget(&keys::entity(M::TABLE, &text)).await?;
248        Ok(())
249    }
250
251    /// Make every cached query for this table a miss.
252    ///
253    /// One counter, bumped. There is no way to enumerate the result sets a
254    /// write invalidates — a cached `WHERE role = 'admin'` is invalidated by
255    /// an insert whose shape this cache never sees — so the generation is what
256    /// stands in for that enumeration.
257    pub async fn invalidate_table<M: Model>(&self) -> Result<()> {
258        let Some(region) = self.region_for(M::TABLE) else { return Ok(()) };
259        if region.strategy == Strategy::ReadOnly {
260            // The caller promised there would be no writes. Bumping anyway
261            // would make the promise meaningless and hide the mistake.
262            return Ok(());
263        }
264        self.store.increment(&keys::generation(M::TABLE), 1).await?;
265        self.stats.record(M::TABLE, Event::Invalidated);
266        Ok(())
267    }
268
269    /// Everything this cache put in the store, for every table.
270    ///
271    /// Bumps each registered table's generation rather than flushing the
272    /// backend, because the backend is shared: flushing it would take the
273    /// session store and the rate limiter with it.
274    pub async fn invalidate_all(&self) -> Result<()> {
275        for (table, region) in self.regions.iter() {
276            if region.strategy == Strategy::ReadOnly {
277                continue;
278            }
279            self.store.increment(&keys::generation(table), 1).await?;
280            self.stats.record(table, Event::Invalidated);
281        }
282        Ok(())
283    }
284
285    // --- Plumbing --------------------------------------------------------
286
287    /// The table's current generation.
288    ///
289    /// A read-only region skips this entirely, which is one round trip less
290    /// per query and the whole reason the strategy exists.
291    async fn generation(&self, table: &str, region: &Region) -> Result<i64> {
292        if region.strategy == Strategy::ReadOnly {
293            return Ok(0);
294        }
295        Ok(self.store.get(&keys::generation(table)).await?.and_then(|v| v.as_i64()).unwrap_or(0))
296    }
297
298    async fn put(&self, key: &str, value: Json, region: &Region) -> Result<()> {
299        match region.ttl {
300            Some(ttl) => self.store.put(key, value, ttl).await,
301            None => self.store.forever(key, value).await,
302        }
303    }
304
305    /// Re-read one row so the cached entity has every column, not just the
306    /// ones `Model::values` writes.
307    ///
308    /// `to_json` on the model would lose whatever the database maintains —
309    /// `created_at`, a computed default — and `from_row` would then fail on
310    /// the way back out. Caching the row the database returned avoids the
311    /// whole question.
312    async fn select_one(
313        &self,
314        db: &Database,
315        table: &str,
316        primary_key: &str,
317        key: Value,
318    ) -> Result<Option<Row>> {
319        let rows = db.table(table).filter(primary_key, key).limit(1).get(db).await?;
320        Ok(rows.into_iter().next())
321    }
322}
323
324impl std::fmt::Debug for ModelCache {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.debug_struct("ModelCache")
327            .field("driver", &self.store.driver())
328            .field("regions", &self.regions.keys().collect::<Vec<_>>())
329            .field("opt_in", &self.opt_in)
330            .finish()
331    }
332}
333
334/// A row as a JSON object: column name to value.
335///
336/// `None` when the row holds something JSON cannot carry back. See
337/// [`value_to_json`] — this is the check that stops a binary column being
338/// quietly turned into the string `"<12 bytes>"` on the way through the cache.
339fn row_to_json(row: &Row) -> Option<Json> {
340    let mut fields = Vec::with_capacity(row.columns().len());
341    for name in row.columns() {
342        let value = row.value(name).ok()?;
343        fields.push((name.clone(), value_to_json(value)?));
344    }
345    Some(Json::object(fields))
346}
347
348/// And back. `None` for anything that is not an object, which is what a
349/// corrupted or outgrown entry looks like.
350fn row_from_json(value: &Json) -> Option<Row> {
351    let Json::Object(map) = value else { return None };
352    let columns: Vec<String> = map.keys().cloned().collect();
353    let values: Vec<Value> = map.values().map(json_to_value).collect();
354    Some(Row::new(Columns::new(columns), values))
355}
356
357/// One value, on the way in.
358///
359/// **`Value::from(json)` is not the inverse of `Json::from(value)`** — it wraps
360/// whatever it is given in a JSON *column* — so the pair is written here
361/// instead. The one case that cannot round-trip is `Bytes`: the built-in
362/// conversion renders it as `"<12 bytes>"`, which reads back as text and hands
363/// a caller a string where their blob was. That is a corrupted row, so it is
364/// refused: the query runs and its result is simply not cached.
365fn value_to_json(value: &Value) -> Option<Json> {
366    match value {
367        Value::Bytes(_) => None,
368        other => Some(Json::from(other.clone())),
369    }
370}
371
372/// One value, on the way out.
373///
374/// A JSON number becomes `Int` when it is whole and `Float` when it is not.
375/// The asymmetry is safe in this direction because `FromValue for f64` accepts
376/// an `Int` — a float column holding `7.0` comes back as `Int(7)` and still
377/// reads as `7.0` — while the reverse, handing an integer column a float,
378/// would not.
379fn json_to_value(value: &Json) -> Value {
380    match value {
381        Json::Null => Value::Null,
382        Json::Bool(b) => Value::Bool(*b),
383        Json::Number(n) if n.fract() == 0.0 && n.abs() < 9.007_199_254_740_992e15 => {
384            Value::Int(*n as i64)
385        }
386        Json::Number(n) => Value::Float(*n),
387        Json::String(s) => Value::Text(s.clone()),
388        other => Value::Json(other.clone()),
389    }
390}
391
392/// The store, for a caller that wants to reach past this.
393impl ModelCache {
394    pub fn store(&self) -> &Arc<dyn Cache> {
395        &self.store
396    }
397}
398
399/// Not reachable without a database, so the round trip is what gets tested.
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn row(pairs: &[(&str, Value)]) -> Row {
405        let columns: Vec<String> = pairs.iter().map(|(n, _)| (*n).to_string()).collect();
406        let values: Vec<Value> = pairs.iter().map(|(_, v)| v.clone()).collect();
407        Row::new(Columns::new(columns), values)
408    }
409
410    #[test]
411    fn a_row_survives_the_round_trip_through_json() {
412        let original = row(&[
413            ("id", Value::from(7i64)),
414            ("name", Value::from("Ada Lovelace")),
415            ("verified", Value::from(true)),
416            ("deleted_at", Value::Null),
417        ]);
418
419        let back = row_from_json(&row_to_json(&original).expect("not encodable")).expect("did not parse");
420
421        assert_eq!(back.get::<i64>("id").unwrap(), 7);
422        assert_eq!(back.get::<String>("name").unwrap(), "Ada Lovelace");
423        assert!(back.get::<bool>("verified").unwrap());
424        assert_eq!(back.columns().len(), 4, "a column was lost");
425    }
426
427    /// A binary column cannot survive JSON — the built-in conversion renders
428    /// it as `"<3 bytes>"` — so the row is refused rather than corrupted.
429    #[test]
430    fn a_row_with_a_binary_column_is_not_cached_at_all() {
431        let with_blob = row(&[("id", Value::from(1i64)), ("avatar", Value::Bytes(vec![1, 2, 3]))]);
432
433        assert!(row_to_json(&with_blob).is_none(), "a blob was silently turned into text");
434    }
435
436    /// A whole number comes back as an integer, which is what an id column
437    /// needs; a fractional one stays a float.
438    #[test]
439    fn numbers_come_back_as_the_variant_the_column_needs() {
440        assert_eq!(json_to_value(&Json::Number(7.0)), Value::Int(7));
441        assert_eq!(json_to_value(&Json::Number(-7.0)), Value::Int(-7));
442        assert_eq!(json_to_value(&Json::Number(7.5)), Value::Float(7.5));
443        assert_eq!(json_to_value(&Json::Null), Value::Null);
444        assert_eq!(json_to_value(&Json::Bool(true)), Value::Bool(true));
445        assert_eq!(json_to_value(&Json::from("x")), Value::Text("x".into()));
446    }
447
448    /// A cached entry from before a column was added must not fail the
449    /// request. It is a miss.
450    #[test]
451    fn rubbish_in_the_cache_reads_as_a_miss_rather_than_an_error() {
452        assert!(row_from_json(&Json::from("not an object")).is_none());
453        assert!(row_from_json(&Json::Null).is_none());
454        assert!(row_from_json(&Json::Array(vec![])).is_none());
455    }
456
457    #[test]
458    fn a_query_entry_carries_the_statement_it_was_taken_from() {
459        let entry = Entry {
460            sql: "select * from users where role = ?".into(),
461            generation: 3,
462            rows: vec![Json::object([("id", Json::from(1))])],
463        };
464
465        let back = Entry::from_json(&entry.to_json()).expect("did not parse");
466        assert_eq!(back.sql, entry.sql);
467        assert_eq!(back.generation, 3);
468        assert_eq!(back.rows.len(), 1);
469
470        // Anything else is a miss.
471        assert!(Entry::from_json(&Json::from("nonsense")).is_none());
472        assert!(Entry::from_json(&Json::object([("sql", Json::from("x"))])).is_none());
473    }
474}