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::sync::Arc;
11use tokio::sync::RwLock;
12
13const DEFAULT_MAX_ENTITIES_PER_VIEW: usize = 500;
14const DEFAULT_MAX_ARRAY_LENGTH: usize = 100;
15const DEFAULT_INITIAL_SNAPSHOT_BATCH_SIZE: usize = 50;
16const DEFAULT_SUBSEQUENT_SNAPSHOT_BATCH_SIZE: usize = 100;
17/// Evicted keys remembered per cached entity, for a source that does not mark
18/// creations (see [`PatchOrigin::Unknown`]). Such a source can keep far more
19/// entities than a view caches (a VM keeps 2,500 per state table by default
20/// against 500 here), so a key the cache evicted can keep receiving patches
21/// long afterwards; eight times the cache bound covers the VM's default table
22/// with room to spare. A key evicted longer ago than that is taken for new.
23///
24/// A source that marks creations needs no memory: an unmarked patch for a key
25/// a view does not hold is refused however long ago the key left, or whether
26/// it was ever there.
27const EVICTED_KEYS_PER_CACHED_ENTITY: usize = 8;
28
29/// Whether a patch creates its entity, as far as its source says.
30///
31/// Every patch carries only the fields that changed. For a key a view holds
32/// that is all it needs; for one it does not hold, the patch is the entity
33/// only if it created it.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PatchOrigin {
36    /// The source marked the patch as its entity's creation
37    /// ([`arete_interpreter::WholeEntity::Created`]), so it is all of the
38    /// entity.
39    Creation,
40    /// The source marks every creation and did not mark this patch, so it
41    /// changes an entity created earlier and is only part of it. A VM linked
42    /// through [`crate::EntityResync`] is such a source.
43    Change,
44    /// The source does not mark creations. A patch for a key the view does not
45    /// hold is taken for a new entity unless the view remembers evicting it.
46    Unknown,
47}
48
49/// What [`EntityCache::upsert_with_append`] did with a patch.
50#[derive(Debug, Clone, PartialEq)]
51pub enum CacheWrite {
52    /// Merged into the entity the cache already held.
53    Merged,
54    /// Stored as a new entity: the patch created it, or its source does not
55    /// say and the cache has no record of the key.
56    Created,
57    /// Not stored. The cache does not hold the key and the patch carries only
58    /// the fields that changed — part of an entity, not one: its source marks
59    /// creations and did not mark this one, or the cache evicted the key. The
60    /// patch is handed back for callers that hold a full copy elsewhere.
61    Refused { patch: Value },
62}
63
64/// One view's cached entities and the keys it evicted to stay bounded.
65///
66/// Both maps are unbounded `LruCache`s capped by hand: a bounded one
67/// allocates its full capacity up front, which for the evicted-key memory is
68/// several times the entity bound, per view, whether or not anything is ever
69/// evicted from it.
70struct ViewEntries {
71    max_entities: usize,
72    entities: LruCache<String, Value>,
73    /// Keys evicted for space, most recently evicted or refused first.
74    /// Allocated on the first eviction, and never for a view fed by a source
75    /// that marks creations.
76    evicted: Option<LruCache<String, ()>>,
77    /// Whether a source that marks creations writes to this view. Such a
78    /// source never consults `evicted`.
79    creations_marked: bool,
80}
81
82impl ViewEntries {
83    fn new(max_entities: usize) -> Self {
84        assert!(max_entities > 0, "max_entities_per_view must be > 0");
85        Self {
86            max_entities,
87            entities: LruCache::unbounded(),
88            evicted: None,
89            creations_marked: false,
90        }
91    }
92
93    /// Note a patch from a source that marks creations: this view no longer
94    /// needs to remember its evictions.
95    fn mark_creations(&mut self) {
96        self.creations_marked = true;
97        self.evicted = None;
98    }
99
100    /// Store `entity` under `key`, remembering whatever it pushes out.
101    fn store(&mut self, key: String, entity: Value) {
102        self.forget_evicted(&key);
103        self.entities.put(key, entity);
104        while self.entities.len() > self.max_entities {
105            match self.entities.pop_lru() {
106                Some((evicted, _)) => self.remember_evicted(evicted),
107                None => break,
108            }
109        }
110    }
111
112    fn remember_evicted(&mut self, key: String) {
113        if self.creations_marked {
114            return;
115        }
116        let capacity = self
117            .max_entities
118            .saturating_mul(EVICTED_KEYS_PER_CACHED_ENTITY);
119        let evicted = self.evicted.get_or_insert_with(LruCache::unbounded);
120        evicted.put(key, ());
121        while evicted.len() > capacity {
122            evicted.pop_lru();
123        }
124    }
125
126    fn forget_evicted(&mut self, key: &str) {
127        if let Some(evicted) = self.evicted.as_mut() {
128            evicted.pop(key);
129        }
130    }
131
132    /// Whether `key` was evicted and is still remembered. Asking refreshes
133    /// it, so an evicted key that keeps changing is not forgotten.
134    fn was_evicted(&mut self, key: &str) -> bool {
135        self.evicted
136            .as_mut()
137            .is_some_and(|evicted| evicted.get(key).is_some())
138    }
139}
140
141/// Compare two `_seq` values numerically.
142/// `_seq` format is "{slot}:{offset}" where slot is not zero-padded.
143/// This handles digit-count boundaries correctly (e.g., 99999999 < 100000000).
144pub fn cmp_seq(a: &str, b: &str) -> std::cmp::Ordering {
145    fn parse(s: &str) -> (u64, u64) {
146        let mut parts = s.splitn(2, ':');
147        let slot = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
148        let offset = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
149        (slot, offset)
150    }
151    parse(a).cmp(&parse(b))
152}
153
154/// Configuration for the entity cache
155#[derive(Debug, Clone)]
156pub struct EntityCacheConfig {
157    /// Maximum number of entities to cache per view
158    pub max_entities_per_view: usize,
159    /// Maximum array length before oldest elements are evicted
160    pub max_array_length: usize,
161    /// Number of entities to send in the first snapshot batch (for fast initial render)
162    pub initial_snapshot_batch_size: usize,
163    /// Number of entities to send in subsequent snapshot batches
164    pub subsequent_snapshot_batch_size: usize,
165}
166
167impl Default for EntityCacheConfig {
168    fn default() -> Self {
169        Self {
170            max_entities_per_view: DEFAULT_MAX_ENTITIES_PER_VIEW,
171            max_array_length: DEFAULT_MAX_ARRAY_LENGTH,
172            initial_snapshot_batch_size: DEFAULT_INITIAL_SNAPSHOT_BATCH_SIZE,
173            subsequent_snapshot_batch_size: DEFAULT_SUBSEQUENT_SNAPSHOT_BATCH_SIZE,
174        }
175    }
176}
177
178/// Entity cache that maintains full projected entities with LRU eviction.
179///
180/// The cache is populated as mutations flow through the projector, regardless
181/// of subscriber state. When a new subscriber connects, they receive snapshots
182/// of all cached entities for their requested view.
183///
184/// # Only whole entities
185///
186/// Source mutations carry only the fields that changed. A patch for a key a
187/// view holds merges into the whole entity. A patch for a key it does not
188/// hold is stored only if it is the whole entity: storing a few changed
189/// fields would serve them as though they were the entity, in snapshots and
190/// in every derived view.
191///
192/// A VM linked through [`crate::EntityResync`] marks the mutation that creates
193/// each entity ([`PatchOrigin::Creation`]), which is stored as-is. Any other
194/// patch from it for a key a view does not hold ([`PatchOrigin::Change`]) is
195/// refused with [`CacheWrite::Refused`] — whether the view evicted the key or
196/// never held it, as after a restore. The projector then asks the VM for the
197/// whole entity, which follows in the VM's next batch and is stored with
198/// [`EntityCache::store_whole`]. That holds however many entities the VM
199/// keeps and however long ago the view evicted the key.
200///
201/// A source that marks nothing ([`PatchOrigin::Unknown`]) leaves a new key and
202/// an evicted one looking alike. Each view therefore remembers the keys it
203/// evicted (bounded, most recent first) and refuses patches for them; a key
204/// evicted so long ago that it has been forgotten is taken for new. A source
205/// delete also ends an eviction.
206#[derive(Clone)]
207pub struct EntityCache {
208    /// view_id -> LRU<entity_key, full_projected_entity>, plus evicted keys
209    caches: Arc<RwLock<HashMap<String, ViewEntries>>>,
210    config: EntityCacheConfig,
211}
212
213impl EntityCache {
214    /// Create a new entity cache with default configuration
215    pub fn new() -> Self {
216        Self::with_config(EntityCacheConfig::default())
217    }
218
219    /// Create a new entity cache with custom configuration
220    pub fn with_config(config: EntityCacheConfig) -> Self {
221        Self {
222            caches: Arc::new(RwLock::new(HashMap::new())),
223            config,
224        }
225    }
226
227    /// Maximum number of entities cached per view.
228    ///
229    /// Derived sorted view caches are bounded by the same value so they never
230    /// hold more entities than their source view caches.
231    pub fn max_entities_per_view(&self) -> usize {
232        self.config.max_entities_per_view
233    }
234
235    /// Write a patch from a source that does not mark creations
236    /// ([`PatchOrigin::Unknown`]).
237    pub async fn upsert(&self, view_id: &str, key: &str, patch: Value) -> CacheWrite {
238        self.upsert_with_append(view_id, key, patch, &[], PatchOrigin::Unknown)
239            .await
240    }
241
242    /// Merge `patch` into the cached entity, store it as a new entity, or
243    /// refuse it as part of an entity the cache does not hold (see the type
244    /// docs).
245    pub async fn upsert_with_append(
246        &self,
247        view_id: &str,
248        key: &str,
249        patch: Value,
250        append_paths: &[String],
251        origin: PatchOrigin,
252    ) -> CacheWrite {
253        let mut caches = self.caches.write().await;
254
255        let view = caches
256            .entry(view_id.to_string())
257            .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
258        if origin != PatchOrigin::Unknown && !view.creations_marked {
259            view.mark_creations();
260        }
261
262        let max_array_length = self.config.max_array_length;
263
264        if let Some(entity) = view.entities.get_mut(key) {
265            deep_merge_with_append(entity, patch, append_paths, max_array_length);
266            return CacheWrite::Merged;
267        }
268        let whole = match origin {
269            PatchOrigin::Creation => true,
270            PatchOrigin::Change => false,
271            PatchOrigin::Unknown => !view.was_evicted(key),
272        };
273        if !whole {
274            return CacheWrite::Refused { patch };
275        }
276        let new_entity = truncate_arrays_if_needed(patch, max_array_length);
277        view.store(key.to_string(), new_entity);
278        CacheWrite::Created
279    }
280
281    /// Store `entity` as the whole entity for `key`, replacing anything held
282    /// and clearing an eviction: the source vouched for it being complete
283    /// (see [`arete_interpreter::Mutation::mark_whole_entity`]).
284    pub async fn store_whole(&self, view_id: &str, key: &str, entity: Value) {
285        let mut caches = self.caches.write().await;
286        let view = caches
287            .entry(view_id.to_string())
288            .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
289        let entity = truncate_arrays_if_needed(entity, self.config.max_array_length);
290        view.store(key.to_string(), entity);
291    }
292
293    /// Merge a source patch into `base` with this cache's append and array
294    /// rules, for callers that keep their own full copy of an entity.
295    pub fn merge_patch(&self, base: &mut Value, patch: Value, append_paths: &[String]) {
296        deep_merge_with_append(base, patch, append_paths, self.config.max_array_length);
297    }
298
299    /// Treat `keys` as evicted from `view_id` unless the view holds them, so
300    /// patches for them from a source that does not mark creations are
301    /// refused. Used after a restore, when the VM holds entities the restored
302    /// cache does not; a VM that marks creations makes this moot, and the
303    /// view drops the memory with its first patch.
304    pub async fn remember_evicted(&self, view_id: &str, keys: impl IntoIterator<Item = String>) {
305        let mut caches = self.caches.write().await;
306        let view = caches
307            .entry(view_id.to_string())
308            .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
309        for key in keys {
310            if !view.entities.contains(&key) {
311                view.remember_evicted(key);
312            }
313        }
314    }
315
316    /// Get all cached entities for a view.
317    ///
318    /// Returns a vector of (key, entity) pairs for sending as snapshots
319    /// to new subscribers.
320    pub async fn get_all(&self, view_id: &str) -> Vec<(String, Value)> {
321        let caches = self.caches.read().await;
322
323        caches
324            .get(view_id)
325            .map(|view| {
326                view.entities
327                    .iter()
328                    .map(|(k, v)| (k.clone(), v.clone()))
329                    .collect()
330            })
331            .unwrap_or_default()
332    }
333
334    /// Get entities with _seq greater than the provided cursor.
335    ///
336    /// Returns entities that have been updated after the given cursor,
337    /// sorted by _seq in ascending order. Useful for resuming from
338    /// a specific point in the stream.
339    pub async fn get_after(
340        &self,
341        view_id: &str,
342        cursor: &str,
343        limit: Option<usize>,
344    ) -> Vec<(String, Value)> {
345        let caches = self.caches.read().await;
346
347        if let Some(view) = caches.get(view_id) {
348            let mut results: Vec<(String, Value)> = view
349                .entities
350                .iter()
351                .filter(|(_, entity)| {
352                    entity
353                        .get("_seq")
354                        .and_then(|s| s.as_str())
355                        .map(|seq| cmp_seq(seq, cursor) == std::cmp::Ordering::Greater)
356                        .unwrap_or(false)
357                })
358                .map(|(k, v)| (k.clone(), v.clone()))
359                .collect();
360
361            // Sort by _seq (ascending)
362            results.sort_by(|a, b| {
363                let seq_a = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
364                let seq_b = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
365                cmp_seq(seq_a, seq_b)
366            });
367
368            // Apply limit if provided
369            if let Some(limit) = limit {
370                results.truncate(limit);
371            }
372
373            results
374        } else {
375            vec![]
376        }
377    }
378
379    /// Get a specific entity from the cache
380    pub async fn get(&self, view_id: &str, key: &str) -> Option<Value> {
381        let caches = self.caches.read().await;
382        caches
383            .get(view_id)
384            .and_then(|view| view.entities.peek(key).cloned())
385    }
386
387    /// Remove one entity after a source-wide delete.
388    ///
389    /// The delete ends the entity, so the key is also forgotten as evicted: a
390    /// later patch for it creates a new entity.
391    pub async fn remove(&self, view_id: &str, key: &str) -> Option<Value> {
392        let mut caches = self.caches.write().await;
393        caches.get_mut(view_id).and_then(|view| {
394            view.forget_evicted(key);
395            view.entities.pop(key)
396        })
397    }
398
399    /// Get the number of cached entities for a view
400    pub async fn len(&self, view_id: &str) -> usize {
401        let caches = self.caches.read().await;
402        caches
403            .get(view_id)
404            .map(|view| view.entities.len())
405            .unwrap_or(0)
406    }
407
408    /// Check if the cache for a view is empty
409    pub async fn is_empty(&self, view_id: &str) -> bool {
410        self.len(view_id).await == 0
411    }
412
413    /// Get the snapshot batch configuration
414    pub fn snapshot_config(&self) -> SnapshotBatchConfig {
415        SnapshotBatchConfig {
416            initial_batch_size: self.config.initial_snapshot_batch_size,
417            subsequent_batch_size: self.config.subsequent_snapshot_batch_size,
418        }
419    }
420
421    /// Clear all cached entities for a view
422    pub async fn clear(&self, view_id: &str) {
423        let mut caches = self.caches.write().await;
424        if let Some(view) = caches.get_mut(view_id) {
425            view.entities.clear();
426            view.evicted = None;
427        }
428    }
429
430    pub async fn clear_all(&self) {
431        let mut caches = self.caches.write().await;
432        caches.clear();
433    }
434
435    /// Dump every view's entities for a state snapshot.
436    ///
437    /// Entries are ordered most-recently-used first; [`Self::hydrate`] relies
438    /// on that to reconstruct LRU eviction order.
439    pub async fn dump(&self) -> Vec<(String, Vec<(String, Value)>)> {
440        let caches = self.caches.read().await;
441        caches
442            .iter()
443            .map(|(view_id, view)| {
444                (
445                    view_id.clone(),
446                    view.entities
447                        .iter()
448                        .map(|(key, entity)| (key.clone(), entity.clone()))
449                        .collect(),
450                )
451            })
452            .collect()
453    }
454
455    /// Restore entities dumped by [`Self::dump`], preserving LRU order.
456    ///
457    /// Entities are inserted as-is (no merge): a snapshot holds fully
458    /// projected entities, not patches.
459    pub async fn hydrate(&self, views: Vec<(String, Vec<(String, Value)>)>) {
460        let mut caches = self.caches.write().await;
461        for (view_id, entries) in views {
462            let view = caches
463                .entry(view_id)
464                .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
465            for (key, entity) in entries.into_iter().rev() {
466                view.store(key, entity);
467            }
468        }
469    }
470
471    pub async fn stats(&self) -> CacheStats {
472        let caches = self.caches.read().await;
473        let mut total_entities = 0;
474        let mut views = Vec::new();
475
476        for (view_id, view) in caches.iter() {
477            let count = view.entities.len();
478            total_entities += count;
479            views.push((view_id.clone(), count));
480        }
481
482        views.sort_by_key(|b| std::cmp::Reverse(b.1));
483
484        CacheStats {
485            view_count: caches.len(),
486            total_entities,
487            top_views: views.into_iter().take(5).collect(),
488        }
489    }
490}
491
492/// A point-in-time count of an [`EntityCache`]'s contents.
493#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct CacheStats {
495    pub view_count: usize,
496    pub total_entities: usize,
497    pub top_views: Vec<(String, usize)>,
498}
499
500#[derive(Debug, Clone, Copy)]
501pub struct SnapshotBatchConfig {
502    pub initial_batch_size: usize,
503    pub subsequent_batch_size: usize,
504}
505
506impl Default for EntityCache {
507    fn default() -> Self {
508        Self::new()
509    }
510}
511
512fn deep_merge_with_append(
513    base: &mut Value,
514    patch: Value,
515    append_paths: &[String],
516    max_array_length: usize,
517) {
518    deep_merge_with_append_inner(base, patch, append_paths, "", max_array_length);
519}
520
521fn deep_merge_with_append_inner(
522    base: &mut Value,
523    patch: Value,
524    append_paths: &[String],
525    current_path: &str,
526    max_array_length: usize,
527) {
528    match (base, patch) {
529        (Value::Object(base_map), Value::Object(patch_map)) => {
530            for (key, patch_value) in patch_map {
531                let child_path = if current_path.is_empty() {
532                    key.clone()
533                } else {
534                    format!("{}.{}", current_path, key)
535                };
536
537                if let Some(base_value) = base_map.get_mut(&key) {
538                    deep_merge_with_append_inner(
539                        base_value,
540                        patch_value,
541                        append_paths,
542                        &child_path,
543                        max_array_length,
544                    );
545                } else {
546                    base_map.insert(
547                        key,
548                        truncate_arrays_if_needed(patch_value, max_array_length),
549                    );
550                }
551            }
552        }
553
554        (Value::Array(base_arr), Value::Array(patch_arr)) => {
555            let should_append = append_paths.iter().any(|p| p == current_path);
556            if should_append {
557                base_arr.extend(patch_arr);
558                if base_arr.len() > max_array_length {
559                    let excess = base_arr.len() - max_array_length;
560                    base_arr.drain(0..excess);
561                }
562            } else {
563                *base_arr = patch_arr;
564                if base_arr.len() > max_array_length {
565                    let excess = base_arr.len() - max_array_length;
566                    base_arr.drain(0..excess);
567                }
568            }
569        }
570
571        (base, patch_value) => {
572            *base = truncate_arrays_if_needed(patch_value, max_array_length);
573        }
574    }
575}
576
577/// Recursively truncate any arrays in a value to the max length
578fn truncate_arrays_if_needed(value: Value, max_array_length: usize) -> Value {
579    match value {
580        Value::Array(mut arr) => {
581            // Truncate this array if needed
582            if arr.len() > max_array_length {
583                let excess = arr.len() - max_array_length;
584                arr.drain(0..excess);
585            }
586            // Recursively process elements
587            Value::Array(
588                arr.into_iter()
589                    .map(|v| truncate_arrays_if_needed(v, max_array_length))
590                    .collect(),
591            )
592        }
593        Value::Object(map) => Value::Object(
594            map.into_iter()
595                .map(|(k, v)| (k, truncate_arrays_if_needed(v, max_array_length)))
596                .collect(),
597        ),
598        other => other,
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use serde_json::json;
606
607    #[tokio::test]
608    async fn test_basic_upsert_and_get() {
609        let cache = EntityCache::new();
610
611        cache
612            .upsert("tokens/list", "abc123", json!({"name": "Test Token"}))
613            .await;
614
615        let entity = cache.get("tokens/list", "abc123").await;
616        assert!(entity.is_some());
617        assert_eq!(entity.unwrap()["name"], "Test Token");
618    }
619
620    #[tokio::test]
621    async fn test_deep_merge_objects() {
622        let cache = EntityCache::new();
623
624        cache
625            .upsert(
626                "tokens/list",
627                "abc123",
628                json!({
629                    "id": "abc123",
630                    "metrics": {"volume": 100}
631                }),
632            )
633            .await;
634
635        cache
636            .upsert(
637                "tokens/list",
638                "abc123",
639                json!({
640                    "metrics": {"trades": 50}
641                }),
642            )
643            .await;
644
645        let entity = cache.get("tokens/list", "abc123").await.unwrap();
646        assert_eq!(entity["id"], "abc123");
647        assert_eq!(entity["metrics"]["volume"], 100);
648        assert_eq!(entity["metrics"]["trades"], 50);
649    }
650
651    #[tokio::test]
652    async fn test_array_append() {
653        let cache = EntityCache::new();
654
655        cache
656            .upsert(
657                "tokens/list",
658                "abc123",
659                json!({
660                    "events": [{"type": "buy", "amount": 100}]
661                }),
662            )
663            .await;
664
665        cache
666            .upsert_with_append(
667                "tokens/list",
668                "abc123",
669                json!({
670                    "events": [{"type": "sell", "amount": 50}]
671                }),
672                &["events".to_string()],
673                PatchOrigin::Unknown,
674            )
675            .await;
676
677        let entity = cache.get("tokens/list", "abc123").await.unwrap();
678        let events = entity["events"].as_array().unwrap();
679        assert_eq!(events.len(), 2);
680        assert_eq!(events[0]["type"], "buy");
681        assert_eq!(events[1]["type"], "sell");
682    }
683
684    #[tokio::test]
685    async fn test_array_lru_eviction() {
686        let config = EntityCacheConfig {
687            max_entities_per_view: 1000,
688            max_array_length: 3,
689            ..Default::default()
690        };
691        let cache = EntityCache::with_config(config);
692
693        cache
694            .upsert(
695                "tokens/list",
696                "abc123",
697                json!({
698                    "events": [
699                        {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}
700                    ]
701                }),
702            )
703            .await;
704
705        let entity = cache.get("tokens/list", "abc123").await.unwrap();
706        let events = entity["events"].as_array().unwrap();
707
708        assert_eq!(events.len(), 3);
709        assert_eq!(events[0]["id"], 3);
710        assert_eq!(events[1]["id"], 4);
711        assert_eq!(events[2]["id"], 5);
712    }
713
714    #[tokio::test]
715    async fn test_array_append_with_lru() {
716        let config = EntityCacheConfig {
717            max_entities_per_view: 1000,
718            max_array_length: 3,
719            ..Default::default()
720        };
721        let cache = EntityCache::with_config(config);
722
723        cache
724            .upsert(
725                "tokens/list",
726                "abc123",
727                json!({
728                    "events": [{"id": 1}, {"id": 2}]
729                }),
730            )
731            .await;
732
733        cache
734            .upsert_with_append(
735                "tokens/list",
736                "abc123",
737                json!({
738                    "events": [{"id": 3}, {"id": 4}]
739                }),
740                &["events".to_string()],
741                PatchOrigin::Unknown,
742            )
743            .await;
744
745        let entity = cache.get("tokens/list", "abc123").await.unwrap();
746        let events = entity["events"].as_array().unwrap();
747
748        // [1,2] + [3,4] = [1,2,3,4] → LRU(3) = [2,3,4]
749        assert_eq!(events.len(), 3);
750        assert_eq!(events[0]["id"], 2);
751        assert_eq!(events[1]["id"], 3);
752        assert_eq!(events[2]["id"], 4);
753    }
754
755    #[tokio::test]
756    async fn test_entity_lru_eviction() {
757        let config = EntityCacheConfig {
758            max_entities_per_view: 2,
759            max_array_length: 100,
760            ..Default::default()
761        };
762        let cache = EntityCache::with_config(config);
763
764        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
765        cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
766        cache.upsert("tokens/list", "key3", json!({"id": 3})).await;
767
768        assert!(cache.get("tokens/list", "key1").await.is_none());
769        assert!(cache.get("tokens/list", "key2").await.is_some());
770        assert!(cache.get("tokens/list", "key3").await.is_some());
771    }
772
773    #[tokio::test]
774    async fn a_patch_for_an_evicted_key_is_refused_not_stored() {
775        let cache = EntityCache::with_config(EntityCacheConfig {
776            max_entities_per_view: 2,
777            ..Default::default()
778        });
779        for id in 1..=3 {
780            assert_eq!(
781                cache
782                    .upsert(
783                        "tokens/list",
784                        &format!("key{id}"),
785                        json!({"id": id, "n": 0})
786                    )
787                    .await,
788                CacheWrite::Created
789            );
790        }
791        // key1 was evicted; this carries only what changed.
792        assert_eq!(
793            cache.upsert("tokens/list", "key1", json!({"n": 5})).await,
794            CacheWrite::Refused {
795                patch: json!({"n": 5})
796            }
797        );
798        assert!(cache.get("tokens/list", "key1").await.is_none());
799        assert_eq!(
800            cache.upsert("tokens/list", "key3", json!({"n": 1})).await,
801            CacheWrite::Merged
802        );
803
804        // A source delete ends the entity: it may then be created again.
805        cache.remove("tokens/list", "key1").await;
806        assert_eq!(
807            cache
808                .upsert("tokens/list", "key1", json!({"id": 1, "n": 6}))
809                .await,
810            CacheWrite::Created
811        );
812    }
813
814    #[tokio::test]
815    async fn a_whole_entity_ends_an_eviction() {
816        let cache = EntityCache::with_config(EntityCacheConfig {
817            max_entities_per_view: 1,
818            ..Default::default()
819        });
820        cache.upsert("v", "a", json!({"id": "a", "n": 0})).await;
821        cache.upsert("v", "b", json!({"id": "b", "n": 0})).await;
822        assert!(matches!(
823            cache.upsert("v", "a", json!({"n": 1})).await,
824            CacheWrite::Refused { .. }
825        ));
826        cache
827            .store_whole("v", "a", json!({"id": "a", "n": 1}))
828            .await;
829        assert_eq!(cache.get("v", "a").await, Some(json!({"id": "a", "n": 1})));
830        assert_eq!(cache.len("v").await, 1, "still bounded: b made room");
831        assert_eq!(
832            cache.upsert("v", "a", json!({"n": 2})).await,
833            CacheWrite::Merged
834        );
835    }
836
837    #[tokio::test]
838    async fn the_evicted_key_memory_is_bounded() {
839        let cache = EntityCache::with_config(EntityCacheConfig {
840            max_entities_per_view: 1,
841            ..Default::default()
842        });
843        let remembered = EVICTED_KEYS_PER_CACHED_ENTITY;
844        for key in 0..=remembered + 1 {
845            cache
846                .upsert("v", &key.to_string(), json!({"id": key}))
847                .await;
848        }
849        // Key 0 was pushed out of the memory by the evictions after it; the
850        // most recent evictions are still remembered.
851        assert_eq!(
852            cache.upsert("v", "0", json!({"id": 0})).await,
853            CacheWrite::Created
854        );
855        assert!(matches!(
856            cache
857                .upsert("v", &remembered.to_string(), json!({"n": 1}))
858                .await,
859            CacheWrite::Refused { .. }
860        ));
861    }
862
863    async fn write(
864        cache: &EntityCache,
865        key: &str,
866        patch: Value,
867        origin: PatchOrigin,
868    ) -> CacheWrite {
869        cache.upsert_with_append("v", key, patch, &[], origin).await
870    }
871
872    /// A source that marks creations says outright which patches are whole:
873    /// no memory of evictions is needed, and none can run out.
874    #[tokio::test]
875    async fn an_unmarked_patch_for_a_key_not_held_is_refused_however_old() {
876        let cache = EntityCache::with_config(EntityCacheConfig {
877            max_entities_per_view: 1,
878            ..Default::default()
879        });
880        let evictions = EVICTED_KEYS_PER_CACHED_ENTITY * 4;
881        for key in 0..=evictions {
882            assert_eq!(
883                write(
884                    &cache,
885                    &key.to_string(),
886                    json!({"id": key}),
887                    PatchOrigin::Creation
888                )
889                .await,
890                CacheWrite::Created,
891                "a creation is stored as soon as it arrives"
892            );
893        }
894        // Key 0 was evicted far longer ago than any memory would reach.
895        assert_eq!(
896            write(&cache, "0", json!({"n": 1}), PatchOrigin::Change).await,
897            CacheWrite::Refused {
898                patch: json!({"n": 1})
899            }
900        );
901        assert!(
902            matches!(
903                write(&cache, "never", json!({"n": 1}), PatchOrigin::Change).await,
904                CacheWrite::Refused { .. }
905            ),
906            "nor does it matter whether the view ever held the key"
907        );
908        assert_eq!(cache.get("v", "0").await, None);
909        assert_eq!(
910            write(
911                &cache,
912                &evictions.to_string(),
913                json!({"n": 1}),
914                PatchOrigin::Change
915            )
916            .await,
917            CacheWrite::Merged
918        );
919        // The source started key 0 again.
920        assert_eq!(
921            write(&cache, "0", json!({"id": 0, "n": 2}), PatchOrigin::Creation).await,
922            CacheWrite::Created
923        );
924        assert_eq!(cache.get("v", "0").await, Some(json!({"id": 0, "n": 2})));
925    }
926
927    /// A creation for a key the view still holds (the source dropped the
928    /// entity and started it again) merges like any patch, as it does for the
929    /// clients holding the key.
930    #[tokio::test]
931    async fn a_creation_for_a_key_held_merges() {
932        let cache = EntityCache::new();
933        write(
934            &cache,
935            "a",
936            json!({"id": "a", "old": 1}),
937            PatchOrigin::Creation,
938        )
939        .await;
940        assert_eq!(
941            write(
942                &cache,
943                "a",
944                json!({"id": "a", "n": 1}),
945                PatchOrigin::Creation
946            )
947            .await,
948            CacheWrite::Merged
949        );
950        assert_eq!(
951            cache.get("v", "a").await,
952            Some(json!({"id": "a", "old": 1, "n": 1}))
953        );
954    }
955
956    async fn remembers_evictions(cache: &EntityCache, view_id: &str) -> bool {
957        cache
958            .caches
959            .read()
960            .await
961            .get(view_id)
962            .is_some_and(|view| view.evicted.is_some())
963    }
964
965    /// A view fed by a source that marks creations never consults the
966    /// evicted-key memory, so it drops what a restore put there and keeps no
967    /// more.
968    #[tokio::test]
969    async fn a_view_fed_marked_creations_keeps_no_eviction_memory() {
970        let cache = EntityCache::with_config(EntityCacheConfig {
971            max_entities_per_view: 1,
972            ..Default::default()
973        });
974        cache.remember_evicted("v", ["restored".to_string()]).await;
975        assert!(remembers_evictions(&cache, "v").await);
976
977        write(&cache, "a", json!({"id": "a"}), PatchOrigin::Creation).await;
978        assert!(!remembers_evictions(&cache, "v").await);
979        write(&cache, "b", json!({"id": "b"}), PatchOrigin::Creation).await;
980        assert!(!remembers_evictions(&cache, "v").await, "a was evicted");
981        assert!(matches!(
982            write(&cache, "restored", json!({"n": 1}), PatchOrigin::Change).await,
983            CacheWrite::Refused { .. }
984        ));
985        assert!(matches!(
986            write(&cache, "a", json!({"n": 1}), PatchOrigin::Change).await,
987            CacheWrite::Refused { .. }
988        ));
989    }
990
991    #[tokio::test]
992    async fn remembered_keys_are_refused_unless_held() {
993        let cache = EntityCache::new();
994        cache.upsert("tokens/list", "held", json!({"id": 1})).await;
995        cache
996            .remember_evicted("tokens/list", ["held".to_string(), "gone".to_string()])
997            .await;
998        assert_eq!(
999            cache.upsert("tokens/list", "held", json!({"n": 1})).await,
1000            CacheWrite::Merged
1001        );
1002        assert!(matches!(
1003            cache.upsert("tokens/list", "gone", json!({"n": 1})).await,
1004            CacheWrite::Refused { .. }
1005        ));
1006        // Other views are unaffected.
1007        assert_eq!(
1008            cache.upsert("tokens/state", "gone", json!({"id": 2})).await,
1009            CacheWrite::Created
1010        );
1011    }
1012
1013    #[tokio::test]
1014    async fn test_get_all() {
1015        let cache = EntityCache::new();
1016
1017        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
1018        cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
1019
1020        let all = cache.get_all("tokens/list").await;
1021        assert_eq!(all.len(), 2);
1022    }
1023
1024    #[tokio::test]
1025    async fn remove_is_scoped_to_one_entity() {
1026        let cache = EntityCache::new();
1027        cache.upsert("tokens/list", "one", json!({"id": 1})).await;
1028        cache.upsert("tokens/list", "two", json!({"id": 2})).await;
1029
1030        assert_eq!(cache.remove("tokens/list", "one").await.unwrap()["id"], 1);
1031        assert!(cache.get("tokens/list", "one").await.is_none());
1032        assert!(cache.get("tokens/list", "two").await.is_some());
1033    }
1034
1035    #[tokio::test]
1036    async fn test_separate_views() {
1037        let cache = EntityCache::new();
1038
1039        cache
1040            .upsert("tokens/list", "key1", json!({"type": "token"}))
1041            .await;
1042        cache
1043            .upsert("games/list", "key1", json!({"type": "game"}))
1044            .await;
1045
1046        let token = cache.get("tokens/list", "key1").await.unwrap();
1047        let game = cache.get("games/list", "key1").await.unwrap();
1048
1049        assert_eq!(token["type"], "token");
1050        assert_eq!(game["type"], "game");
1051    }
1052
1053    #[test]
1054    fn test_deep_merge_with_append() {
1055        let mut base = json!({
1056            "a": 1,
1057            "b": {"c": 2},
1058            "arr": [1, 2]
1059        });
1060
1061        let patch = json!({
1062            "b": {"d": 3},
1063            "arr": [3],
1064            "e": 4
1065        });
1066
1067        deep_merge_with_append(&mut base, patch, &["arr".to_string()], 100);
1068
1069        assert_eq!(base["a"], 1);
1070        assert_eq!(base["b"]["c"], 2);
1071        assert_eq!(base["b"]["d"], 3);
1072        assert_eq!(base["arr"].as_array().unwrap().len(), 3);
1073        assert_eq!(base["e"], 4);
1074    }
1075
1076    #[test]
1077    fn test_deep_merge_replace_array() {
1078        let mut base = json!({
1079            "arr": [1, 2, 3]
1080        });
1081
1082        let patch = json!({
1083            "arr": [4, 5]
1084        });
1085
1086        deep_merge_with_append(&mut base, patch, &[], 100);
1087
1088        assert_eq!(base["arr"].as_array().unwrap().len(), 2);
1089        assert_eq!(base["arr"][0], 4);
1090        assert_eq!(base["arr"][1], 5);
1091    }
1092
1093    #[test]
1094    fn test_deep_merge_nested_append() {
1095        let mut base = json!({
1096            "stats": {"events": [1, 2]}
1097        });
1098
1099        let patch = json!({
1100            "stats": {"events": [3]}
1101        });
1102
1103        deep_merge_with_append(&mut base, patch, &["stats.events".to_string()], 100);
1104
1105        assert_eq!(base["stats"]["events"].as_array().unwrap().len(), 3);
1106    }
1107
1108    #[test]
1109    fn test_snapshot_config_defaults() {
1110        let cache = EntityCache::new();
1111        let config = cache.snapshot_config();
1112
1113        assert_eq!(config.initial_batch_size, 50);
1114        assert_eq!(config.subsequent_batch_size, 100);
1115    }
1116
1117    #[test]
1118    fn test_snapshot_config_custom() {
1119        let config = EntityCacheConfig {
1120            initial_snapshot_batch_size: 25,
1121            subsequent_snapshot_batch_size: 75,
1122            ..Default::default()
1123        };
1124        let cache = EntityCache::with_config(config);
1125        let snapshot_config = cache.snapshot_config();
1126
1127        assert_eq!(snapshot_config.initial_batch_size, 25);
1128        assert_eq!(snapshot_config.subsequent_batch_size, 75);
1129    }
1130
1131    #[tokio::test]
1132    async fn test_get_after() {
1133        let cache = EntityCache::new();
1134
1135        // Insert entities with _seq values
1136        cache
1137            .upsert(
1138                "tokens/list",
1139                "key1",
1140                json!({"id": 1, "_seq": "100:000000000001"}),
1141            )
1142            .await;
1143        cache
1144            .upsert(
1145                "tokens/list",
1146                "key2",
1147                json!({"id": 2, "_seq": "100:000000000002"}),
1148            )
1149            .await;
1150        cache
1151            .upsert(
1152                "tokens/list",
1153                "key3",
1154                json!({"id": 3, "_seq": "100:000000000003"}),
1155            )
1156            .await;
1157        cache
1158            .upsert(
1159                "tokens/list",
1160                "key4",
1161                json!({"id": 4, "_seq": "101:000000000001"}),
1162            )
1163            .await;
1164
1165        // Get all entities after "100:000000000002"
1166        let after = cache
1167            .get_after("tokens/list", "100:000000000002", None)
1168            .await;
1169
1170        // Should return key3 and key4 (sorted by _seq)
1171        assert_eq!(after.len(), 2);
1172        assert_eq!(after[0].0, "key3");
1173        assert_eq!(after[1].0, "key4");
1174    }
1175
1176    #[tokio::test]
1177    async fn test_get_after_with_limit() {
1178        let cache = EntityCache::new();
1179
1180        // Insert entities with _seq values
1181        cache
1182            .upsert(
1183                "tokens/list",
1184                "key1",
1185                json!({"id": 1, "_seq": "100:000000000001"}),
1186            )
1187            .await;
1188        cache
1189            .upsert(
1190                "tokens/list",
1191                "key2",
1192                json!({"id": 2, "_seq": "100:000000000002"}),
1193            )
1194            .await;
1195        cache
1196            .upsert(
1197                "tokens/list",
1198                "key3",
1199                json!({"id": 3, "_seq": "100:000000000003"}),
1200            )
1201            .await;
1202
1203        // Get entities after "100:000000000000" with limit 2
1204        let after = cache
1205            .get_after("tokens/list", "100:000000000000", Some(2))
1206            .await;
1207
1208        // Should return only first 2 (key1 and key2)
1209        assert_eq!(after.len(), 2);
1210        assert_eq!(after[0].0, "key1");
1211        assert_eq!(after[1].0, "key2");
1212    }
1213
1214    #[tokio::test]
1215    async fn test_get_after_empty_result() {
1216        let cache = EntityCache::new();
1217
1218        cache
1219            .upsert(
1220                "tokens/list",
1221                "key1",
1222                json!({"id": 1, "_seq": "100:000000000001"}),
1223            )
1224            .await;
1225
1226        // Get entities after a future cursor
1227        let after = cache
1228            .get_after("tokens/list", "999:000000000000", None)
1229            .await;
1230
1231        assert!(after.is_empty());
1232    }
1233
1234    #[tokio::test]
1235    async fn test_get_after_missing_seq() {
1236        let cache = EntityCache::new();
1237
1238        // Insert entity without _seq
1239        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
1240
1241        // Get entities after any cursor - entity without _seq should not be included
1242        let after = cache.get_after("tokens/list", "0:000000000000", None).await;
1243
1244        assert!(after.is_empty());
1245    }
1246}