1use 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;
17const EVICTED_KEYS_PER_CACHED_ENTITY: usize = 8;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PatchOrigin {
36 Creation,
40 Change,
44 Unknown,
47}
48
49#[derive(Debug, Clone, PartialEq)]
51pub enum CacheWrite {
52 Merged,
54 Created,
57 Refused { patch: Value },
62}
63
64struct ViewEntries {
71 max_entities: usize,
72 entities: LruCache<String, Value>,
73 evicted: Option<LruCache<String, ()>>,
77 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 fn mark_creations(&mut self) {
96 self.creations_marked = true;
97 self.evicted = None;
98 }
99
100 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 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
141pub 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#[derive(Debug, Clone)]
156pub struct EntityCacheConfig {
157 pub max_entities_per_view: usize,
159 pub max_array_length: usize,
161 pub initial_snapshot_batch_size: usize,
163 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#[derive(Clone)]
207pub struct EntityCache {
208 caches: Arc<RwLock<HashMap<String, ViewEntries>>>,
210 config: EntityCacheConfig,
211}
212
213impl EntityCache {
214 pub fn new() -> Self {
216 Self::with_config(EntityCacheConfig::default())
217 }
218
219 pub fn with_config(config: EntityCacheConfig) -> Self {
221 Self {
222 caches: Arc::new(RwLock::new(HashMap::new())),
223 config,
224 }
225 }
226
227 pub fn max_entities_per_view(&self) -> usize {
232 self.config.max_entities_per_view
233 }
234
235 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 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 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 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 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 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 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 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 if let Some(limit) = limit {
370 results.truncate(limit);
371 }
372
373 results
374 } else {
375 vec![]
376 }
377 }
378
379 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 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 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 pub async fn is_empty(&self, view_id: &str) -> bool {
410 self.len(view_id).await == 0
411 }
412
413 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 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 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 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#[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
577fn truncate_arrays_if_needed(value: Value, max_array_length: usize) -> Value {
579 match value {
580 Value::Array(mut arr) => {
581 if arr.len() > max_array_length {
583 let excess = arr.len() - max_array_length;
584 arr.drain(0..excess);
585 }
586 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 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 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 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 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 #[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 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 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 #[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 #[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 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 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 let after = cache
1167 .get_after("tokens/list", "100:000000000002", None)
1168 .await;
1169
1170 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 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 let after = cache
1205 .get_after("tokens/list", "100:000000000000", Some(2))
1206 .await;
1207
1208 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 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 cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
1240
1241 let after = cache.get_after("tokens/list", "0:000000000000", None).await;
1243
1244 assert!(after.is_empty());
1245 }
1246}