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