Skip to main content

valence_core/
read_cache.rs

1//! Valence-wide read-through LRU for `Model::get`-shaped point reads.
2//!
3//! **Enable:** default on; set `VALENCE_READ_CACHE=0` to disable. **Capacity:** `VALENCE_READ_CACHE_MAX` (default 10_000).
4//!
5//! Stores raw rows pre-privacy; callers still run `check_read_privacy` per request actor.
6//! When [`ownership_unified_fetch_enabled`] is on, cache entries bundle the main row and ownership
7//! gate status so warm reads avoid a second ownership trip.
8//! Write paths invalidate via generated `Model` ops and [`OwnershipService`].
9
10use std::sync::{Arc, OnceLock};
11
12use quick_cache::sync::Cache;
13use serde_json::Value;
14
15use crate::backend::DatabaseBackend;
16use crate::error::Result;
17use crate::instrumentation;
18use crate::ownership::{
19    ownership_unified_fetch_enabled, OwnershipGateStatus, OwnershipService, RecordOwnershipBundle,
20};
21
22fn cache_key(table: &str, id: &str) -> (String, String) {
23    (table.to_string(), id.to_string())
24}
25
26/// Whether the read-through record cache is active (default on; `VALENCE_READ_CACHE=0` disables).
27pub fn read_cache_enabled() -> bool {
28    static ENABLED: OnceLock<bool> = OnceLock::new();
29    *ENABLED.get_or_init(|| {
30        !matches!(
31            std::env::var("VALENCE_READ_CACHE").as_deref(),
32            Ok("0") | Ok("false") | Ok("FALSE")
33        )
34    })
35}
36
37fn read_cache_capacity() -> usize {
38    static CAPACITY: OnceLock<usize> = OnceLock::new();
39    *CAPACITY.get_or_init(|| {
40        std::env::var("VALENCE_READ_CACHE_MAX")
41            .ok()
42            .and_then(|v| v.parse().ok())
43            .unwrap_or(10_000)
44            .max(1)
45    })
46}
47
48fn global_cache() -> &'static Cache<(String, String), RecordOwnershipBundle> {
49    static CACHE: OnceLock<Cache<(String, String), RecordOwnershipBundle>> = OnceLock::new();
50    CACHE.get_or_init(|| Cache::new(read_cache_capacity()))
51}
52
53/// Point-get with optional read-through cache (raw row, pre-privacy).
54pub async fn get_record_via_cache(
55    backend: &Arc<dyn DatabaseBackend>,
56    table: &str,
57    id: &str,
58) -> Result<Option<Value>> {
59    if read_cache_enabled() {
60        let key = cache_key(table, id);
61        if let Some(cached) = global_cache().get(&key) {
62            instrumentation::record_ownership_fetch_mode("cache_hit_row");
63            return Ok(cached.row);
64        }
65        let row = backend.get_record(table, id).await?;
66        if row.is_some() || ownership_unified_fetch_enabled() {
67            global_cache().insert(
68                key,
69                RecordOwnershipBundle {
70                    row: row.clone(),
71                    ownership_status: OwnershipGateStatus::NotFetched,
72                },
73            );
74        }
75        return Ok(row);
76    }
77    backend.get_record(table, id).await
78}
79
80/// Unified `Model::get` fetch: one backend round trip for row + ownership gate status (when cache cold).
81pub async fn get_record_with_ownership_bundle_via_cache(
82    backend: &Arc<dyn DatabaseBackend>,
83    table: &str,
84    id: &str,
85    valence_model: &str,
86    v: &crate::runtime::Valence,
87) -> Result<RecordOwnershipBundle> {
88    if read_cache_enabled() {
89        let key = cache_key(table, id);
90        if let Some(cached) = global_cache().get(&key) {
91            if cached.ownership_status != OwnershipGateStatus::NotFetched {
92                instrumentation::record_ownership_fetch_mode("cache_hit");
93                return Ok(cached);
94            }
95        }
96    }
97
98    let bundle = OwnershipService::fetch_record_with_ownership_gate_uncached(
99        backend,
100        table,
101        id,
102        valence_model,
103        v,
104    )
105    .await?;
106    instrumentation::record_ownership_fetch_mode("unified");
107
108    if read_cache_enabled() {
109        global_cache().insert(cache_key(table, id), bundle.clone());
110    }
111    Ok(bundle)
112}
113
114/// Drop a cached row after a write or ownership status change.
115pub fn invalidate(table: &str, id: &str) {
116    if read_cache_enabled() {
117        global_cache().remove(&cache_key(table, id));
118    }
119}
120
121/// Clear the global cache (tests / integration tests).
122#[doc(hidden)]
123pub fn clear_for_test() {
124    global_cache().clear();
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn read_cache_enabled_by_default() {
133        assert!(read_cache_enabled());
134    }
135
136    #[test]
137    fn read_cache_capacity_defaults_to_10k() {
138        assert_eq!(read_cache_capacity(), 10_000);
139    }
140}