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    pub async fn stats(&self) -> CacheStats {
216        let caches = self.caches.read().await;
217        let mut total_entities = 0;
218        let mut views = Vec::new();
219
220        for (view_id, cache) in caches.iter() {
221            let count = cache.len();
222            total_entities += count;
223            views.push((view_id.clone(), count));
224        }
225
226        views.sort_by_key(|b| std::cmp::Reverse(b.1));
227
228        CacheStats {
229            view_count: caches.len(),
230            total_entities,
231            top_views: views.into_iter().take(5).collect(),
232        }
233    }
234}
235
236#[derive(Debug)]
237pub struct CacheStats {
238    pub view_count: usize,
239    pub total_entities: usize,
240    pub top_views: Vec<(String, usize)>,
241}
242
243#[derive(Debug, Clone, Copy)]
244pub struct SnapshotBatchConfig {
245    pub initial_batch_size: usize,
246    pub subsequent_batch_size: usize,
247}
248
249impl Default for EntityCache {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255fn deep_merge_with_append(
256    base: &mut Value,
257    patch: Value,
258    append_paths: &[String],
259    max_array_length: usize,
260) {
261    deep_merge_with_append_inner(base, patch, append_paths, "", max_array_length);
262}
263
264fn deep_merge_with_append_inner(
265    base: &mut Value,
266    patch: Value,
267    append_paths: &[String],
268    current_path: &str,
269    max_array_length: usize,
270) {
271    match (base, patch) {
272        (Value::Object(base_map), Value::Object(patch_map)) => {
273            for (key, patch_value) in patch_map {
274                let child_path = if current_path.is_empty() {
275                    key.clone()
276                } else {
277                    format!("{}.{}", current_path, key)
278                };
279
280                if let Some(base_value) = base_map.get_mut(&key) {
281                    deep_merge_with_append_inner(
282                        base_value,
283                        patch_value,
284                        append_paths,
285                        &child_path,
286                        max_array_length,
287                    );
288                } else {
289                    base_map.insert(
290                        key,
291                        truncate_arrays_if_needed(patch_value, max_array_length),
292                    );
293                }
294            }
295        }
296
297        (Value::Array(base_arr), Value::Array(patch_arr)) => {
298            let should_append = append_paths.iter().any(|p| p == current_path);
299            if should_append {
300                base_arr.extend(patch_arr);
301                if base_arr.len() > max_array_length {
302                    let excess = base_arr.len() - max_array_length;
303                    base_arr.drain(0..excess);
304                }
305            } else {
306                *base_arr = patch_arr;
307                if base_arr.len() > max_array_length {
308                    let excess = base_arr.len() - max_array_length;
309                    base_arr.drain(0..excess);
310                }
311            }
312        }
313
314        (base, patch_value) => {
315            *base = truncate_arrays_if_needed(patch_value, max_array_length);
316        }
317    }
318}
319
320/// Recursively truncate any arrays in a value to the max length
321fn truncate_arrays_if_needed(value: Value, max_array_length: usize) -> Value {
322    match value {
323        Value::Array(mut arr) => {
324            // Truncate this array if needed
325            if arr.len() > max_array_length {
326                let excess = arr.len() - max_array_length;
327                arr.drain(0..excess);
328            }
329            // Recursively process elements
330            Value::Array(
331                arr.into_iter()
332                    .map(|v| truncate_arrays_if_needed(v, max_array_length))
333                    .collect(),
334            )
335        }
336        Value::Object(map) => Value::Object(
337            map.into_iter()
338                .map(|(k, v)| (k, truncate_arrays_if_needed(v, max_array_length)))
339                .collect(),
340        ),
341        other => other,
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use serde_json::json;
349
350    #[tokio::test]
351    async fn test_basic_upsert_and_get() {
352        let cache = EntityCache::new();
353
354        cache
355            .upsert("tokens/list", "abc123", json!({"name": "Test Token"}))
356            .await;
357
358        let entity = cache.get("tokens/list", "abc123").await;
359        assert!(entity.is_some());
360        assert_eq!(entity.unwrap()["name"], "Test Token");
361    }
362
363    #[tokio::test]
364    async fn test_deep_merge_objects() {
365        let cache = EntityCache::new();
366
367        cache
368            .upsert(
369                "tokens/list",
370                "abc123",
371                json!({
372                    "id": "abc123",
373                    "metrics": {"volume": 100}
374                }),
375            )
376            .await;
377
378        cache
379            .upsert(
380                "tokens/list",
381                "abc123",
382                json!({
383                    "metrics": {"trades": 50}
384                }),
385            )
386            .await;
387
388        let entity = cache.get("tokens/list", "abc123").await.unwrap();
389        assert_eq!(entity["id"], "abc123");
390        assert_eq!(entity["metrics"]["volume"], 100);
391        assert_eq!(entity["metrics"]["trades"], 50);
392    }
393
394    #[tokio::test]
395    async fn test_array_append() {
396        let cache = EntityCache::new();
397
398        cache
399            .upsert(
400                "tokens/list",
401                "abc123",
402                json!({
403                    "events": [{"type": "buy", "amount": 100}]
404                }),
405            )
406            .await;
407
408        cache
409            .upsert_with_append(
410                "tokens/list",
411                "abc123",
412                json!({
413                    "events": [{"type": "sell", "amount": 50}]
414                }),
415                &["events".to_string()],
416            )
417            .await;
418
419        let entity = cache.get("tokens/list", "abc123").await.unwrap();
420        let events = entity["events"].as_array().unwrap();
421        assert_eq!(events.len(), 2);
422        assert_eq!(events[0]["type"], "buy");
423        assert_eq!(events[1]["type"], "sell");
424    }
425
426    #[tokio::test]
427    async fn test_array_lru_eviction() {
428        let config = EntityCacheConfig {
429            max_entities_per_view: 1000,
430            max_array_length: 3,
431            ..Default::default()
432        };
433        let cache = EntityCache::with_config(config);
434
435        cache
436            .upsert(
437                "tokens/list",
438                "abc123",
439                json!({
440                    "events": [
441                        {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}
442                    ]
443                }),
444            )
445            .await;
446
447        let entity = cache.get("tokens/list", "abc123").await.unwrap();
448        let events = entity["events"].as_array().unwrap();
449
450        assert_eq!(events.len(), 3);
451        assert_eq!(events[0]["id"], 3);
452        assert_eq!(events[1]["id"], 4);
453        assert_eq!(events[2]["id"], 5);
454    }
455
456    #[tokio::test]
457    async fn test_array_append_with_lru() {
458        let config = EntityCacheConfig {
459            max_entities_per_view: 1000,
460            max_array_length: 3,
461            ..Default::default()
462        };
463        let cache = EntityCache::with_config(config);
464
465        cache
466            .upsert(
467                "tokens/list",
468                "abc123",
469                json!({
470                    "events": [{"id": 1}, {"id": 2}]
471                }),
472            )
473            .await;
474
475        cache
476            .upsert_with_append(
477                "tokens/list",
478                "abc123",
479                json!({
480                    "events": [{"id": 3}, {"id": 4}]
481                }),
482                &["events".to_string()],
483            )
484            .await;
485
486        let entity = cache.get("tokens/list", "abc123").await.unwrap();
487        let events = entity["events"].as_array().unwrap();
488
489        // [1,2] + [3,4] = [1,2,3,4] → LRU(3) = [2,3,4]
490        assert_eq!(events.len(), 3);
491        assert_eq!(events[0]["id"], 2);
492        assert_eq!(events[1]["id"], 3);
493        assert_eq!(events[2]["id"], 4);
494    }
495
496    #[tokio::test]
497    async fn test_entity_lru_eviction() {
498        let config = EntityCacheConfig {
499            max_entities_per_view: 2,
500            max_array_length: 100,
501            ..Default::default()
502        };
503        let cache = EntityCache::with_config(config);
504
505        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
506        cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
507        cache.upsert("tokens/list", "key3", json!({"id": 3})).await;
508
509        assert!(cache.get("tokens/list", "key1").await.is_none());
510        assert!(cache.get("tokens/list", "key2").await.is_some());
511        assert!(cache.get("tokens/list", "key3").await.is_some());
512    }
513
514    #[tokio::test]
515    async fn test_get_all() {
516        let cache = EntityCache::new();
517
518        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
519        cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
520
521        let all = cache.get_all("tokens/list").await;
522        assert_eq!(all.len(), 2);
523    }
524
525    #[tokio::test]
526    async fn remove_is_scoped_to_one_entity() {
527        let cache = EntityCache::new();
528        cache.upsert("tokens/list", "one", json!({"id": 1})).await;
529        cache.upsert("tokens/list", "two", json!({"id": 2})).await;
530
531        assert_eq!(cache.remove("tokens/list", "one").await.unwrap()["id"], 1);
532        assert!(cache.get("tokens/list", "one").await.is_none());
533        assert!(cache.get("tokens/list", "two").await.is_some());
534    }
535
536    #[tokio::test]
537    async fn test_separate_views() {
538        let cache = EntityCache::new();
539
540        cache
541            .upsert("tokens/list", "key1", json!({"type": "token"}))
542            .await;
543        cache
544            .upsert("games/list", "key1", json!({"type": "game"}))
545            .await;
546
547        let token = cache.get("tokens/list", "key1").await.unwrap();
548        let game = cache.get("games/list", "key1").await.unwrap();
549
550        assert_eq!(token["type"], "token");
551        assert_eq!(game["type"], "game");
552    }
553
554    #[test]
555    fn test_deep_merge_with_append() {
556        let mut base = json!({
557            "a": 1,
558            "b": {"c": 2},
559            "arr": [1, 2]
560        });
561
562        let patch = json!({
563            "b": {"d": 3},
564            "arr": [3],
565            "e": 4
566        });
567
568        deep_merge_with_append(&mut base, patch, &["arr".to_string()], 100);
569
570        assert_eq!(base["a"], 1);
571        assert_eq!(base["b"]["c"], 2);
572        assert_eq!(base["b"]["d"], 3);
573        assert_eq!(base["arr"].as_array().unwrap().len(), 3);
574        assert_eq!(base["e"], 4);
575    }
576
577    #[test]
578    fn test_deep_merge_replace_array() {
579        let mut base = json!({
580            "arr": [1, 2, 3]
581        });
582
583        let patch = json!({
584            "arr": [4, 5]
585        });
586
587        deep_merge_with_append(&mut base, patch, &[], 100);
588
589        assert_eq!(base["arr"].as_array().unwrap().len(), 2);
590        assert_eq!(base["arr"][0], 4);
591        assert_eq!(base["arr"][1], 5);
592    }
593
594    #[test]
595    fn test_deep_merge_nested_append() {
596        let mut base = json!({
597            "stats": {"events": [1, 2]}
598        });
599
600        let patch = json!({
601            "stats": {"events": [3]}
602        });
603
604        deep_merge_with_append(&mut base, patch, &["stats.events".to_string()], 100);
605
606        assert_eq!(base["stats"]["events"].as_array().unwrap().len(), 3);
607    }
608
609    #[test]
610    fn test_snapshot_config_defaults() {
611        let cache = EntityCache::new();
612        let config = cache.snapshot_config();
613
614        assert_eq!(config.initial_batch_size, 50);
615        assert_eq!(config.subsequent_batch_size, 100);
616    }
617
618    #[test]
619    fn test_snapshot_config_custom() {
620        let config = EntityCacheConfig {
621            initial_snapshot_batch_size: 25,
622            subsequent_snapshot_batch_size: 75,
623            ..Default::default()
624        };
625        let cache = EntityCache::with_config(config);
626        let snapshot_config = cache.snapshot_config();
627
628        assert_eq!(snapshot_config.initial_batch_size, 25);
629        assert_eq!(snapshot_config.subsequent_batch_size, 75);
630    }
631
632    #[tokio::test]
633    async fn test_get_after() {
634        let cache = EntityCache::new();
635
636        // Insert entities with _seq values
637        cache
638            .upsert(
639                "tokens/list",
640                "key1",
641                json!({"id": 1, "_seq": "100:000000000001"}),
642            )
643            .await;
644        cache
645            .upsert(
646                "tokens/list",
647                "key2",
648                json!({"id": 2, "_seq": "100:000000000002"}),
649            )
650            .await;
651        cache
652            .upsert(
653                "tokens/list",
654                "key3",
655                json!({"id": 3, "_seq": "100:000000000003"}),
656            )
657            .await;
658        cache
659            .upsert(
660                "tokens/list",
661                "key4",
662                json!({"id": 4, "_seq": "101:000000000001"}),
663            )
664            .await;
665
666        // Get all entities after "100:000000000002"
667        let after = cache
668            .get_after("tokens/list", "100:000000000002", None)
669            .await;
670
671        // Should return key3 and key4 (sorted by _seq)
672        assert_eq!(after.len(), 2);
673        assert_eq!(after[0].0, "key3");
674        assert_eq!(after[1].0, "key4");
675    }
676
677    #[tokio::test]
678    async fn test_get_after_with_limit() {
679        let cache = EntityCache::new();
680
681        // Insert entities with _seq values
682        cache
683            .upsert(
684                "tokens/list",
685                "key1",
686                json!({"id": 1, "_seq": "100:000000000001"}),
687            )
688            .await;
689        cache
690            .upsert(
691                "tokens/list",
692                "key2",
693                json!({"id": 2, "_seq": "100:000000000002"}),
694            )
695            .await;
696        cache
697            .upsert(
698                "tokens/list",
699                "key3",
700                json!({"id": 3, "_seq": "100:000000000003"}),
701            )
702            .await;
703
704        // Get entities after "100:000000000000" with limit 2
705        let after = cache
706            .get_after("tokens/list", "100:000000000000", Some(2))
707            .await;
708
709        // Should return only first 2 (key1 and key2)
710        assert_eq!(after.len(), 2);
711        assert_eq!(after[0].0, "key1");
712        assert_eq!(after[1].0, "key2");
713    }
714
715    #[tokio::test]
716    async fn test_get_after_empty_result() {
717        let cache = EntityCache::new();
718
719        cache
720            .upsert(
721                "tokens/list",
722                "key1",
723                json!({"id": 1, "_seq": "100:000000000001"}),
724            )
725            .await;
726
727        // Get entities after a future cursor
728        let after = cache
729            .get_after("tokens/list", "999:000000000000", None)
730            .await;
731
732        assert!(after.is_empty());
733    }
734
735    #[tokio::test]
736    async fn test_get_after_missing_seq() {
737        let cache = EntityCache::new();
738
739        // Insert entity without _seq
740        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
741
742        // Get entities after any cursor - entity without _seq should not be included
743        let after = cache.get_after("tokens/list", "0:000000000000", None).await;
744
745        assert!(after.is_empty());
746    }
747}