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        // The hit is recorded only once a model has actually come back. A row
128        // that no longer parses — the model gained a column since it was
129        // cached — is a miss, not an error and not a hit: refusing to serve
130        // the page because an old entry has the wrong shape would make a
131        // deployment an outage, and counting it as a hit would overstate the
132        // hit rate by exactly the entries that are costing a query.
133        if let Some(model) = self
134            .store
135            .get(&cache_key)
136            .await?
137            .as_ref()
138            .and_then(row_from_json)
139            .and_then(|row| M::from_row(&row).ok())
140        {
141            self.stats.record(M::TABLE, Event::EntityHit);
142            return Ok(Some(model));
143        }
144        self.stats.record(M::TABLE, Event::EntityMiss);
145
146        // **One query, not two.** This used to call `M::find` and then read the
147        // row again to cache it, because `Model::to_json` cannot be rehydrated
148        // — it drops the columns the database maintains. Fetching the row and
149        // hydrating from it gets both from a single round trip, which matters
150        // because the two-query version made a cold read more expensive than
151        // no cache at all.
152        let Some(row) = self.select_one(db, M::TABLE, M::PRIMARY_KEY, key.into()).await? else {
153            return Ok(None);
154        };
155        let model = M::from_row(&row)?;
156
157        if let Some(json) = row_to_json(&row) {
158            self.put(&cache_key, json, region).await?;
159        }
160        Ok(Some(model))
161    }
162
163    /// A query's results, from the cache when they are there and still current.
164    pub async fn get<M: Model + Send>(&self, db: &Database, query: QueryBuilder) -> Result<Vec<M>> {
165        let Some(region) = self.region_for(M::TABLE).filter(|r| r.queries) else {
166            return M::get(db, query).await;
167        };
168
169        let (sql, bindings) = query.to_sql(db.dialect())?;
170        let cache_key = keys::query(M::TABLE, keys::fingerprint(&sql, &bindings));
171        let generation = self.generation(M::TABLE, region).await?;
172
173        // Three ways this is a miss rather than a hit, and all three matter: a
174        // different statement means the fingerprint collided, an older
175        // generation means somebody wrote to the table, and a row that will
176        // not parse means the model changed shape since it was cached.
177        let usable = self
178            .store
179            .get(&cache_key)
180            .await?
181            .as_ref()
182            .and_then(Entry::from_json)
183            .filter(|entry| entry.sql == sql && entry.generation == generation);
184
185        // A row that will not parse — the model gained a column since it was
186        // cached — is the third way this is a miss, and it is why the hit is
187        // only recorded once every row has come back.
188        if let Some(models) = usable.and_then(|entry| {
189            entry
190                .rows
191                .iter()
192                .map(|value| row_from_json(value).and_then(|row| M::from_row(&row).ok()))
193                .collect::<Option<Vec<M>>>()
194        }) {
195            self.stats.record(M::TABLE, Event::QueryHit);
196            return Ok(models);
197        }
198        self.stats.record(M::TABLE, Event::QueryMiss);
199
200        let rows = query.get(db).await?;
201        let models = M::hydrate(&rows)?;
202
203        if rows.len() > region.max_rows {
204            self.stats.record(M::TABLE, Event::TooLarge);
205            return Ok(models);
206        }
207
208        // A row this cache cannot carry back exactly — a binary column — means
209        // the whole result set goes unstored rather than half-stored.
210        let Some(encoded) = rows.iter().map(row_to_json).collect::<Option<Vec<Json>>>() else {
211            self.stats.record(M::TABLE, Event::Unsupported);
212            return Ok(models);
213        };
214
215        let entry = Entry { sql, generation, rows: encoded };
216        self.put(&cache_key, entry.to_json(), region).await?;
217        Ok(models)
218    }
219
220    /// The first result, from the cache when it is there.
221    pub async fn first<M: Model + Send>(
222        &self,
223        db: &Database,
224        query: QueryBuilder,
225    ) -> Result<Option<M>> {
226        Ok(self.get::<M>(db, query.limit(1)).await?.into_iter().next())
227    }
228
229    // --- Writing ---------------------------------------------------------
230
231    /// Insert, and invalidate what the insert makes stale.
232    pub async fn insert<M: Model + Send + Sync>(&self, db: &Database, model: &mut M) -> Result<()> {
233        model.insert(db).await?;
234        self.invalidate_table::<M>().await
235    }
236
237    /// Update, and invalidate the entity and the table's cached queries.
238    pub async fn update<M: Model + Send + Sync>(&self, db: &Database, model: &M) -> Result<u64> {
239        let affected = model.update(db).await?;
240        self.forget::<M>(model.key()).await?;
241        self.invalidate_table::<M>().await?;
242        Ok(affected)
243    }
244
245    /// Delete, and the same.
246    pub async fn delete<M: Model + Send + Sync>(&self, db: &Database, model: &M) -> Result<u64> {
247        let affected = model.delete(db).await?;
248        self.forget::<M>(model.key()).await?;
249        self.invalidate_table::<M>().await?;
250        Ok(affected)
251    }
252
253    /// Drop one entity from the cache. The table's queries are left alone.
254    ///
255    /// For a write this cache did not make — a raw `UPDATE`, a migration, a
256    /// change from another process. Almost always wanted together with
257    /// [`invalidate_table`](Self::invalidate_table).
258    pub async fn forget<M: Model>(&self, key: M::Key) -> Result<()> {
259        let text = Json::from(key.into()).to_string();
260        self.store.forget(&keys::entity(M::TABLE, &text)).await?;
261        Ok(())
262    }
263
264    /// Make every cached query for this table a miss.
265    ///
266    /// One counter, bumped. There is no way to enumerate the result sets a
267    /// write invalidates — a cached `WHERE role = 'admin'` is invalidated by
268    /// an insert whose shape this cache never sees — so the generation is what
269    /// stands in for that enumeration.
270    pub async fn invalidate_table<M: Model>(&self) -> Result<()> {
271        let Some(region) = self.region_for(M::TABLE) else { return Ok(()) };
272        if region.strategy == Strategy::ReadOnly {
273            // The caller promised there would be no writes. Bumping anyway
274            // would make the promise meaningless and hide the mistake.
275            return Ok(());
276        }
277        self.store.increment(&keys::generation(M::TABLE), 1).await?;
278        self.stats.record(M::TABLE, Event::Invalidated);
279        Ok(())
280    }
281
282    /// Everything this cache put in the store, for every table.
283    ///
284    /// Bumps each registered table's generation rather than flushing the
285    /// backend, because the backend is shared: flushing it would take the
286    /// session store and the rate limiter with it.
287    pub async fn invalidate_all(&self) -> Result<()> {
288        for (table, region) in self.regions.iter() {
289            if region.strategy == Strategy::ReadOnly {
290                continue;
291            }
292            self.store.increment(&keys::generation(table), 1).await?;
293            self.stats.record(table, Event::Invalidated);
294        }
295        Ok(())
296    }
297
298    // --- Plumbing --------------------------------------------------------
299
300    /// The table's current generation.
301    ///
302    /// A read-only region skips this entirely, which is one round trip less
303    /// per query and the whole reason the strategy exists.
304    async fn generation(&self, table: &str, region: &Region) -> Result<i64> {
305        if region.strategy == Strategy::ReadOnly {
306            return Ok(0);
307        }
308        Ok(self.store.get(&keys::generation(table)).await?.and_then(|v| v.as_i64()).unwrap_or(0))
309    }
310
311    async fn put(&self, key: &str, value: Json, region: &Region) -> Result<()> {
312        match region.ttl {
313            Some(ttl) => self.store.put(key, value, ttl).await,
314            None => self.store.forever(key, value).await,
315        }
316    }
317
318    /// Re-read one row so the cached entity has every column, not just the
319    /// ones `Model::values` writes.
320    ///
321    /// `to_json` on the model would lose whatever the database maintains —
322    /// `created_at`, a computed default — and `from_row` would then fail on
323    /// the way back out. Caching the row the database returned avoids the
324    /// whole question.
325    async fn select_one(
326        &self,
327        db: &Database,
328        table: &str,
329        primary_key: &str,
330        key: Value,
331    ) -> Result<Option<Row>> {
332        let rows = db.table(table).filter(primary_key, key).limit(1).get(db).await?;
333        Ok(rows.into_iter().next())
334    }
335}
336
337impl std::fmt::Debug for ModelCache {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        f.debug_struct("ModelCache")
340            .field("driver", &self.store.driver())
341            .field("regions", &self.regions.keys().collect::<Vec<_>>())
342            .field("opt_in", &self.opt_in)
343            .finish()
344    }
345}
346
347/// A row as a JSON object: column name to value.
348///
349/// `None` when the row holds something JSON cannot carry back. See
350/// [`value_to_json`] — this is the check that stops a binary column being
351/// quietly turned into the string `"<12 bytes>"` on the way through the cache.
352fn row_to_json(row: &Row) -> Option<Json> {
353    let mut fields = Vec::with_capacity(row.columns().len());
354    for name in row.columns() {
355        let value = row.value(name).ok()?;
356        fields.push((name.clone(), value_to_json(value)?));
357    }
358    Some(Json::object(fields))
359}
360
361/// And back. `None` for anything that is not an object, which is what a
362/// corrupted or outgrown entry looks like.
363fn row_from_json(value: &Json) -> Option<Row> {
364    let Json::Object(map) = value else { return None };
365    let columns: Vec<String> = map.keys().cloned().collect();
366    let values: Vec<Value> = map.values().map(json_to_value).collect();
367    Some(Row::new(Columns::new(columns), values))
368}
369
370/// One value, on the way in.
371///
372/// **`Value::from(json)` is not the inverse of `Json::from(value)`** — it wraps
373/// whatever it is given in a JSON *column* — so the pair is written here
374/// instead. The one case that cannot round-trip is `Bytes`: the built-in
375/// conversion renders it as `"<12 bytes>"`, which reads back as text and hands
376/// a caller a string where their blob was. That is a corrupted row, so it is
377/// refused: the query runs and its result is simply not cached.
378fn value_to_json(value: &Value) -> Option<Json> {
379    match value {
380        Value::Bytes(_) => None,
381        other => Some(Json::from(other.clone())),
382    }
383}
384
385/// One value, on the way out.
386///
387/// A JSON number becomes `Int` when it is whole and `Float` when it is not.
388/// The asymmetry is safe in this direction because `FromValue for f64` accepts
389/// an `Int` — a float column holding `7.0` comes back as `Int(7)` and still
390/// reads as `7.0` — while the reverse, handing an integer column a float,
391/// would not.
392fn json_to_value(value: &Json) -> Value {
393    match value {
394        Json::Null => Value::Null,
395        Json::Bool(b) => Value::Bool(*b),
396        Json::Number(n) if n.fract() == 0.0 && n.abs() < 9.007_199_254_740_992e15 => {
397            Value::Int(*n as i64)
398        }
399        Json::Number(n) => Value::Float(*n),
400        Json::String(s) => Value::Text(s.clone()),
401        other => Value::Json(other.clone()),
402    }
403}
404
405/// The store, for a caller that wants to reach past this.
406impl ModelCache {
407    pub fn store(&self) -> &Arc<dyn Cache> {
408        &self.store
409    }
410}
411
412/// Not reachable without a database, so the round trip is what gets tested.
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn row(pairs: &[(&str, Value)]) -> Row {
418        let columns: Vec<String> = pairs.iter().map(|(n, _)| (*n).to_string()).collect();
419        let values: Vec<Value> = pairs.iter().map(|(_, v)| v.clone()).collect();
420        Row::new(Columns::new(columns), values)
421    }
422
423    #[test]
424    fn a_row_survives_the_round_trip_through_json() {
425        let original = row(&[
426            ("id", Value::from(7i64)),
427            ("name", Value::from("Ada Lovelace")),
428            ("verified", Value::from(true)),
429            ("deleted_at", Value::Null),
430        ]);
431
432        let back = row_from_json(&row_to_json(&original).expect("not encodable")).expect("did not parse");
433
434        assert_eq!(back.get::<i64>("id").unwrap(), 7);
435        assert_eq!(back.get::<String>("name").unwrap(), "Ada Lovelace");
436        assert!(back.get::<bool>("verified").unwrap());
437        assert_eq!(back.columns().len(), 4, "a column was lost");
438    }
439
440    /// A binary column cannot survive JSON — the built-in conversion renders
441    /// it as `"<3 bytes>"` — so the row is refused rather than corrupted.
442    #[test]
443    fn a_row_with_a_binary_column_is_not_cached_at_all() {
444        let with_blob = row(&[("id", Value::from(1i64)), ("avatar", Value::Bytes(vec![1, 2, 3]))]);
445
446        assert!(row_to_json(&with_blob).is_none(), "a blob was silently turned into text");
447    }
448
449    /// A whole number comes back as an integer, which is what an id column
450    /// needs; a fractional one stays a float.
451    #[test]
452    fn numbers_come_back_as_the_variant_the_column_needs() {
453        assert_eq!(json_to_value(&Json::Number(7.0)), Value::Int(7));
454        assert_eq!(json_to_value(&Json::Number(-7.0)), Value::Int(-7));
455        assert_eq!(json_to_value(&Json::Number(7.5)), Value::Float(7.5));
456        assert_eq!(json_to_value(&Json::Null), Value::Null);
457        assert_eq!(json_to_value(&Json::Bool(true)), Value::Bool(true));
458        assert_eq!(json_to_value(&Json::from("x")), Value::Text("x".into()));
459    }
460
461    /// A cached entry from before a column was added must not fail the
462    /// request. It is a miss.
463    #[test]
464    fn rubbish_in_the_cache_reads_as_a_miss_rather_than_an_error() {
465        assert!(row_from_json(&Json::from("not an object")).is_none());
466        assert!(row_from_json(&Json::Null).is_none());
467        assert!(row_from_json(&Json::Array(vec![])).is_none());
468    }
469
470    #[test]
471    fn a_query_entry_carries_the_statement_it_was_taken_from() {
472        let entry = Entry {
473            sql: "select * from users where role = ?".into(),
474            generation: 3,
475            rows: vec![Json::object([("id", Json::from(1))])],
476        };
477
478        let back = Entry::from_json(&entry.to_json()).expect("did not parse");
479        assert_eq!(back.sql, entry.sql);
480        assert_eq!(back.generation, 3);
481        assert_eq!(back.rows.len(), 1);
482
483        // Anything else is a miss.
484        assert!(Entry::from_json(&Json::from("nonsense")).is_none());
485        assert!(Entry::from_json(&Json::object([("sql", Json::from("x"))])).is_none());
486    }
487}