Skip to main content

hayson_engine/table/
cache.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use serde_json::Value;
5
6use crate::schema::HaysonRecord;
7
8/// Approximate document size at or above which a record is cached in the large-document tier
9/// instead of the main one — four kilobytes, which no record of scalars and short strings reaches
10/// and every sealed `cypher` envelope or paragraph-length `fulltext` field does.
11pub const DEFAULT_LARGE_BYTES: usize = 4 * 1024;
12
13/// How many parsed documents a table keeps in memory in front of its log — the read cache
14/// [`crate::table::Table::get`] answers a repeated read from instead of re-reading and re-parsing
15/// the line the registry points at.
16///
17/// `Disabled` is the default everywhere, including [`crate::database::OpenOptions`]: caching a
18/// document is a memory bet a caller has to place on purpose.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum CachePolicy {
21    #[default]
22    Disabled,
23    Enabled {
24        /// Most documents held below the `large_bytes` line, evicting least-recently-read first.
25        entries: usize,
26        /// Most documents held at or above the `large_bytes` line — its own, smaller budget, so a
27        /// handful of big records cannot evict every small one.
28        large_entries: usize,
29        /// The size, as [`approx_bytes`] measures it, that separates the two tiers.
30        large_bytes: usize,
31    },
32}
33
34impl CachePolicy {
35    /// `entries` small documents, an eighth as many large ones (never fewer than one) and
36    /// `DEFAULT_LARGE_BYTES` between the tiers — the shape for a caller who has a rough memory
37    /// budget rather than an opinion about tiers. `0` is [`CachePolicy::Disabled`].
38    pub fn with_entries(entries: usize) -> Self {
39        if entries == 0 {
40            return CachePolicy::Disabled;
41        }
42        CachePolicy::Enabled {
43            entries,
44            large_entries: (entries / 8).max(1),
45            large_bytes: DEFAULT_LARGE_BYTES,
46        }
47    }
48}
49
50/// Roughly how much a parsed document holds, without serializing it: string and field-name lengths
51/// counted for real, every scalar counted as eight bytes. It only has to tell a long `fulltext`
52/// field or a base64 `cypher` envelope from a handful of numbers, which is all the tier split asks
53/// of it.
54fn approx_bytes(record: &HaysonRecord) -> usize {
55    record
56        .iter()
57        .map(|(field, value)| field.len() + value_bytes(value))
58        .sum()
59}
60
61fn value_bytes(value: &Value) -> usize {
62    match value {
63        Value::Null | Value::Bool(_) | Value::Number(_) => 8,
64        Value::String(text) => text.len(),
65        Value::Array(items) => items.iter().map(value_bytes).sum::<usize>() + 8,
66        Value::Object(fields) => {
67            fields
68                .iter()
69                .map(|(field, value)| field.len() + value_bytes(value))
70                .sum::<usize>()
71                + 8
72        }
73    }
74}
75
76struct Node {
77    key: Arc<str>,
78    record: HaysonRecord,
79    prev: Option<usize>,
80    next: Option<usize>,
81}
82
83/// A fixed-capacity LRU over a slab of nodes: the map finds a key, the nodes are threaded
84/// most-recently-used first, and eviction hands its slot back to `free` for the next insert.
85///
86/// Every operation is O(1) on purpose — a cache sized in the tens of thousands must not turn a
87/// read miss into a scan of everything cached, which is the shape a "least recently used wins"
88/// timestamp sweep would have.
89struct Lru {
90    capacity: usize,
91    map: HashMap<Arc<str>, usize>,
92    nodes: Vec<Node>,
93    free: Vec<usize>,
94    head: Option<usize>,
95    tail: Option<usize>,
96}
97
98impl Lru {
99    fn new(capacity: usize) -> Self {
100        Lru {
101            capacity,
102            map: HashMap::new(),
103            nodes: Vec::new(),
104            free: Vec::new(),
105            head: None,
106            tail: None,
107        }
108    }
109
110    fn get(&mut self, key: &str) -> Option<&HaysonRecord> {
111        let at = *self.map.get(key)?;
112        self.unlink(at);
113        self.push_front(at);
114        Some(&self.nodes[at].record)
115    }
116
117    fn insert(&mut self, key: &str, record: HaysonRecord) {
118        if self.capacity == 0 {
119            return;
120        }
121        if let Some(&at) = self.map.get(key) {
122            self.nodes[at].record = record;
123            self.unlink(at);
124            self.push_front(at);
125            return;
126        }
127        if self.map.len() >= self.capacity {
128            self.evict_tail();
129        }
130        let key: Arc<str> = Arc::from(key);
131        let node = Node {
132            key: Arc::clone(&key),
133            record,
134            prev: None,
135            next: None,
136        };
137        let at = match self.free.pop() {
138            Some(at) => {
139                self.nodes[at] = node;
140                at
141            }
142            None => {
143                self.nodes.push(node);
144                self.nodes.len() - 1
145            }
146        };
147        self.map.insert(key, at);
148        self.push_front(at);
149    }
150
151    fn remove(&mut self, key: &str) {
152        let Some(at) = self.map.remove(key) else { return };
153        self.unlink(at);
154        self.release(at);
155    }
156
157    fn clear(&mut self) {
158        self.map.clear();
159        self.nodes.clear();
160        self.free.clear();
161        self.head = None;
162        self.tail = None;
163    }
164
165    #[cfg(test)]
166    fn len(&self) -> usize {
167        self.map.len()
168    }
169
170    fn evict_tail(&mut self) {
171        let Some(at) = self.tail else { return };
172        let key = Arc::clone(&self.nodes[at].key);
173        self.map.remove(&key);
174        self.unlink(at);
175        self.release(at);
176    }
177
178    /// Empties the slot without shrinking the slab, so the document it held stops taking memory
179    /// before another insert reuses the slot.
180    fn release(&mut self, at: usize) {
181        self.nodes[at].key = Arc::from("");
182        self.nodes[at].record = HaysonRecord::new();
183        self.free.push(at);
184    }
185
186    // NOTE: only sound for a node currently in the list — `prev == None` is read as "this is head".
187    fn unlink(&mut self, at: usize) {
188        let prev = self.nodes[at].prev.take();
189        let next = self.nodes[at].next.take();
190        match prev {
191            Some(prev) => self.nodes[prev].next = next,
192            None => self.head = next,
193        }
194        match next {
195            Some(next) => self.nodes[next].prev = prev,
196            None => self.tail = prev,
197        }
198    }
199
200    fn push_front(&mut self, at: usize) {
201        self.nodes[at].prev = None;
202        self.nodes[at].next = self.head;
203        if let Some(head) = self.head {
204            self.nodes[head].prev = Some(at);
205        }
206        self.head = Some(at);
207        if self.tail.is_none() {
208            self.tail = Some(at);
209        }
210    }
211}
212
213struct Tiers {
214    small: Lru,
215    large: Lru,
216}
217
218/// The bounded cache of parsed documents a table reads through, in the two tiers
219/// [`CachePolicy`] describes. A key lives in exactly one of them, chosen by [`approx_bytes`] of the
220/// record when it was cached.
221///
222/// Interior mutability, because `Table::get` takes `&self` and a cache hit still has to move the
223/// key it hit to the front of the list. A `Mutex` rather than a `RefCell`: `Database` is
224/// `Send + Sync` and pinned as such by a test. Nothing but this module's own code ever runs under
225/// the lock, so it is a leaf — no other lock is ever taken while it is held.
226pub(crate) struct RecordCache {
227    policy: CachePolicy,
228    tiers: Mutex<Tiers>,
229}
230
231impl RecordCache {
232    pub(crate) fn new(policy: CachePolicy) -> Self {
233        let (entries, large_entries) = match policy {
234            CachePolicy::Disabled => (0, 0),
235            CachePolicy::Enabled {
236                entries, large_entries, ..
237            } => (entries, large_entries),
238        };
239        RecordCache {
240            policy,
241            tiers: Mutex::new(Tiers {
242                small: Lru::new(entries),
243                large: Lru::new(large_entries),
244            }),
245        }
246    }
247
248    pub(crate) fn policy(&self) -> CachePolicy {
249        self.policy
250    }
251
252    fn enabled(&self) -> bool {
253        self.policy != CachePolicy::Disabled
254    }
255
256    /// The cached document under `key`, moved to the front of its tier. Cloned out: the record
257    /// cannot leave the lock by reference, and cloning a parsed document is still what a read of
258    /// the log would end up doing after slicing and parsing the line.
259    pub(crate) fn get(&self, key: &str) -> Option<HaysonRecord> {
260        if !self.enabled() {
261            return None;
262        }
263        let mut tiers = self.locked();
264        if let Some(record) = tiers.small.get(key) {
265            return Some(record.clone());
266        }
267        tiers.large.get(key).cloned()
268    }
269
270    pub(crate) fn insert(&self, key: &str, record: &HaysonRecord) {
271        let CachePolicy::Enabled { large_bytes, .. } = self.policy else {
272            return;
273        };
274        let large = approx_bytes(record) >= large_bytes;
275        let mut tiers = self.locked();
276        let tier = if large { &mut tiers.large } else { &mut tiers.small };
277        tier.insert(key, record.clone());
278    }
279
280    pub(crate) fn invalidate(&self, key: &str) {
281        if !self.enabled() {
282            return;
283        }
284        let mut tiers = self.locked();
285        tiers.small.remove(key);
286        tiers.large.remove(key);
287    }
288
289    pub(crate) fn clear(&self) {
290        if !self.enabled() {
291            return;
292        }
293        let mut tiers = self.locked();
294        tiers.small.clear();
295        tiers.large.clear();
296    }
297
298    #[cfg(test)]
299    pub(crate) fn len(&self) -> (usize, usize) {
300        let tiers = self.locked();
301        (tiers.small.len(), tiers.large.len())
302    }
303
304    // NOTE: a poisoned cache is taken anyway — dropping an invalidation here would serve stale docs.
305    fn locked(&self) -> std::sync::MutexGuard<'_, Tiers> {
306        self.tiers.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use serde_json::json;
313
314    use super::*;
315
316    fn record(name: &str) -> HaysonRecord {
317        let mut record = HaysonRecord::new();
318        record.insert("name".to_string(), json!(name));
319        record
320    }
321
322    fn big(bytes: usize) -> HaysonRecord {
323        let mut record = HaysonRecord::new();
324        record.insert("body".to_string(), json!("x".repeat(bytes)));
325        record
326    }
327
328    fn cache_of(entries: usize, large_entries: usize) -> RecordCache {
329        RecordCache::new(CachePolicy::Enabled {
330            entries,
331            large_entries,
332            large_bytes: DEFAULT_LARGE_BYTES,
333        })
334    }
335
336    #[test]
337    fn a_disabled_cache_answers_nothing_and_keeps_nothing() {
338        let cache = RecordCache::new(CachePolicy::Disabled);
339        cache.insert("u1", &record("Ania"));
340        assert_eq!(cache.get("u1"), None);
341        assert_eq!(cache.len(), (0, 0));
342    }
343
344    #[test]
345    fn with_entries_derives_a_smaller_large_tier_and_reads_zero_as_disabled() {
346        assert_eq!(CachePolicy::with_entries(0), CachePolicy::Disabled);
347        assert_eq!(
348            CachePolicy::with_entries(64),
349            CachePolicy::Enabled {
350                entries: 64,
351                large_entries: 8,
352                large_bytes: DEFAULT_LARGE_BYTES,
353            }
354        );
355        assert_eq!(
356            CachePolicy::with_entries(1),
357            CachePolicy::Enabled {
358                entries: 1,
359                large_entries: 1,
360                large_bytes: DEFAULT_LARGE_BYTES,
361            }
362        );
363    }
364
365    #[test]
366    fn what_was_inserted_reads_back_until_it_is_invalidated() {
367        let cache = cache_of(4, 2);
368        cache.insert("u1", &record("Ania"));
369        assert_eq!(cache.get("u1"), Some(record("Ania")));
370        cache.invalidate("u1");
371        assert_eq!(cache.get("u1"), None);
372    }
373
374    #[test]
375    fn a_second_insert_on_one_key_replaces_the_document_without_taking_a_second_slot() {
376        let cache = cache_of(4, 2);
377        cache.insert("u1", &record("Ania"));
378        cache.insert("u1", &record("Zosia"));
379        assert_eq!(cache.get("u1"), Some(record("Zosia")));
380        assert_eq!(cache.len(), (1, 0));
381    }
382
383    #[test]
384    fn a_full_tier_evicts_the_least_recently_read_key_and_keeps_the_one_just_read() {
385        let cache = cache_of(2, 1);
386        cache.insert("u1", &record("Ania"));
387        cache.insert("u2", &record("Piotr"));
388        // NOTE: this read is what makes u2, not u1, the eviction candidate.
389        assert_eq!(cache.get("u1"), Some(record("Ania")));
390        cache.insert("u3", &record("Zosia"));
391
392        assert_eq!(cache.get("u2"), None);
393        assert_eq!(cache.get("u1"), Some(record("Ania")));
394        assert_eq!(cache.get("u3"), Some(record("Zosia")));
395        assert_eq!(cache.len(), (2, 0));
396    }
397
398    #[test]
399    fn a_large_document_lands_in_its_own_tier_and_evicts_only_large_ones() {
400        let cache = cache_of(2, 1);
401        cache.insert("u1", &record("Ania"));
402        cache.insert("d1", &big(DEFAULT_LARGE_BYTES));
403        cache.insert("d2", &big(DEFAULT_LARGE_BYTES));
404
405        assert_eq!(cache.len(), (1, 1));
406        assert_eq!(cache.get("d1"), None);
407        assert_eq!(cache.get("d2"), Some(big(DEFAULT_LARGE_BYTES)));
408        assert_eq!(cache.get("u1"), Some(record("Ania")));
409    }
410
411    #[test]
412    fn a_document_one_byte_under_the_line_is_still_a_small_one() {
413        let cache = cache_of(2, 1);
414        cache.insert("d1", &big(DEFAULT_LARGE_BYTES - "body".len() - 1));
415        assert_eq!(cache.len(), (1, 0));
416    }
417
418    #[test]
419    fn clear_empties_both_tiers() {
420        let cache = cache_of(2, 1);
421        cache.insert("u1", &record("Ania"));
422        cache.insert("d1", &big(DEFAULT_LARGE_BYTES));
423        cache.clear();
424        assert_eq!(cache.len(), (0, 0));
425        assert_eq!(cache.get("u1"), None);
426        assert_eq!(cache.get("d1"), None);
427    }
428
429    #[test]
430    fn invalidating_a_key_nobody_cached_changes_nothing() {
431        let cache = cache_of(2, 1);
432        cache.insert("u1", &record("Ania"));
433        cache.invalidate("u404");
434        assert_eq!(cache.len(), (1, 0));
435        assert_eq!(cache.get("u1"), Some(record("Ania")));
436    }
437
438    #[test]
439    fn a_tier_keeps_its_capacity_across_eviction_and_reinsertion() {
440        let cache = cache_of(2, 1);
441        for round in 0..10 {
442            cache.insert(&format!("u{round}"), &record("Ania"));
443            assert!(cache.len().0 <= 2);
444        }
445        assert_eq!(cache.len(), (2, 0));
446        assert_eq!(cache.get("u9"), Some(record("Ania")));
447        assert_eq!(cache.get("u8"), Some(record("Ania")));
448        assert_eq!(cache.get("u7"), None);
449    }
450
451    #[test]
452    fn approx_bytes_counts_strings_for_real_and_nested_values_too() {
453        let mut record = HaysonRecord::new();
454        record.insert("id".to_string(), json!("u1"));
455        record.insert("tags".to_string(), json!(["aaa", "bbb"]));
456        record.insert("meta".to_string(), json!({ "note": "hello" }));
457        assert_eq!(approx_bytes(&record), 2 + 2 + 4 + (3 + 3 + 8) + 4 + (4 + 5 + 8));
458    }
459}