Skip to main content

arete_server/
cache.rs

1//! Entity cache for snapshot-on-subscribe functionality.
2//!
3//! This module provides an `EntityCache` that maintains full projected entities
4//! in memory with LRU eviction. When a new client subscribes, they receive
5//! cached snapshots immediately rather than waiting for the next live mutation.
6
7use lru::LruCache;
8use serde_json::Value;
9use std::collections::HashMap;
10use std::num::NonZeroUsize;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13
14const DEFAULT_MAX_ENTITIES_PER_VIEW: usize = 500;
15const DEFAULT_MAX_ARRAY_LENGTH: usize = 100;
16const DEFAULT_INITIAL_SNAPSHOT_BATCH_SIZE: usize = 50;
17const DEFAULT_SUBSEQUENT_SNAPSHOT_BATCH_SIZE: usize = 100;
18
19/// Compare two `_seq` values numerically.
20/// `_seq` format is "{slot}:{offset}" where slot is not zero-padded.
21/// This handles digit-count boundaries correctly (e.g., 99999999 < 100000000).
22pub fn cmp_seq(a: &str, b: &str) -> std::cmp::Ordering {
23    fn parse(s: &str) -> (u64, u64) {
24        let mut parts = s.splitn(2, ':');
25        let slot = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
26        let offset = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
27        (slot, offset)
28    }
29    parse(a).cmp(&parse(b))
30}
31
32/// Configuration for the entity cache
33#[derive(Debug, Clone)]
34pub struct EntityCacheConfig {
35    /// Maximum number of entities to cache per view
36    pub max_entities_per_view: usize,
37    /// Maximum array length before oldest elements are evicted
38    pub max_array_length: usize,
39    /// Number of entities to send in the first snapshot batch (for fast initial render)
40    pub initial_snapshot_batch_size: usize,
41    /// Number of entities to send in subsequent snapshot batches
42    pub subsequent_snapshot_batch_size: usize,
43}
44
45impl Default for EntityCacheConfig {
46    fn default() -> Self {
47        Self {
48            max_entities_per_view: DEFAULT_MAX_ENTITIES_PER_VIEW,
49            max_array_length: DEFAULT_MAX_ARRAY_LENGTH,
50            initial_snapshot_batch_size: DEFAULT_INITIAL_SNAPSHOT_BATCH_SIZE,
51            subsequent_snapshot_batch_size: DEFAULT_SUBSEQUENT_SNAPSHOT_BATCH_SIZE,
52        }
53    }
54}
55
56/// Entity cache that maintains full projected entities with LRU eviction.
57///
58/// The cache is populated as mutations flow through the projector, regardless
59/// of subscriber state. When a new subscriber connects, they receive snapshots
60/// of all cached entities for their requested view.
61#[derive(Clone)]
62pub struct EntityCache {
63    /// view_id -> LRU<entity_key, full_projected_entity>
64    caches: Arc<RwLock<HashMap<String, LruCache<String, Value>>>>,
65    config: EntityCacheConfig,
66}
67
68impl EntityCache {
69    /// Create a new entity cache with default configuration
70    pub fn new() -> Self {
71        Self::with_config(EntityCacheConfig::default())
72    }
73
74    /// Create a new entity cache with custom configuration
75    pub fn with_config(config: EntityCacheConfig) -> Self {
76        Self {
77            caches: Arc::new(RwLock::new(HashMap::new())),
78            config,
79        }
80    }
81
82    /// Maximum number of entities cached per view.
83    ///
84    /// Derived sorted view caches are bounded by the same value so they never
85    /// hold more entities than their source view caches.
86    pub fn max_entities_per_view(&self) -> usize {
87        self.config.max_entities_per_view
88    }
89
90    pub async fn upsert(&self, view_id: &str, key: &str, patch: Value) {
91        self.upsert_with_append(view_id, key, patch, &[]).await;
92    }
93
94    pub async fn upsert_with_append(
95        &self,
96        view_id: &str,
97        key: &str,
98        patch: Value,
99        append_paths: &[String],
100    ) {
101        let mut caches = self.caches.write().await;
102
103        let cache = caches.entry(view_id.to_string()).or_insert_with(|| {
104            LruCache::new(
105                NonZeroUsize::new(self.config.max_entities_per_view)
106                    .expect("max_entities_per_view must be > 0"),
107            )
108        });
109
110        let max_array_length = self.config.max_array_length;
111
112        if let Some(entity) = cache.get_mut(key) {
113            deep_merge_with_append(entity, patch, append_paths, max_array_length);
114        } else {
115            let new_entity = truncate_arrays_if_needed(patch, max_array_length);
116            cache.put(key.to_string(), new_entity);
117        }
118    }
119
120    /// Get all cached entities for a view.
121    ///
122    /// Returns a vector of (key, entity) pairs for sending as snapshots
123    /// to new subscribers.
124    pub async fn get_all(&self, view_id: &str) -> Vec<(String, Value)> {
125        let caches = self.caches.read().await;
126
127        caches
128            .get(view_id)
129            .map(|cache| cache.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
130            .unwrap_or_default()
131    }
132
133    /// Get entities with _seq greater than the provided cursor.
134    ///
135    /// Returns entities that have been updated after the given cursor,
136    /// sorted by _seq in ascending order. Useful for resuming from
137    /// a specific point in the stream.
138    pub async fn get_after(
139        &self,
140        view_id: &str,
141        cursor: &str,
142        limit: Option<usize>,
143    ) -> Vec<(String, Value)> {
144        let caches = self.caches.read().await;
145
146        if let Some(cache) = caches.get(view_id) {
147            let mut results: Vec<(String, Value)> = cache
148                .iter()
149                .filter(|(_, entity)| {
150                    entity
151                        .get("_seq")
152                        .and_then(|s| s.as_str())
153                        .map(|seq| cmp_seq(seq, cursor) == std::cmp::Ordering::Greater)
154                        .unwrap_or(false)
155                })
156                .map(|(k, v)| (k.clone(), v.clone()))
157                .collect();
158
159            // Sort by _seq (ascending)
160            results.sort_by(|a, b| {
161                let seq_a = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
162                let seq_b = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
163                cmp_seq(seq_a, seq_b)
164            });
165
166            // Apply limit if provided
167            if let Some(limit) = limit {
168                results.truncate(limit);
169            }
170
171            results
172        } else {
173            vec![]
174        }
175    }
176
177    /// Get a specific entity from the cache
178    pub async fn get(&self, view_id: &str, key: &str) -> Option<Value> {
179        let caches = self.caches.read().await;
180        caches
181            .get(view_id)
182            .and_then(|cache| cache.peek(key).cloned())
183    }
184
185    /// Remove one entity after a source-wide delete.
186    pub async fn remove(&self, view_id: &str, key: &str) -> Option<Value> {
187        let mut caches = self.caches.write().await;
188        caches.get_mut(view_id).and_then(|cache| cache.pop(key))
189    }
190
191    /// Get the number of cached entities for a view
192    pub async fn len(&self, view_id: &str) -> usize {
193        let caches = self.caches.read().await;
194        caches.get(view_id).map(|c| c.len()).unwrap_or(0)
195    }
196
197    /// Check if the cache for a view is empty
198    pub async fn is_empty(&self, view_id: &str) -> bool {
199        self.len(view_id).await == 0
200    }
201
202    /// Get the snapshot batch configuration
203    pub fn snapshot_config(&self) -> SnapshotBatchConfig {
204        SnapshotBatchConfig {
205            initial_batch_size: self.config.initial_snapshot_batch_size,
206            subsequent_batch_size: self.config.subsequent_snapshot_batch_size,
207        }
208    }
209
210    /// Clear all cached entities for a view
211    pub async fn clear(&self, view_id: &str) {
212        let mut caches = self.caches.write().await;
213        if let Some(cache) = caches.get_mut(view_id) {
214            cache.clear();
215        }
216    }
217
218    pub async fn clear_all(&self) {
219        let mut caches = self.caches.write().await;
220        caches.clear();
221    }
222
223    /// Dump every view's entities for a state snapshot.
224    ///
225    /// Entries are ordered most-recently-used first; [`Self::hydrate`] relies
226    /// on that to reconstruct LRU eviction order.
227    pub async fn dump(&self) -> Vec<(String, Vec<(String, Value)>)> {
228        let caches = self.caches.read().await;
229        caches
230            .iter()
231            .map(|(view_id, cache)| {
232                (
233                    view_id.clone(),
234                    cache
235                        .iter()
236                        .map(|(key, entity)| (key.clone(), entity.clone()))
237                        .collect(),
238                )
239            })
240            .collect()
241    }
242
243    /// Restore entities dumped by [`Self::dump`], preserving LRU order.
244    ///
245    /// Entities are inserted as-is (no merge): a snapshot holds fully
246    /// projected entities, not patches.
247    pub async fn hydrate(&self, views: Vec<(String, Vec<(String, Value)>)>) {
248        let mut caches = self.caches.write().await;
249        for (view_id, entries) in views {
250            let cache = caches.entry(view_id).or_insert_with(|| {
251                LruCache::new(
252                    NonZeroUsize::new(self.config.max_entities_per_view)
253                        .expect("max_entities_per_view must be > 0"),
254                )
255            });
256            for (key, entity) in entries.into_iter().rev() {
257                cache.put(key, entity);
258            }
259        }
260    }
261
262    pub async fn stats(&self) -> CacheStats {
263        let caches = self.caches.read().await;
264        let mut total_entities = 0;
265        let mut views = Vec::new();
266
267        for (view_id, cache) in caches.iter() {
268            let count = cache.len();
269            total_entities += count;
270            views.push((view_id.clone(), count));
271        }
272
273        views.sort_by_key(|b| std::cmp::Reverse(b.1));
274
275        CacheStats {
276            view_count: caches.len(),
277            total_entities,
278            top_views: views.into_iter().take(5).collect(),
279        }
280    }
281}
282
283/// A point-in-time count of an [`EntityCache`]'s contents.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct CacheStats {
286    pub view_count: usize,
287    pub total_entities: usize,
288    pub top_views: Vec<(String, usize)>,
289}
290
291#[derive(Debug, Clone, Copy)]
292pub struct SnapshotBatchConfig {
293    pub initial_batch_size: usize,
294    pub subsequent_batch_size: usize,
295}
296
297impl Default for EntityCache {
298    fn default() -> Self {
299        Self::new()
300    }
301}
302
303fn deep_merge_with_append(
304    base: &mut Value,
305    patch: Value,
306    append_paths: &[String],
307    max_array_length: usize,
308) {
309    deep_merge_with_append_inner(base, patch, append_paths, "", max_array_length);
310}
311
312fn deep_merge_with_append_inner(
313    base: &mut Value,
314    patch: Value,
315    append_paths: &[String],
316    current_path: &str,
317    max_array_length: usize,
318) {
319    match (base, patch) {
320        (Value::Object(base_map), Value::Object(patch_map)) => {
321            for (key, patch_value) in patch_map {
322                let child_path = if current_path.is_empty() {
323                    key.clone()
324                } else {
325                    format!("{}.{}", current_path, key)
326                };
327
328                if let Some(base_value) = base_map.get_mut(&key) {
329                    deep_merge_with_append_inner(
330                        base_value,
331                        patch_value,
332                        append_paths,
333                        &child_path,
334                        max_array_length,
335                    );
336                } else {
337                    base_map.insert(
338                        key,
339                        truncate_arrays_if_needed(patch_value, max_array_length),
340                    );
341                }
342            }
343        }
344
345        (Value::Array(base_arr), Value::Array(patch_arr)) => {
346            let should_append = append_paths.iter().any(|p| p == current_path);
347            if should_append {
348                base_arr.extend(patch_arr);
349                if base_arr.len() > max_array_length {
350                    let excess = base_arr.len() - max_array_length;
351                    base_arr.drain(0..excess);
352                }
353            } else {
354                *base_arr = patch_arr;
355                if base_arr.len() > max_array_length {
356                    let excess = base_arr.len() - max_array_length;
357                    base_arr.drain(0..excess);
358                }
359            }
360        }
361
362        (base, patch_value) => {
363            *base = truncate_arrays_if_needed(patch_value, max_array_length);
364        }
365    }
366}
367
368/// Recursively truncate any arrays in a value to the max length
369fn truncate_arrays_if_needed(value: Value, max_array_length: usize) -> Value {
370    match value {
371        Value::Array(mut arr) => {
372            // Truncate this array if needed
373            if arr.len() > max_array_length {
374                let excess = arr.len() - max_array_length;
375                arr.drain(0..excess);
376            }
377            // Recursively process elements
378            Value::Array(
379                arr.into_iter()
380                    .map(|v| truncate_arrays_if_needed(v, max_array_length))
381                    .collect(),
382            )
383        }
384        Value::Object(map) => Value::Object(
385            map.into_iter()
386                .map(|(k, v)| (k, truncate_arrays_if_needed(v, max_array_length)))
387                .collect(),
388        ),
389        other => other,
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use serde_json::json;
397
398    #[tokio::test]
399    async fn test_basic_upsert_and_get() {
400        let cache = EntityCache::new();
401
402        cache
403            .upsert("tokens/list", "abc123", json!({"name": "Test Token"}))
404            .await;
405
406        let entity = cache.get("tokens/list", "abc123").await;
407        assert!(entity.is_some());
408        assert_eq!(entity.unwrap()["name"], "Test Token");
409    }
410
411    #[tokio::test]
412    async fn test_deep_merge_objects() {
413        let cache = EntityCache::new();
414
415        cache
416            .upsert(
417                "tokens/list",
418                "abc123",
419                json!({
420                    "id": "abc123",
421                    "metrics": {"volume": 100}
422                }),
423            )
424            .await;
425
426        cache
427            .upsert(
428                "tokens/list",
429                "abc123",
430                json!({
431                    "metrics": {"trades": 50}
432                }),
433            )
434            .await;
435
436        let entity = cache.get("tokens/list", "abc123").await.unwrap();
437        assert_eq!(entity["id"], "abc123");
438        assert_eq!(entity["metrics"]["volume"], 100);
439        assert_eq!(entity["metrics"]["trades"], 50);
440    }
441
442    #[tokio::test]
443    async fn test_array_append() {
444        let cache = EntityCache::new();
445
446        cache
447            .upsert(
448                "tokens/list",
449                "abc123",
450                json!({
451                    "events": [{"type": "buy", "amount": 100}]
452                }),
453            )
454            .await;
455
456        cache
457            .upsert_with_append(
458                "tokens/list",
459                "abc123",
460                json!({
461                    "events": [{"type": "sell", "amount": 50}]
462                }),
463                &["events".to_string()],
464            )
465            .await;
466
467        let entity = cache.get("tokens/list", "abc123").await.unwrap();
468        let events = entity["events"].as_array().unwrap();
469        assert_eq!(events.len(), 2);
470        assert_eq!(events[0]["type"], "buy");
471        assert_eq!(events[1]["type"], "sell");
472    }
473
474    #[tokio::test]
475    async fn test_array_lru_eviction() {
476        let config = EntityCacheConfig {
477            max_entities_per_view: 1000,
478            max_array_length: 3,
479            ..Default::default()
480        };
481        let cache = EntityCache::with_config(config);
482
483        cache
484            .upsert(
485                "tokens/list",
486                "abc123",
487                json!({
488                    "events": [
489                        {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}
490                    ]
491                }),
492            )
493            .await;
494
495        let entity = cache.get("tokens/list", "abc123").await.unwrap();
496        let events = entity["events"].as_array().unwrap();
497
498        assert_eq!(events.len(), 3);
499        assert_eq!(events[0]["id"], 3);
500        assert_eq!(events[1]["id"], 4);
501        assert_eq!(events[2]["id"], 5);
502    }
503
504    #[tokio::test]
505    async fn test_array_append_with_lru() {
506        let config = EntityCacheConfig {
507            max_entities_per_view: 1000,
508            max_array_length: 3,
509            ..Default::default()
510        };
511        let cache = EntityCache::with_config(config);
512
513        cache
514            .upsert(
515                "tokens/list",
516                "abc123",
517                json!({
518                    "events": [{"id": 1}, {"id": 2}]
519                }),
520            )
521            .await;
522
523        cache
524            .upsert_with_append(
525                "tokens/list",
526                "abc123",
527                json!({
528                    "events": [{"id": 3}, {"id": 4}]
529                }),
530                &["events".to_string()],
531            )
532            .await;
533
534        let entity = cache.get("tokens/list", "abc123").await.unwrap();
535        let events = entity["events"].as_array().unwrap();
536
537        // [1,2] + [3,4] = [1,2,3,4] → LRU(3) = [2,3,4]
538        assert_eq!(events.len(), 3);
539        assert_eq!(events[0]["id"], 2);
540        assert_eq!(events[1]["id"], 3);
541        assert_eq!(events[2]["id"], 4);
542    }
543
544    #[tokio::test]
545    async fn test_entity_lru_eviction() {
546        let config = EntityCacheConfig {
547            max_entities_per_view: 2,
548            max_array_length: 100,
549            ..Default::default()
550        };
551        let cache = EntityCache::with_config(config);
552
553        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
554        cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
555        cache.upsert("tokens/list", "key3", json!({"id": 3})).await;
556
557        assert!(cache.get("tokens/list", "key1").await.is_none());
558        assert!(cache.get("tokens/list", "key2").await.is_some());
559        assert!(cache.get("tokens/list", "key3").await.is_some());
560    }
561
562    #[tokio::test]
563    async fn test_get_all() {
564        let cache = EntityCache::new();
565
566        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
567        cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
568
569        let all = cache.get_all("tokens/list").await;
570        assert_eq!(all.len(), 2);
571    }
572
573    #[tokio::test]
574    async fn remove_is_scoped_to_one_entity() {
575        let cache = EntityCache::new();
576        cache.upsert("tokens/list", "one", json!({"id": 1})).await;
577        cache.upsert("tokens/list", "two", json!({"id": 2})).await;
578
579        assert_eq!(cache.remove("tokens/list", "one").await.unwrap()["id"], 1);
580        assert!(cache.get("tokens/list", "one").await.is_none());
581        assert!(cache.get("tokens/list", "two").await.is_some());
582    }
583
584    #[tokio::test]
585    async fn test_separate_views() {
586        let cache = EntityCache::new();
587
588        cache
589            .upsert("tokens/list", "key1", json!({"type": "token"}))
590            .await;
591        cache
592            .upsert("games/list", "key1", json!({"type": "game"}))
593            .await;
594
595        let token = cache.get("tokens/list", "key1").await.unwrap();
596        let game = cache.get("games/list", "key1").await.unwrap();
597
598        assert_eq!(token["type"], "token");
599        assert_eq!(game["type"], "game");
600    }
601
602    #[test]
603    fn test_deep_merge_with_append() {
604        let mut base = json!({
605            "a": 1,
606            "b": {"c": 2},
607            "arr": [1, 2]
608        });
609
610        let patch = json!({
611            "b": {"d": 3},
612            "arr": [3],
613            "e": 4
614        });
615
616        deep_merge_with_append(&mut base, patch, &["arr".to_string()], 100);
617
618        assert_eq!(base["a"], 1);
619        assert_eq!(base["b"]["c"], 2);
620        assert_eq!(base["b"]["d"], 3);
621        assert_eq!(base["arr"].as_array().unwrap().len(), 3);
622        assert_eq!(base["e"], 4);
623    }
624
625    #[test]
626    fn test_deep_merge_replace_array() {
627        let mut base = json!({
628            "arr": [1, 2, 3]
629        });
630
631        let patch = json!({
632            "arr": [4, 5]
633        });
634
635        deep_merge_with_append(&mut base, patch, &[], 100);
636
637        assert_eq!(base["arr"].as_array().unwrap().len(), 2);
638        assert_eq!(base["arr"][0], 4);
639        assert_eq!(base["arr"][1], 5);
640    }
641
642    #[test]
643    fn test_deep_merge_nested_append() {
644        let mut base = json!({
645            "stats": {"events": [1, 2]}
646        });
647
648        let patch = json!({
649            "stats": {"events": [3]}
650        });
651
652        deep_merge_with_append(&mut base, patch, &["stats.events".to_string()], 100);
653
654        assert_eq!(base["stats"]["events"].as_array().unwrap().len(), 3);
655    }
656
657    #[test]
658    fn test_snapshot_config_defaults() {
659        let cache = EntityCache::new();
660        let config = cache.snapshot_config();
661
662        assert_eq!(config.initial_batch_size, 50);
663        assert_eq!(config.subsequent_batch_size, 100);
664    }
665
666    #[test]
667    fn test_snapshot_config_custom() {
668        let config = EntityCacheConfig {
669            initial_snapshot_batch_size: 25,
670            subsequent_snapshot_batch_size: 75,
671            ..Default::default()
672        };
673        let cache = EntityCache::with_config(config);
674        let snapshot_config = cache.snapshot_config();
675
676        assert_eq!(snapshot_config.initial_batch_size, 25);
677        assert_eq!(snapshot_config.subsequent_batch_size, 75);
678    }
679
680    #[tokio::test]
681    async fn test_get_after() {
682        let cache = EntityCache::new();
683
684        // Insert entities with _seq values
685        cache
686            .upsert(
687                "tokens/list",
688                "key1",
689                json!({"id": 1, "_seq": "100:000000000001"}),
690            )
691            .await;
692        cache
693            .upsert(
694                "tokens/list",
695                "key2",
696                json!({"id": 2, "_seq": "100:000000000002"}),
697            )
698            .await;
699        cache
700            .upsert(
701                "tokens/list",
702                "key3",
703                json!({"id": 3, "_seq": "100:000000000003"}),
704            )
705            .await;
706        cache
707            .upsert(
708                "tokens/list",
709                "key4",
710                json!({"id": 4, "_seq": "101:000000000001"}),
711            )
712            .await;
713
714        // Get all entities after "100:000000000002"
715        let after = cache
716            .get_after("tokens/list", "100:000000000002", None)
717            .await;
718
719        // Should return key3 and key4 (sorted by _seq)
720        assert_eq!(after.len(), 2);
721        assert_eq!(after[0].0, "key3");
722        assert_eq!(after[1].0, "key4");
723    }
724
725    #[tokio::test]
726    async fn test_get_after_with_limit() {
727        let cache = EntityCache::new();
728
729        // Insert entities with _seq values
730        cache
731            .upsert(
732                "tokens/list",
733                "key1",
734                json!({"id": 1, "_seq": "100:000000000001"}),
735            )
736            .await;
737        cache
738            .upsert(
739                "tokens/list",
740                "key2",
741                json!({"id": 2, "_seq": "100:000000000002"}),
742            )
743            .await;
744        cache
745            .upsert(
746                "tokens/list",
747                "key3",
748                json!({"id": 3, "_seq": "100:000000000003"}),
749            )
750            .await;
751
752        // Get entities after "100:000000000000" with limit 2
753        let after = cache
754            .get_after("tokens/list", "100:000000000000", Some(2))
755            .await;
756
757        // Should return only first 2 (key1 and key2)
758        assert_eq!(after.len(), 2);
759        assert_eq!(after[0].0, "key1");
760        assert_eq!(after[1].0, "key2");
761    }
762
763    #[tokio::test]
764    async fn test_get_after_empty_result() {
765        let cache = EntityCache::new();
766
767        cache
768            .upsert(
769                "tokens/list",
770                "key1",
771                json!({"id": 1, "_seq": "100:000000000001"}),
772            )
773            .await;
774
775        // Get entities after a future cursor
776        let after = cache
777            .get_after("tokens/list", "999:000000000000", None)
778            .await;
779
780        assert!(after.is_empty());
781    }
782
783    #[tokio::test]
784    async fn test_get_after_missing_seq() {
785        let cache = EntityCache::new();
786
787        // Insert entity without _seq
788        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
789
790        // Get entities after any cursor - entity without _seq should not be included
791        let after = cache.get_after("tokens/list", "0:000000000000", None).await;
792
793        assert!(after.is_empty());
794    }
795}