use crate::mutation_batch::SlotIndexDomain;
use crate::shared_entity::{
split_version, with_version, EntityFields, Fields, SharedEntity, VERSION_FIELD,
};
use arete_interpreter::AccountPosition;
use lru::LruCache;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
const DEFAULT_MAX_ENTITIES_PER_VIEW: usize = 500;
const DEFAULT_MAX_ARRAY_LENGTH: usize = 100;
const DEFAULT_INITIAL_SNAPSHOT_BATCH_SIZE: usize = 50;
const DEFAULT_SUBSEQUENT_SNAPSHOT_BATCH_SIZE: usize = 100;
const EVICTED_KEYS_PER_CACHED_ENTITY: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatchOrigin {
Creation,
Change,
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CacheWrite {
Merged,
Created,
Refused { patch: Value },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Admission {
Refuse,
Create,
Merge,
}
#[derive(Debug, Clone)]
pub(crate) struct ViewVersion<'a> {
pub view_id: &'a str,
pub version: Option<Value>,
}
struct ViewEntries {
max_entities: usize,
entities: LruCache<String, SharedEntity>,
evicted: Option<LruCache<String, ()>>,
creations_marked: bool,
lifetimes: LruCache<String, EntityLifetimeCheckpoint>,
}
impl ViewEntries {
fn new(max_entities: usize) -> Self {
assert!(max_entities > 0, "max_entities_per_view must be > 0");
Self {
max_entities,
entities: LruCache::unbounded(),
evicted: None,
creations_marked: false,
lifetimes: LruCache::unbounded(),
}
}
fn mark_creations(&mut self) {
self.creations_marked = true;
self.evicted = None;
}
fn store(&mut self, key: String, entity: SharedEntity) {
self.forget_evicted(&key);
self.entities.put(key, entity);
while self.entities.len() > self.max_entities {
match self.entities.pop_lru() {
Some((evicted, _)) => self.remember_evicted(evicted),
None => break,
}
}
}
fn remember_evicted(&mut self, key: String) {
if self.creations_marked {
return;
}
let capacity = self
.max_entities
.saturating_mul(EVICTED_KEYS_PER_CACHED_ENTITY);
let evicted = self.evicted.get_or_insert_with(LruCache::unbounded);
evicted.put(key, ());
while evicted.len() > capacity {
evicted.pop_lru();
}
}
fn forget_evicted(&mut self, key: &str) {
if let Some(evicted) = self.evicted.as_mut() {
evicted.pop(key);
}
}
fn was_evicted(&mut self, key: &str) -> bool {
self.evicted
.as_mut()
.is_some_and(|evicted| evicted.get(key).is_some())
}
fn accepts(
&self,
key: &str,
creation: bool,
account_position: Option<AccountPosition>,
source_seq: Option<&str>,
source_domain: Option<SlotIndexDomain>,
) -> bool {
if let Some(checkpoint) = self.lifetimes.peek(key) {
match (checkpoint.account_position, account_position) {
(Some(previous), Some(incoming)) if incoming <= previous => return false,
(Some(_), None) if creation || checkpoint.deleted => return false,
_ => {}
}
if checkpoint.deleted && !creation {
return false;
}
}
if account_position.is_none() && source_domain.is_some() && source_seq.is_some() {
return self.accepts_source_ordering(key, source_seq, source_domain, !creation);
}
true
}
fn latest_source_seq<'a>(
&'a self,
key: &str,
source_domain: Option<SlotIndexDomain>,
) -> Option<&'a str> {
let checkpoint = self.lifetimes.peek(key);
if checkpoint.is_some_and(|checkpoint| checkpoint.account_position.is_some()) {
return checkpoint.and_then(|checkpoint| checkpoint.source_seq(source_domain));
}
let checkpoint = checkpoint.and_then(|checkpoint| checkpoint.source_seq(source_domain));
if source_domain != Some(SlotIndexDomain::Legacy) {
return checkpoint;
}
let entity = self
.entities
.peek(key)
.and_then(|entity| entity.field("_seq"))
.and_then(Value::as_str);
match (checkpoint, entity) {
(Some(left), Some(right)) if cmp_seq(left, right).is_lt() => Some(right),
(Some(left), _) => Some(left),
(None, entity) => entity,
}
}
fn accepts_source_ordering(
&self,
key: &str,
incoming: Option<&str>,
source_domain: Option<SlotIndexDomain>,
allow_equal: bool,
) -> bool {
match (self.latest_source_seq(key, source_domain), incoming) {
(Some(previous), Some(incoming)) => {
let ordering = cmp_seq(incoming, previous);
ordering.is_gt() || (allow_equal && ordering.is_eq())
}
(Some(_), None) => false,
(None, _) => true,
}
}
fn admit(
&mut self,
key: &str,
origin: PatchOrigin,
ordering: LifetimeOrdering<'_>,
) -> Admission {
let LifetimeOrdering {
account_position,
source_seq,
source_domain,
} = ordering;
if origin != PatchOrigin::Unknown && !self.creations_marked {
self.mark_creations();
}
let creation = origin == PatchOrigin::Creation;
if !self.accepts(key, creation, account_position, source_seq, source_domain) {
return Admission::Refuse;
}
if let Some(position) = account_position {
let previous = self.lifetimes.peek(key).cloned();
self.lifetimes.put(
key.to_string(),
EntityLifetimeCheckpoint {
account_position: Some(position),
source_seq: previous
.as_ref()
.and_then(|checkpoint| checkpoint.source_seq.clone()),
source_sequences: previous
.map(|checkpoint| checkpoint.source_sequences)
.unwrap_or_default(),
deleted: false,
},
);
self.trim_lifetimes();
} else if let (Some(source_seq), Some(source_domain)) = (source_seq, source_domain) {
let mut checkpoint = if creation {
EntityLifetimeCheckpoint {
account_position: None,
source_seq: None,
source_sequences: BTreeMap::new(),
deleted: false,
}
} else {
self.lifetimes
.peek(key)
.cloned()
.unwrap_or(EntityLifetimeCheckpoint {
account_position: None,
source_seq: None,
source_sequences: BTreeMap::new(),
deleted: false,
})
};
checkpoint
.source_sequences
.insert(source_domain, source_seq.to_string());
checkpoint.deleted = false;
self.lifetimes.put(key.to_string(), checkpoint);
self.trim_lifetimes();
} else if creation
&& self.lifetimes.peek(key).is_some_and(|checkpoint| {
checkpoint.source_seq.is_none() && checkpoint.source_sequences.is_empty()
})
{
self.lifetimes.pop(key);
}
let whole = match origin {
PatchOrigin::Creation => true,
PatchOrigin::Change => self.entities.contains(key),
PatchOrigin::Unknown => self.entities.contains(key) || !self.was_evicted(key),
};
if !whole {
return Admission::Refuse;
}
if creation || !self.entities.contains(key) {
Admission::Create
} else {
Admission::Merge
}
}
fn accepts_whole(
&self,
key: &str,
requested_account_position: Option<AccountPosition>,
requested_source: Option<(Option<&str>, SlotIndexDomain)>,
) -> bool {
let Some(checkpoint) = self.lifetimes.peek(key) else {
return true;
};
if checkpoint.deleted || checkpoint.account_position != requested_account_position {
return false;
}
match requested_source {
Some((requested_source_seq, requested_source_domain)) => {
!((checkpoint.account_position.is_none()
&& checkpoint.source_seq(Some(requested_source_domain))
!= requested_source_seq)
|| self.entities.contains(key))
}
None => !(checkpoint.account_position.is_some() && self.entities.contains(key)),
}
}
fn trim_lifetimes(&mut self) {
while self.lifetimes.len()
> self
.max_entities
.saturating_mul(EVICTED_KEYS_PER_CACHED_ENTITY)
{
self.lifetimes.pop_lru();
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EntityLifetimeCheckpoint {
#[serde(skip_serializing_if = "Option::is_none")]
pub account_position: Option<AccountPosition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_seq: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub source_sequences: BTreeMap<SlotIndexDomain, String>,
pub deleted: bool,
}
impl EntityLifetimeCheckpoint {
fn source_seq(&self, source_domain: Option<SlotIndexDomain>) -> Option<&str> {
match source_domain {
Some(SlotIndexDomain::Legacy) | None => self
.source_sequences
.get(&SlotIndexDomain::Legacy)
.map(String::as_str)
.or(self.source_seq.as_deref()),
Some(domain) => self.source_sequences.get(&domain).map(String::as_str),
}
}
}
pub type EntityLifetimes = Vec<(String, String, EntityLifetimeCheckpoint)>;
#[derive(Debug, Clone, Copy, Default)]
pub struct LifetimeOrdering<'a> {
pub account_position: Option<AccountPosition>,
pub source_seq: Option<&'a str>,
pub source_domain: Option<SlotIndexDomain>,
}
pub type EntityTombstones = Vec<(String, String, Option<String>)>;
pub fn cmp_seq(a: &str, b: &str) -> std::cmp::Ordering {
fn parse(s: &str) -> (u64, u64) {
let mut parts = s.splitn(2, ':');
let slot = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let offset = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
(slot, offset)
}
parse(a).cmp(&parse(b))
}
fn account_position_from_seq(seq: &str) -> Option<AccountPosition> {
let mut parts = seq.splitn(2, ':');
Some(AccountPosition::new(
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
}
#[derive(Debug, Clone)]
pub struct EntityCacheConfig {
pub max_entities_per_view: usize,
pub max_array_length: usize,
pub initial_snapshot_batch_size: usize,
pub subsequent_snapshot_batch_size: usize,
}
impl Default for EntityCacheConfig {
fn default() -> Self {
Self {
max_entities_per_view: DEFAULT_MAX_ENTITIES_PER_VIEW,
max_array_length: DEFAULT_MAX_ARRAY_LENGTH,
initial_snapshot_batch_size: DEFAULT_INITIAL_SNAPSHOT_BATCH_SIZE,
subsequent_snapshot_batch_size: DEFAULT_SUBSEQUENT_SNAPSHOT_BATCH_SIZE,
}
}
}
#[derive(Clone)]
pub struct EntityCache {
caches: Arc<RwLock<HashMap<String, ViewEntries>>>,
config: EntityCacheConfig,
}
impl EntityCache {
pub fn new() -> Self {
Self::with_config(EntityCacheConfig::default())
}
pub fn with_config(config: EntityCacheConfig) -> Self {
Self {
caches: Arc::new(RwLock::new(HashMap::new())),
config,
}
}
pub fn max_entities_per_view(&self) -> usize {
self.config.max_entities_per_view
}
pub async fn upsert(&self, view_id: &str, key: &str, patch: Value) -> CacheWrite {
self.upsert_with_append(view_id, key, patch, &[], PatchOrigin::Unknown)
.await
}
pub async fn upsert_with_append(
&self,
view_id: &str,
key: &str,
patch: Value,
append_paths: &[String],
origin: PatchOrigin,
) -> CacheWrite {
self.upsert_with_lifetime(view_id, key, patch, append_paths, origin, None)
.await
}
pub async fn upsert_with_lifetime(
&self,
view_id: &str,
key: &str,
patch: Value,
append_paths: &[String],
origin: PatchOrigin,
account_position: Option<AccountPosition>,
) -> CacheWrite {
let source_seq = patch.get("_seq").and_then(Value::as_str).map(str::to_owned);
self.upsert_with_ordering(
view_id,
key,
patch,
append_paths,
origin,
LifetimeOrdering {
account_position,
source_seq: source_seq.as_deref(),
source_domain: (origin == PatchOrigin::Creation).then_some(SlotIndexDomain::Legacy),
},
)
.await
}
pub async fn upsert_with_ordering(
&self,
view_id: &str,
key: &str,
patch: Value,
append_paths: &[String],
origin: PatchOrigin,
ordering: LifetimeOrdering<'_>,
) -> CacheWrite {
let (patch, version) = split_version(patch);
self.upsert_views(
key,
patch,
&[ViewVersion { view_id, version }],
append_paths,
origin,
ordering,
)
.await
.pop()
.expect("upsert_views returns one write per view")
}
pub(crate) async fn upsert_views(
&self,
key: &str,
patch: Value,
views: &[ViewVersion<'_>],
append_paths: &[String],
origin: PatchOrigin,
ordering: LifetimeOrdering<'_>,
) -> Vec<CacheWrite> {
let (mut patch, stray_version) = split_version(patch);
let version_of = |index: usize| {
views[index]
.version
.clone()
.or_else(|| stray_version.clone())
};
let max_array_length = self.config.max_array_length;
let mut caches = self.caches.write().await;
let mut admissions: Vec<Admission> = views
.iter()
.map(|view| {
caches
.entry(view.view_id.to_string())
.or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view))
.admit(key, origin, ordering)
})
.collect();
let mut writes = vec![CacheWrite::Merged; views.len()];
for (index, admission) in admissions.iter().enumerate() {
if *admission == Admission::Refuse {
writes[index] = CacheWrite::Refused {
patch: with_version(patch.clone(), version_of(index)),
};
}
}
let placeholder = Arc::new(Fields::Other(Value::Null));
let mut groups: Vec<(Arc<Fields>, Vec<usize>)> = Vec::new();
for (index, admission) in admissions.iter_mut().enumerate() {
if *admission != Admission::Merge {
continue;
}
let held = caches
.get_mut(views[index].view_id)
.and_then(|view| view.entities.get_mut(key));
let Some(entity) = held else {
*admission = Admission::Create;
continue;
};
let fields = std::mem::replace(entity.fields_mut(), placeholder.clone());
match groups
.iter_mut()
.find(|(shared, _)| Arc::ptr_eq(shared, &fields))
{
Some((_, members)) => members.push(index),
None => groups.push((fields, vec![index])),
}
}
if admissions.contains(&Admission::Create) {
let source = if groups.is_empty() {
std::mem::take(&mut patch)
} else {
patch.clone()
};
let created = Arc::new(Fields::from_value(truncate_arrays_if_needed(
source,
max_array_length,
)));
for (index, admission) in admissions.iter().enumerate() {
if *admission != Admission::Create {
continue;
}
let version = version_of(index)
.filter(|_| created.is_object())
.map(|version| truncate_arrays_if_needed(version, max_array_length));
if let Some(view) = caches.get_mut(views[index].view_id) {
view.store(
key.to_string(),
SharedEntity::from_parts(created.clone(), version),
);
}
writes[index] = CacheWrite::Created;
}
}
let last_group = groups.len().saturating_sub(1);
for (group, (mut fields, members)) in groups.into_iter().enumerate() {
let mut group_patch = if group == last_group {
std::mem::take(&mut patch)
} else {
patch.clone()
};
let merge_once = fields.is_object() && group_patch.is_object();
if merge_once {
merge_fields(
Arc::make_mut(&mut fields),
std::mem::take(&mut group_patch),
append_paths,
max_array_length,
);
}
for index in members {
let Some(entity) = caches
.get_mut(views[index].view_id)
.and_then(|view| view.entities.peek_mut(key))
else {
continue;
};
*entity.fields_mut() = fields.clone();
if merge_once {
let version = entity.version_mut();
*version = merge_version(
version.take(),
version_of(index),
append_paths,
max_array_length,
);
} else {
merge_shared(
entity,
with_version(group_patch.clone(), version_of(index)),
append_paths,
max_array_length,
);
}
}
}
writes
}
pub async fn store_whole(&self, view_id: &str, key: &str, entity: Value) -> bool {
self.store_whole_for_lifetime(view_id, key, entity, None)
.await
}
pub async fn store_whole_for_lifetime(
&self,
view_id: &str,
key: &str,
entity: Value,
requested_account_position: Option<AccountPosition>,
) -> bool {
self.store_whole_checked(view_id, key, entity, requested_account_position, None)
.await
}
pub async fn store_whole_for_ordering(
&self,
view_id: &str,
key: &str,
entity: Value,
requested_account_position: Option<AccountPosition>,
requested_source_seq: Option<&str>,
) -> bool {
self.store_whole_for_ordering_in_domain(
view_id,
key,
entity,
requested_account_position,
requested_source_seq,
SlotIndexDomain::Legacy,
)
.await
}
pub async fn store_whole_for_ordering_in_domain(
&self,
view_id: &str,
key: &str,
entity: Value,
requested_account_position: Option<AccountPosition>,
requested_source_seq: Option<&str>,
requested_source_domain: SlotIndexDomain,
) -> bool {
self.store_whole_checked(
view_id,
key,
entity,
requested_account_position,
Some((requested_source_seq, requested_source_domain)),
)
.await
}
async fn store_whole_checked(
&self,
view_id: &str,
key: &str,
entity: Value,
requested_account_position: Option<AccountPosition>,
requested_source: Option<(Option<&str>, SlotIndexDomain)>,
) -> bool {
let (entity, version) = split_version(entity);
self.store_whole_views(
key,
entity,
&[ViewVersion { view_id, version }],
requested_account_position,
requested_source,
)
.await
.pop()
.expect("store_whole_views answers for every view")
}
pub(crate) async fn store_whole_views(
&self,
key: &str,
entity: Value,
views: &[ViewVersion<'_>],
requested_account_position: Option<AccountPosition>,
requested_source: Option<(Option<&str>, SlotIndexDomain)>,
) -> Vec<bool> {
let max_array_length = self.config.max_array_length;
let (entity, stray_version) = split_version(entity);
let fields = Arc::new(Fields::from_value(truncate_arrays_if_needed(
entity,
max_array_length,
)));
let mut caches = self.caches.write().await;
views
.iter()
.map(|view| {
let entries = caches
.entry(view.view_id.to_string())
.or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
if !entries.accepts_whole(key, requested_account_position, requested_source) {
return false;
}
let version = view
.version
.clone()
.or_else(|| stray_version.clone())
.filter(|_| fields.is_object())
.map(|version| truncate_arrays_if_needed(version, max_array_length));
entries.store(
key.to_string(),
SharedEntity::from_parts(fields.clone(), version),
);
true
})
.collect()
}
pub async fn account_position(&self, view_id: &str, key: &str) -> Option<AccountPosition> {
self.caches
.read()
.await
.get(view_id)
.and_then(|view| view.lifetimes.peek(key))
.and_then(|checkpoint| checkpoint.account_position)
}
pub async fn accepts_mutation(
&self,
view_id: &str,
key: &str,
_entity: &Value,
creation: bool,
) -> bool {
self.accepts_lifetime_mutation(view_id, key, creation, None)
.await
}
pub async fn accepts_lifetime_mutation(
&self,
view_id: &str,
key: &str,
creation: bool,
account_position: Option<AccountPosition>,
) -> bool {
self.accepts_ordered_lifetime_mutation(view_id, key, creation, account_position, None)
.await
}
pub async fn accepts_ordered_lifetime_mutation(
&self,
view_id: &str,
key: &str,
creation: bool,
account_position: Option<AccountPosition>,
source_seq: Option<&str>,
) -> bool {
self.accepts_ordered_lifetime_mutation_in_domain(
view_id,
key,
creation,
account_position,
source_seq,
SlotIndexDomain::Legacy,
)
.await
}
pub async fn accepts_ordered_lifetime_mutation_in_domain(
&self,
view_id: &str,
key: &str,
creation: bool,
account_position: Option<AccountPosition>,
source_seq: Option<&str>,
source_domain: SlotIndexDomain,
) -> bool {
self.caches.read().await.get(view_id).is_none_or(|view| {
view.accepts(
key,
creation,
account_position,
source_seq,
Some(source_domain),
)
})
}
pub async fn delete(
&self,
view_id: &str,
key: &str,
account_position: Option<AccountPosition>,
) -> bool {
match account_position {
Some(position) => {
self.delete_ordered(view_id, key, Some(position), None)
.await
}
None => self.delete_current(view_id, key).await,
}
}
pub async fn delete_current(&self, view_id: &str, key: &str) -> bool {
let mut caches = self.caches.write().await;
let view = caches
.entry(view_id.to_string())
.or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
let checkpoint = view
.lifetimes
.peek(key)
.cloned()
.unwrap_or(EntityLifetimeCheckpoint {
account_position: None,
source_seq: None,
source_sequences: BTreeMap::new(),
deleted: false,
});
if checkpoint.deleted {
return true;
}
view.entities.pop(key);
view.forget_evicted(key);
view.lifetimes.put(
key.to_string(),
EntityLifetimeCheckpoint {
deleted: true,
..checkpoint
},
);
view.trim_lifetimes();
true
}
pub async fn delete_ordered(
&self,
view_id: &str,
key: &str,
account_position: Option<AccountPosition>,
source_seq: Option<&str>,
) -> bool {
self.delete_ordered_in_domain(
view_id,
key,
account_position,
source_seq,
SlotIndexDomain::Legacy,
)
.await
}
pub async fn delete_ordered_in_domain(
&self,
view_id: &str,
key: &str,
account_position: Option<AccountPosition>,
source_seq: Option<&str>,
source_domain: SlotIndexDomain,
) -> bool {
let mut caches = self.caches.write().await;
let view = caches
.entry(view_id.to_string())
.or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
if let Some(checkpoint) = view.lifetimes.peek(key) {
match (checkpoint.account_position, account_position) {
(Some(previous), Some(incoming)) if incoming < previous => return false,
(Some(previous), Some(incoming)) if incoming == previous => {
return checkpoint.deleted;
}
_ => {}
}
}
if account_position.is_none()
&& source_seq.is_some()
&& !view.accepts_source_ordering(key, source_seq, Some(source_domain), true)
{
return false;
}
let previous = view.lifetimes.peek(key).cloned();
view.entities.pop(key);
view.forget_evicted(key);
let mut checkpoint = if account_position.is_some() {
let previous = previous.unwrap_or(EntityLifetimeCheckpoint {
account_position: None,
source_seq: None,
source_sequences: BTreeMap::new(),
deleted: false,
});
EntityLifetimeCheckpoint {
account_position,
source_seq: previous.source_seq,
source_sequences: previous.source_sequences,
deleted: true,
}
} else {
previous.unwrap_or(EntityLifetimeCheckpoint {
account_position: None,
source_seq: None,
source_sequences: BTreeMap::new(),
deleted: false,
})
};
if account_position.is_none() {
if let Some(source_seq) = source_seq {
checkpoint
.source_sequences
.insert(source_domain, source_seq.to_string());
}
checkpoint.deleted = true;
}
view.lifetimes.put(key.to_string(), checkpoint);
view.trim_lifetimes();
true
}
pub async fn dump_lifetimes(&self) -> EntityLifetimes {
self.caches
.read()
.await
.iter()
.flat_map(|(id, view)| {
view.lifetimes
.iter()
.map(|(key, checkpoint)| (id.clone(), key.clone(), checkpoint.clone()))
})
.collect()
}
pub async fn hydrate_lifetimes(&self, lifetimes: EntityLifetimes) {
let mut caches = self.caches.write().await;
for (id, key, checkpoint) in lifetimes.into_iter().rev() {
let view = caches
.entry(id)
.or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
view.lifetimes.put(key, checkpoint);
view.trim_lifetimes();
}
}
pub async fn hydrate_legacy_tombstones(&self, tombstones: EntityTombstones) {
let mut caches = self.caches.write().await;
for (id, key, seq) in tombstones.into_iter().rev() {
let view = caches
.entry(id)
.or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
let deleted = !view.entities.contains(&key);
let account_position = seq.as_deref().and_then(account_position_from_seq);
view.lifetimes.put(
key,
EntityLifetimeCheckpoint {
account_position,
source_seq: None,
source_sequences: BTreeMap::new(),
deleted,
},
);
view.trim_lifetimes();
}
}
pub async fn deletion_is_current(&self, view_id: &str, key: &str) -> bool {
let caches = self.caches.read().await;
caches.get(view_id).is_none_or(|view| {
view.lifetimes
.peek(key)
.map_or(!view.entities.contains(key), |checkpoint| {
checkpoint.deleted
})
})
}
pub fn merge_patch(&self, base: &mut Value, patch: Value, append_paths: &[String]) {
deep_merge_with_append(base, patch, append_paths, self.config.max_array_length);
}
pub fn merge_patch_shared(
&self,
entity: &mut SharedEntity,
patch: Value,
append_paths: &[String],
) {
merge_shared(entity, patch, append_paths, self.config.max_array_length);
}
pub async fn remember_evicted(&self, view_id: &str, keys: impl IntoIterator<Item = String>) {
let mut caches = self.caches.write().await;
let view = caches
.entry(view_id.to_string())
.or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
for key in keys {
if !view.entities.contains(&key) {
view.remember_evicted(key);
}
}
}
pub async fn get_all(&self, view_id: &str) -> Vec<(String, Value)> {
self.get_all_shared(view_id)
.await
.into_iter()
.map(|(key, entity)| (key, entity.into_value()))
.collect()
}
pub async fn get_all_shared(&self, view_id: &str) -> Vec<(String, SharedEntity)> {
let caches = self.caches.read().await;
caches
.get(view_id)
.map(|view| {
view.entities
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
.unwrap_or_default()
}
pub async fn get_after(
&self,
view_id: &str,
cursor: &str,
limit: Option<usize>,
) -> Vec<(String, Value)> {
let caches = self.caches.read().await;
if let Some(view) = caches.get(view_id) {
let mut results: Vec<(String, &SharedEntity)> = view
.entities
.iter()
.filter(|(_, entity)| {
entity
.field("_seq")
.and_then(|s| s.as_str())
.map(|seq| cmp_seq(seq, cursor) == std::cmp::Ordering::Greater)
.unwrap_or(false)
})
.map(|(k, v)| (k.clone(), v))
.collect();
results.sort_by(|a, b| {
let seq_a = a.1.field("_seq").and_then(|s| s.as_str()).unwrap_or("");
let seq_b = b.1.field("_seq").and_then(|s| s.as_str()).unwrap_or("");
cmp_seq(seq_a, seq_b)
});
if let Some(limit) = limit {
results.truncate(limit);
}
results
.into_iter()
.map(|(key, entity)| (key, entity.to_value()))
.collect()
} else {
vec![]
}
}
pub async fn get(&self, view_id: &str, key: &str) -> Option<Value> {
self.get_shared(view_id, key)
.await
.map(SharedEntity::into_value)
}
pub async fn get_shared(&self, view_id: &str, key: &str) -> Option<SharedEntity> {
let caches = self.caches.read().await;
caches
.get(view_id)
.and_then(|view| view.entities.peek(key).cloned())
}
pub async fn remove(&self, view_id: &str, key: &str) -> Option<Value> {
let removed = {
let mut caches = self.caches.write().await;
caches.get_mut(view_id).and_then(|view| {
view.forget_evicted(key);
view.entities.pop(key)
})
};
removed.map(SharedEntity::into_value)
}
pub async fn len(&self, view_id: &str) -> usize {
let caches = self.caches.read().await;
caches
.get(view_id)
.map(|view| view.entities.len())
.unwrap_or(0)
}
pub async fn is_empty(&self, view_id: &str) -> bool {
self.len(view_id).await == 0
}
pub fn snapshot_config(&self) -> SnapshotBatchConfig {
SnapshotBatchConfig {
initial_batch_size: self.config.initial_snapshot_batch_size,
subsequent_batch_size: self.config.subsequent_snapshot_batch_size,
}
}
pub async fn clear(&self, view_id: &str) {
let mut caches = self.caches.write().await;
if let Some(view) = caches.get_mut(view_id) {
view.entities.clear();
view.evicted = None;
}
}
pub async fn clear_all(&self) {
let mut caches = self.caches.write().await;
caches.clear();
}
pub async fn dump(&self) -> Vec<(String, Vec<(String, Value)>)> {
self.dump_shared()
.await
.into_iter()
.map(|(view_id, entries)| {
(
view_id,
entries
.into_iter()
.map(|(key, entity)| (key, entity.into_value()))
.collect(),
)
})
.collect()
}
pub async fn dump_shared(&self) -> Vec<(String, Vec<(String, SharedEntity)>)> {
let caches = self.caches.read().await;
caches
.iter()
.map(|(view_id, view)| {
(
view_id.clone(),
view.entities
.iter()
.map(|(key, entity)| (key.clone(), entity.clone()))
.collect(),
)
})
.collect()
}
pub async fn hydrate(&self, views: Vec<(String, Vec<(String, Value)>)>) {
let mut caches = self.caches.write().await;
caches.clear();
let mut restored: HashMap<(String, String), Vec<Arc<Fields>>> = HashMap::new();
for (view_id, entries) in views {
let export = view_id.split('/').next().unwrap_or_default().to_string();
let view = caches
.entry(view_id)
.or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
for (key, entity) in entries.into_iter().rev() {
let (fields, version) = SharedEntity::new(entity).into_parts();
let seen = restored.entry((export.clone(), key.clone())).or_default();
let fields = match seen.iter().find(|held| **held == fields) {
Some(held) => held.clone(),
None => {
seen.push(fields.clone());
fields
}
};
view.store(key, SharedEntity::from_parts(fields, version));
}
}
}
pub async fn stats(&self) -> CacheStats {
let caches = self.caches.read().await;
let mut total_entities = 0;
let mut views = Vec::new();
for (view_id, view) in caches.iter() {
let count = view.entities.len();
total_entities += count;
views.push((view_id.clone(), count));
}
views.sort_by_key(|b| std::cmp::Reverse(b.1));
CacheStats {
view_count: caches.len(),
total_entities,
top_views: views.into_iter().take(5).collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheStats {
pub view_count: usize,
pub total_entities: usize,
pub top_views: Vec<(String, usize)>,
}
#[derive(Debug, Clone, Copy)]
pub struct SnapshotBatchConfig {
pub initial_batch_size: usize,
pub subsequent_batch_size: usize,
}
impl Default for EntityCache {
fn default() -> Self {
Self::new()
}
}
fn deep_merge_with_append(
base: &mut Value,
patch: Value,
append_paths: &[String],
max_array_length: usize,
) {
deep_merge_with_append_inner(base, patch, append_paths, "", max_array_length);
}
fn deep_merge_with_append_inner(
base: &mut Value,
patch: Value,
append_paths: &[String],
current_path: &str,
max_array_length: usize,
) {
match (base, patch) {
(Value::Object(base_map), Value::Object(patch_map)) => {
for (key, patch_value) in patch_map {
let child_path = child_path(append_paths, current_path, &key);
if let Some(base_value) = base_map.get_mut(&key) {
deep_merge_with_append_inner(
base_value,
patch_value,
append_paths,
&child_path,
max_array_length,
);
} else {
base_map.insert(
key,
truncate_arrays_if_needed(patch_value, max_array_length),
);
}
}
}
(Value::Array(base_arr), Value::Array(patch_arr)) => {
let should_append = append_paths.iter().any(|p| p == current_path);
if should_append {
base_arr.extend(patch_arr);
if base_arr.len() > max_array_length {
let excess = base_arr.len() - max_array_length;
base_arr.drain(0..excess);
}
} else {
*base_arr = patch_arr;
if base_arr.len() > max_array_length {
let excess = base_arr.len() - max_array_length;
base_arr.drain(0..excess);
}
}
}
(base, patch_value) => {
*base = truncate_arrays_if_needed(patch_value, max_array_length);
}
}
}
fn merge_fields(
fields: &mut Fields,
patch: Value,
append_paths: &[String],
max_array_length: usize,
) {
let (Fields::Object(members), Value::Object(patch)) = (fields, patch) else {
unreachable!("merge_fields merges an object patch into an object");
};
for (key, patch_value) in patch {
match members.get_mut(&key) {
Some(member) => {
if merge_changes_nothing(member, &patch_value, append_paths, &key, max_array_length)
{
continue;
}
match Arc::get_mut(member) {
Some(value) => deep_merge_with_append_inner(
value,
patch_value,
append_paths,
&key,
max_array_length,
),
None => {
*member = Arc::new(merged_copy(
member,
patch_value,
append_paths,
&key,
max_array_length,
))
}
}
}
None => members.insert(
key,
Arc::new(truncate_arrays_if_needed(patch_value, max_array_length)),
),
}
}
}
fn merge_changes_nothing(
base: &Value,
patch: &Value,
append_paths: &[String],
current_path: &str,
max_array_length: usize,
) -> bool {
match (base, patch) {
(Value::Object(base_map), Value::Object(patch_map)) => {
patch_map.iter().all(|(key, patch_value)| {
base_map.get(key).is_some_and(|base_value| {
merge_changes_nothing(
base_value,
patch_value,
append_paths,
&child_path(append_paths, current_path, key),
max_array_length,
)
})
})
}
(Value::Array(base_arr), Value::Array(patch_arr)) => {
base_arr.len() <= max_array_length
&& if append_paths.iter().any(|path| path == current_path) {
patch_arr.is_empty()
} else {
base_arr == patch_arr
}
}
(Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => false,
(base, patch) => base == patch,
}
}
fn merged_copy(
base: &Value,
patch: Value,
append_paths: &[String],
current_path: &str,
max_array_length: usize,
) -> Value {
match (base, patch) {
(Value::Object(base_map), Value::Object(patch_map)) => {
let mut merged = serde_json::Map::new();
for (key, value) in base_map {
let value = if patch_map.contains_key(key) {
Value::Null
} else {
value.clone()
};
merged.insert(key.clone(), value);
}
for (key, patch_value) in patch_map {
let child_path = child_path(append_paths, current_path, &key);
match (merged.get_mut(&key), base_map.get(&key)) {
(Some(slot), Some(base_value)) => {
*slot = merged_copy(
base_value,
patch_value,
append_paths,
&child_path,
max_array_length,
);
}
_ => {
merged.insert(
key,
truncate_arrays_if_needed(patch_value, max_array_length),
);
}
}
}
Value::Object(merged)
}
(Value::Array(base_arr), Value::Array(mut patch_arr)) => {
if append_paths.iter().any(|path| path == current_path) {
let kept = max_array_length
.saturating_sub(patch_arr.len())
.min(base_arr.len());
let mut merged = base_arr[base_arr.len() - kept..].to_vec();
merged.append(&mut patch_arr);
if merged.len() > max_array_length {
let excess = merged.len() - max_array_length;
merged.drain(0..excess);
}
Value::Array(merged)
} else {
if patch_arr.len() > max_array_length {
let excess = patch_arr.len() - max_array_length;
patch_arr.drain(0..excess);
}
Value::Array(patch_arr)
}
}
(_, patch_value) => truncate_arrays_if_needed(patch_value, max_array_length),
}
}
fn child_path(append_paths: &[String], current_path: &str, key: &str) -> String {
if append_paths.is_empty() {
String::new()
} else if current_path.is_empty() {
key.to_string()
} else {
format!("{current_path}.{key}")
}
}
fn truncate_arrays_if_needed(mut value: Value, max_array_length: usize) -> Value {
truncate_arrays_in_place(&mut value, max_array_length);
value
}
fn truncate_arrays_in_place(value: &mut Value, max_array_length: usize) {
match value {
Value::Array(arr) => {
if arr.len() > max_array_length {
let excess = arr.len() - max_array_length;
arr.drain(0..excess);
}
for element in arr {
truncate_arrays_in_place(element, max_array_length);
}
}
Value::Object(map) => {
for field in map.values_mut() {
truncate_arrays_in_place(field, max_array_length);
}
}
_ => {}
}
}
fn merge_shared(
entity: &mut SharedEntity,
patch: Value,
append_paths: &[String],
max_array_length: usize,
) {
let (patch, patch_version) = split_version(patch);
if entity.fields().is_object() && patch.is_object() {
merge_fields(
Arc::make_mut(entity.fields_mut()),
patch,
append_paths,
max_array_length,
);
let version = entity.version_mut();
*version = merge_version(
version.take(),
patch_version,
append_paths,
max_array_length,
);
return;
}
let (fields, _) = std::mem::replace(entity, SharedEntity::new(Value::Null)).into_parts();
let mut whole = match (&*fields, &patch) {
(Fields::Other(Value::Array(_)), Value::Array(_)) => match Arc::try_unwrap(fields) {
Ok(fields) => fields.into_value(),
Err(shared) => shared.to_value(),
},
_ => Value::Null,
};
deep_merge_with_append(
&mut whole,
with_version(patch, patch_version),
append_paths,
max_array_length,
);
*entity = SharedEntity::new(whole);
}
fn merge_version(
current: Option<Value>,
incoming: Option<Value>,
append_paths: &[String],
max_array_length: usize,
) -> Option<Value> {
match (current, incoming) {
(Some(mut current), Some(incoming)) => {
deep_merge_with_append_inner(
&mut current,
incoming,
append_paths,
VERSION_FIELD,
max_array_length,
);
Some(current)
}
(None, Some(incoming)) => Some(truncate_arrays_if_needed(incoming, max_array_length)),
(current, None) => current,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn same_slot_instruction_activity_cannot_block_authoritative_deletion() {
let cache = EntityCache::new();
assert_eq!(
cache
.upsert_with_lifetime(
"v",
"a",
json!({"account":true,"_seq":"100:000000000009"}),
&[],
PatchOrigin::Creation,
Some(AccountPosition::new(100, 9)),
)
.await,
CacheWrite::Created
);
assert_eq!(
cache
.upsert_with_append(
"v",
"a",
json!({"instruction":true,"_seq":"100:000000000900"}),
&[],
PatchOrigin::Change
)
.await,
CacheWrite::Merged
);
assert!(
cache
.delete("v", "a", Some(AccountPosition::new(100, 10)))
.await
);
assert!(cache.get("v", "a").await.is_none());
assert!(cache.deletion_is_current("v", "a").await);
}
#[tokio::test]
async fn recreation_has_its_own_ordering_and_replaces_old_fields() {
let cache = EntityCache::with_config(EntityCacheConfig {
max_entities_per_view: 1,
..Default::default()
});
assert!(
cache
.delete("v", "a", Some(AccountPosition::new(100, 1001)))
.await
);
assert_eq!(
cache
.upsert_with_lifetime(
"v",
"a",
json!({"fresh":true, "_seq":"100:000000001002"}),
&[],
PatchOrigin::Creation,
Some(AccountPosition::new(100, 1002)),
)
.await,
CacheWrite::Created
);
assert_eq!(
cache
.upsert_with_append(
"v",
"a",
json!({"instruction":true, "_seq":"100:000000000900"}),
&[],
PatchOrigin::Change
)
.await,
CacheWrite::Merged
);
assert!(matches!(
cache
.upsert_with_lifetime(
"v",
"a",
json!({"old":true, "_seq":"100:000000001001"}),
&[],
PatchOrigin::Change,
Some(AccountPosition::new(100, 1001)),
)
.await,
CacheWrite::Refused { .. }
));
assert!(
!cache
.delete("v", "a", Some(AccountPosition::new(100, 1001)))
.await
);
assert!(
!cache
.store_whole("v", "a", json!({"old":true, "_seq":"100:000000000900"}))
.await
);
let row = cache.get("v", "a").await.unwrap();
assert_eq!(row["fresh"], true);
assert_eq!(row["instruction"], true);
assert!(row.get("old").is_none());
cache
.upsert_with_append(
"v",
"other",
json!({"other":true}),
&[],
PatchOrigin::Creation,
)
.await;
assert!(cache.get("v", "a").await.is_none());
assert!(matches!(
cache
.upsert_with_lifetime(
"v",
"a",
json!({"accountUpdate":true, "_seq":"100:000000001003"}),
&[],
PatchOrigin::Change,
Some(AccountPosition::new(100, 1003)),
)
.await,
CacheWrite::Refused { .. }
));
assert!(
!cache
.store_whole_for_lifetime(
"v",
"a",
row.clone(),
Some(AccountPosition::new(100, 1002)),
)
.await
);
let mut current = row;
current["accountUpdate"] = Value::Bool(true);
current["_seq"] = Value::String("100:000000001003".into());
assert!(
cache
.store_whole_for_lifetime("v", "a", current, Some(AccountPosition::new(100, 1003)),)
.await
);
let restored = EntityCache::new();
restored.hydrate(cache.dump().await).await;
restored
.hydrate_lifetimes(cache.dump_lifetimes().await)
.await;
assert_eq!(
restored
.upsert_with_append(
"v",
"a",
json!({"afterRestore":true, "_seq":"100:000000000901"}),
&[],
PatchOrigin::Change,
)
.await,
CacheWrite::Merged
);
assert!(matches!(
restored
.upsert_with_lifetime(
"v",
"a",
json!({"old":true, "_seq":"100:000000001001"}),
&[],
PatchOrigin::Change,
Some(AccountPosition::new(100, 1001)),
)
.await,
CacheWrite::Refused { .. }
));
}
#[tokio::test]
async fn recreation_retains_each_source_cursor_as_an_old_lifetime_barrier() {
let cache = EntityCache::new();
assert_eq!(
cache
.upsert_with_lifetime(
"v",
"a",
json!({"lifetime":"old"}),
&[],
PatchOrigin::Creation,
Some(AccountPosition::new(100, 9)),
)
.await,
CacheWrite::Created
);
for (domain, seq, field) in [
(
SlotIndexDomain::Instruction,
"100:000000000800",
"oldInstruction",
),
(SlotIndexDomain::Resolver, "100:900000000000", "oldResolver"),
] {
let mut patch = json!({"_seq":seq});
patch[field] = Value::Bool(true);
assert_eq!(
cache
.upsert_with_ordering(
"v",
"a",
patch,
&[],
PatchOrigin::Change,
LifetimeOrdering {
account_position: None,
source_seq: Some(seq),
source_domain: Some(domain),
},
)
.await,
CacheWrite::Merged
);
}
assert!(
cache
.delete("v", "a", Some(AccountPosition::new(100, 10)))
.await
);
assert_eq!(
cache
.upsert_with_lifetime(
"v",
"a",
json!({"lifetime":"new"}),
&[],
PatchOrigin::Creation,
Some(AccountPosition::new(100, 11)),
)
.await,
CacheWrite::Created
);
for (domain, seq, field) in [
(
SlotIndexDomain::Instruction,
"100:000000000799",
"staleInstruction",
),
(
SlotIndexDomain::Resolver,
"100:899999999999",
"staleResolver",
),
] {
let mut patch = json!({"_seq":seq});
patch[field] = Value::Bool(true);
assert!(matches!(
cache
.upsert_with_ordering(
"v",
"a",
patch,
&[],
PatchOrigin::Change,
LifetimeOrdering {
account_position: None,
source_seq: Some(seq),
source_domain: Some(domain),
},
)
.await,
CacheWrite::Refused { .. }
));
}
assert_eq!(
cache
.upsert_with_ordering(
"v",
"a",
json!({"newInstruction":true, "_seq":"100:000000000900"}),
&[],
PatchOrigin::Change,
LifetimeOrdering {
account_position: None,
source_seq: Some("100:000000000900"),
source_domain: Some(SlotIndexDomain::Instruction),
},
)
.await,
CacheWrite::Merged
);
let row = cache.get("v", "a").await.unwrap();
assert_eq!(row["lifetime"], "new");
assert_eq!(row["newInstruction"], true);
assert!(row.get("oldInstruction").is_none());
assert!(row.get("oldResolver").is_none());
assert!(row.get("staleInstruction").is_none());
assert!(row.get("staleResolver").is_none());
let restored = EntityCache::new();
restored.hydrate(cache.dump().await).await;
restored
.hydrate_lifetimes(cache.dump_lifetimes().await)
.await;
assert!(matches!(
restored
.upsert_with_ordering(
"v",
"a",
json!({"staleAfterRestore":true}),
&[],
PatchOrigin::Change,
LifetimeOrdering {
account_position: None,
source_seq: Some("100:000000000799"),
source_domain: Some(SlotIndexDomain::Instruction),
},
)
.await,
CacheWrite::Refused { .. }
));
}
#[tokio::test]
async fn markerless_sources_keep_seq_ordering_across_recreation_and_restore() {
let cache = EntityCache::new();
assert_eq!(
cache
.upsert_with_append(
"v",
"a",
json!({"old":true,"_seq":"100:000000000010"}),
&[],
PatchOrigin::Creation,
)
.await,
CacheWrite::Created
);
assert!(
!cache
.delete_ordered("v", "a", None, Some("100:000000000009"))
.await
);
assert!(cache.get("v", "a").await.is_some());
assert!(
cache
.delete_ordered("v", "a", None, Some("100:000000000011"))
.await
);
assert_eq!(
cache
.upsert_with_append(
"v",
"a",
json!({"fresh":true,"_seq":"100:000000000012"}),
&[],
PatchOrigin::Creation,
)
.await,
CacheWrite::Created
);
assert!(matches!(
cache
.upsert_with_append(
"v",
"a",
json!({"stale":true,"_seq":"100:000000000010"}),
&[],
PatchOrigin::Creation,
)
.await,
CacheWrite::Refused { .. }
));
assert!(
!cache
.delete_ordered("v", "a", None, Some("100:000000000011"))
.await
);
let restored = EntityCache::new();
restored.hydrate(cache.dump().await).await;
restored
.hydrate_lifetimes(cache.dump_lifetimes().await)
.await;
assert!(
!restored
.delete_ordered("v", "a", None, Some("100:000000000011"))
.await
);
let row = restored.get("v", "a").await.unwrap();
assert_eq!(row["fresh"], true);
assert!(row.get("old").is_none());
assert!(row.get("stale").is_none());
let resync_cache = EntityCache::new();
resync_cache
.hydrate_lifetimes(cache.dump_lifetimes().await)
.await;
assert!(
!resync_cache
.store_whole_for_ordering(
"v",
"a",
json!({"stale_resend":true}),
None,
Some("100:000000000010"),
)
.await
);
assert!(
resync_cache
.store_whole_for_ordering(
"v",
"a",
json!({"fresh_resend":true}),
None,
Some("100:000000000012"),
)
.await
);
}
#[tokio::test]
async fn older_source_patch_cannot_replace_newer_row_or_move_its_cursor_back() {
let cache = EntityCache::new();
assert_eq!(
cache
.upsert_with_append(
"v",
"a",
json!({"value":"created","_seq":"100:000000000010"}),
&[],
PatchOrigin::Creation,
)
.await,
CacheWrite::Created
);
assert_eq!(
cache
.upsert_with_ordering(
"v",
"a",
json!({"value":"newer","_seq":"100:000000000012"}),
&[],
PatchOrigin::Change,
LifetimeOrdering {
account_position: None,
source_seq: Some("100:000000000012"),
source_domain: Some(SlotIndexDomain::Legacy),
},
)
.await,
CacheWrite::Merged
);
assert!(matches!(
cache
.upsert_with_ordering(
"v",
"a",
json!({"value":"older","stale":true,"_seq":"100:000000000011"}),
&[],
PatchOrigin::Change,
LifetimeOrdering {
account_position: None,
source_seq: Some("100:000000000011"),
source_domain: Some(SlotIndexDomain::Legacy),
},
)
.await,
CacheWrite::Refused { .. }
));
assert!(
!cache
.delete_ordered("v", "a", None, Some("100:000000000011"))
.await
);
let row = cache.get("v", "a").await.unwrap();
assert_eq!(row["value"], "newer");
assert_eq!(row["_seq"], "100:000000000012");
assert!(row.get("stale").is_none());
}
#[tokio::test]
async fn independent_source_recency_domains_survive_snapshot_restore() {
let cache = EntityCache::new();
cache
.upsert_with_lifetime(
"v",
"a",
json!({"value":"created"}),
&[],
PatchOrigin::Creation,
Some(AccountPosition::new(100, 9)),
)
.await;
for (domain, seq, value) in [
(SlotIndexDomain::Resolver, "100:900000000000", "resolver"),
(
SlotIndexDomain::Instruction,
"100:000000000900",
"instruction",
),
] {
assert_eq!(
cache
.upsert_with_ordering(
"v",
"a",
json!({"value":value, "_seq":seq}),
&[],
PatchOrigin::Change,
LifetimeOrdering {
account_position: None,
source_seq: Some(seq),
source_domain: Some(domain),
},
)
.await,
CacheWrite::Merged
);
}
let restored = EntityCache::new();
restored.hydrate(cache.dump().await).await;
restored
.hydrate_lifetimes(cache.dump_lifetimes().await)
.await;
for (domain, seq) in [
(SlotIndexDomain::Instruction, "100:000000000899"),
(SlotIndexDomain::Resolver, "100:899999999999"),
] {
assert!(matches!(
restored
.upsert_with_ordering(
"v",
"a",
json!({"stale":true, "_seq":seq}),
&[],
PatchOrigin::Change,
LifetimeOrdering {
account_position: None,
source_seq: Some(seq),
source_domain: Some(domain),
},
)
.await,
CacheWrite::Refused { .. }
));
}
assert_eq!(
restored.get("v", "a").await.unwrap()["value"],
"instruction"
);
}
#[tokio::test]
async fn explicit_current_delete_accepts_a_source_without_position() {
let cache = EntityCache::new();
cache
.upsert_with_append(
"v",
"a",
json!({"value":true,"_seq":"100:000000000010"}),
&[],
PatchOrigin::Creation,
)
.await;
assert!(cache.delete("v", "a", None).await);
assert!(cache.get("v", "a").await.is_none());
assert!(cache.deletion_is_current("v", "a").await);
}
#[tokio::test]
async fn evicted_live_row_does_not_make_an_earlier_delete_current() {
let cache = EntityCache::with_config(EntityCacheConfig {
max_entities_per_view: 1,
..Default::default()
});
cache
.upsert_with_lifetime(
"v",
"a",
json!({"value":true,"_seq":"100:000000000010"}),
&[],
PatchOrigin::Creation,
Some(AccountPosition::new(100, 10)),
)
.await;
cache
.upsert_with_append("v", "b", json!({"value":true}), &[], PatchOrigin::Creation)
.await;
assert!(cache.get("v", "a").await.is_none());
assert!(!cache.deletion_is_current("v", "a").await);
assert!(cache.delete_current("v", "a").await);
assert!(cache.deletion_is_current("v", "a").await);
}
#[tokio::test]
async fn test_basic_upsert_and_get() {
let cache = EntityCache::new();
cache
.upsert("tokens/list", "abc123", json!({"name": "Test Token"}))
.await;
let entity = cache.get("tokens/list", "abc123").await;
assert!(entity.is_some());
assert_eq!(entity.unwrap()["name"], "Test Token");
}
#[tokio::test]
async fn test_deep_merge_objects() {
let cache = EntityCache::new();
cache
.upsert(
"tokens/list",
"abc123",
json!({
"id": "abc123",
"metrics": {"volume": 100}
}),
)
.await;
cache
.upsert(
"tokens/list",
"abc123",
json!({
"metrics": {"trades": 50}
}),
)
.await;
let entity = cache.get("tokens/list", "abc123").await.unwrap();
assert_eq!(entity["id"], "abc123");
assert_eq!(entity["metrics"]["volume"], 100);
assert_eq!(entity["metrics"]["trades"], 50);
}
#[tokio::test]
async fn test_array_append() {
let cache = EntityCache::new();
cache
.upsert(
"tokens/list",
"abc123",
json!({
"events": [{"type": "buy", "amount": 100}]
}),
)
.await;
cache
.upsert_with_append(
"tokens/list",
"abc123",
json!({
"events": [{"type": "sell", "amount": 50}]
}),
&["events".to_string()],
PatchOrigin::Unknown,
)
.await;
let entity = cache.get("tokens/list", "abc123").await.unwrap();
let events = entity["events"].as_array().unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0]["type"], "buy");
assert_eq!(events[1]["type"], "sell");
}
#[tokio::test]
async fn test_array_lru_eviction() {
let config = EntityCacheConfig {
max_entities_per_view: 1000,
max_array_length: 3,
..Default::default()
};
let cache = EntityCache::with_config(config);
cache
.upsert(
"tokens/list",
"abc123",
json!({
"events": [
{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}
]
}),
)
.await;
let entity = cache.get("tokens/list", "abc123").await.unwrap();
let events = entity["events"].as_array().unwrap();
assert_eq!(events.len(), 3);
assert_eq!(events[0]["id"], 3);
assert_eq!(events[1]["id"], 4);
assert_eq!(events[2]["id"], 5);
}
#[tokio::test]
async fn test_array_append_with_lru() {
let config = EntityCacheConfig {
max_entities_per_view: 1000,
max_array_length: 3,
..Default::default()
};
let cache = EntityCache::with_config(config);
cache
.upsert(
"tokens/list",
"abc123",
json!({
"events": [{"id": 1}, {"id": 2}]
}),
)
.await;
cache
.upsert_with_append(
"tokens/list",
"abc123",
json!({
"events": [{"id": 3}, {"id": 4}]
}),
&["events".to_string()],
PatchOrigin::Unknown,
)
.await;
let entity = cache.get("tokens/list", "abc123").await.unwrap();
let events = entity["events"].as_array().unwrap();
assert_eq!(events.len(), 3);
assert_eq!(events[0]["id"], 2);
assert_eq!(events[1]["id"], 3);
assert_eq!(events[2]["id"], 4);
}
#[tokio::test]
async fn test_entity_lru_eviction() {
let config = EntityCacheConfig {
max_entities_per_view: 2,
max_array_length: 100,
..Default::default()
};
let cache = EntityCache::with_config(config);
cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
cache.upsert("tokens/list", "key3", json!({"id": 3})).await;
assert!(cache.get("tokens/list", "key1").await.is_none());
assert!(cache.get("tokens/list", "key2").await.is_some());
assert!(cache.get("tokens/list", "key3").await.is_some());
}
#[tokio::test]
async fn a_patch_for_an_evicted_key_is_refused_not_stored() {
let cache = EntityCache::with_config(EntityCacheConfig {
max_entities_per_view: 2,
..Default::default()
});
for id in 1..=3 {
assert_eq!(
cache
.upsert(
"tokens/list",
&format!("key{id}"),
json!({"id": id, "n": 0})
)
.await,
CacheWrite::Created
);
}
assert_eq!(
cache.upsert("tokens/list", "key1", json!({"n": 5})).await,
CacheWrite::Refused {
patch: json!({"n": 5})
}
);
assert!(cache.get("tokens/list", "key1").await.is_none());
assert_eq!(
cache.upsert("tokens/list", "key3", json!({"n": 1})).await,
CacheWrite::Merged
);
cache.remove("tokens/list", "key1").await;
assert_eq!(
cache
.upsert("tokens/list", "key1", json!({"id": 1, "n": 6}))
.await,
CacheWrite::Created
);
}
#[tokio::test]
async fn a_whole_entity_ends_an_eviction() {
let cache = EntityCache::with_config(EntityCacheConfig {
max_entities_per_view: 1,
..Default::default()
});
cache.upsert("v", "a", json!({"id": "a", "n": 0})).await;
cache.upsert("v", "b", json!({"id": "b", "n": 0})).await;
assert!(matches!(
cache.upsert("v", "a", json!({"n": 1})).await,
CacheWrite::Refused { .. }
));
cache
.store_whole("v", "a", json!({"id": "a", "n": 1}))
.await;
assert_eq!(cache.get("v", "a").await, Some(json!({"id": "a", "n": 1})));
assert_eq!(cache.len("v").await, 1, "still bounded: b made room");
assert_eq!(
cache.upsert("v", "a", json!({"n": 2})).await,
CacheWrite::Merged
);
}
#[tokio::test]
async fn the_evicted_key_memory_is_bounded() {
let cache = EntityCache::with_config(EntityCacheConfig {
max_entities_per_view: 1,
..Default::default()
});
let remembered = EVICTED_KEYS_PER_CACHED_ENTITY;
for key in 0..=remembered + 1 {
cache
.upsert("v", &key.to_string(), json!({"id": key}))
.await;
}
assert_eq!(
cache.upsert("v", "0", json!({"id": 0})).await,
CacheWrite::Created
);
assert!(matches!(
cache
.upsert("v", &remembered.to_string(), json!({"n": 1}))
.await,
CacheWrite::Refused { .. }
));
}
async fn write(
cache: &EntityCache,
key: &str,
patch: Value,
origin: PatchOrigin,
) -> CacheWrite {
cache.upsert_with_append("v", key, patch, &[], origin).await
}
#[tokio::test]
async fn an_unmarked_patch_for_a_key_not_held_is_refused_however_old() {
let cache = EntityCache::with_config(EntityCacheConfig {
max_entities_per_view: 1,
..Default::default()
});
let evictions = EVICTED_KEYS_PER_CACHED_ENTITY * 4;
for key in 0..=evictions {
assert_eq!(
write(
&cache,
&key.to_string(),
json!({"id": key}),
PatchOrigin::Creation
)
.await,
CacheWrite::Created,
"a creation is stored as soon as it arrives"
);
}
assert_eq!(
write(&cache, "0", json!({"n": 1}), PatchOrigin::Change).await,
CacheWrite::Refused {
patch: json!({"n": 1})
}
);
assert!(
matches!(
write(&cache, "never", json!({"n": 1}), PatchOrigin::Change).await,
CacheWrite::Refused { .. }
),
"nor does it matter whether the view ever held the key"
);
assert_eq!(cache.get("v", "0").await, None);
assert_eq!(
write(
&cache,
&evictions.to_string(),
json!({"n": 1}),
PatchOrigin::Change
)
.await,
CacheWrite::Merged
);
assert_eq!(
write(&cache, "0", json!({"id": 0, "n": 2}), PatchOrigin::Creation).await,
CacheWrite::Created
);
assert_eq!(cache.get("v", "0").await, Some(json!({"id": 0, "n": 2})));
}
#[tokio::test]
async fn a_creation_for_a_key_held_replaces() {
let cache = EntityCache::new();
write(
&cache,
"a",
json!({"id": "a", "old": 1}),
PatchOrigin::Creation,
)
.await;
assert_eq!(
write(
&cache,
"a",
json!({"id": "a", "n": 1}),
PatchOrigin::Creation
)
.await,
CacheWrite::Created
);
assert_eq!(cache.get("v", "a").await, Some(json!({"id": "a", "n": 1})));
}
async fn remembers_evictions(cache: &EntityCache, view_id: &str) -> bool {
cache
.caches
.read()
.await
.get(view_id)
.is_some_and(|view| view.evicted.is_some())
}
#[tokio::test]
async fn a_view_fed_marked_creations_keeps_no_eviction_memory() {
let cache = EntityCache::with_config(EntityCacheConfig {
max_entities_per_view: 1,
..Default::default()
});
cache.remember_evicted("v", ["restored".to_string()]).await;
assert!(remembers_evictions(&cache, "v").await);
write(&cache, "a", json!({"id": "a"}), PatchOrigin::Creation).await;
assert!(!remembers_evictions(&cache, "v").await);
write(&cache, "b", json!({"id": "b"}), PatchOrigin::Creation).await;
assert!(!remembers_evictions(&cache, "v").await, "a was evicted");
assert!(matches!(
write(&cache, "restored", json!({"n": 1}), PatchOrigin::Change).await,
CacheWrite::Refused { .. }
));
assert!(matches!(
write(&cache, "a", json!({"n": 1}), PatchOrigin::Change).await,
CacheWrite::Refused { .. }
));
}
#[tokio::test]
async fn remembered_keys_are_refused_unless_held() {
let cache = EntityCache::new();
cache.upsert("tokens/list", "held", json!({"id": 1})).await;
cache
.remember_evicted("tokens/list", ["held".to_string(), "gone".to_string()])
.await;
assert_eq!(
cache.upsert("tokens/list", "held", json!({"n": 1})).await,
CacheWrite::Merged
);
assert!(matches!(
cache.upsert("tokens/list", "gone", json!({"n": 1})).await,
CacheWrite::Refused { .. }
));
assert_eq!(
cache.upsert("tokens/state", "gone", json!({"id": 2})).await,
CacheWrite::Created
);
}
#[tokio::test]
async fn test_get_all() {
let cache = EntityCache::new();
cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
let all = cache.get_all("tokens/list").await;
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn remove_is_scoped_to_one_entity() {
let cache = EntityCache::new();
cache.upsert("tokens/list", "one", json!({"id": 1})).await;
cache.upsert("tokens/list", "two", json!({"id": 2})).await;
assert_eq!(cache.remove("tokens/list", "one").await.unwrap()["id"], 1);
assert!(cache.get("tokens/list", "one").await.is_none());
assert!(cache.get("tokens/list", "two").await.is_some());
}
#[tokio::test]
async fn test_separate_views() {
let cache = EntityCache::new();
cache
.upsert("tokens/list", "key1", json!({"type": "token"}))
.await;
cache
.upsert("games/list", "key1", json!({"type": "game"}))
.await;
let token = cache.get("tokens/list", "key1").await.unwrap();
let game = cache.get("games/list", "key1").await.unwrap();
assert_eq!(token["type"], "token");
assert_eq!(game["type"], "game");
}
#[test]
fn test_deep_merge_with_append() {
let mut base = json!({
"a": 1,
"b": {"c": 2},
"arr": [1, 2]
});
let patch = json!({
"b": {"d": 3},
"arr": [3],
"e": 4
});
deep_merge_with_append(&mut base, patch, &["arr".to_string()], 100);
assert_eq!(base["a"], 1);
assert_eq!(base["b"]["c"], 2);
assert_eq!(base["b"]["d"], 3);
assert_eq!(base["arr"].as_array().unwrap().len(), 3);
assert_eq!(base["e"], 4);
}
#[test]
fn test_deep_merge_replace_array() {
let mut base = json!({
"arr": [1, 2, 3]
});
let patch = json!({
"arr": [4, 5]
});
deep_merge_with_append(&mut base, patch, &[], 100);
assert_eq!(base["arr"].as_array().unwrap().len(), 2);
assert_eq!(base["arr"][0], 4);
assert_eq!(base["arr"][1], 5);
}
#[test]
fn test_deep_merge_nested_append() {
let mut base = json!({
"stats": {"events": [1, 2]}
});
let patch = json!({
"stats": {"events": [3]}
});
deep_merge_with_append(&mut base, patch, &["stats.events".to_string()], 100);
assert_eq!(base["stats"]["events"].as_array().unwrap().len(), 3);
}
#[test]
fn test_snapshot_config_defaults() {
let cache = EntityCache::new();
let config = cache.snapshot_config();
assert_eq!(config.initial_batch_size, 50);
assert_eq!(config.subsequent_batch_size, 100);
}
#[test]
fn test_snapshot_config_custom() {
let config = EntityCacheConfig {
initial_snapshot_batch_size: 25,
subsequent_snapshot_batch_size: 75,
..Default::default()
};
let cache = EntityCache::with_config(config);
let snapshot_config = cache.snapshot_config();
assert_eq!(snapshot_config.initial_batch_size, 25);
assert_eq!(snapshot_config.subsequent_batch_size, 75);
}
#[tokio::test]
async fn test_get_after() {
let cache = EntityCache::new();
cache
.upsert(
"tokens/list",
"key1",
json!({"id": 1, "_seq": "100:000000000001"}),
)
.await;
cache
.upsert(
"tokens/list",
"key2",
json!({"id": 2, "_seq": "100:000000000002"}),
)
.await;
cache
.upsert(
"tokens/list",
"key3",
json!({"id": 3, "_seq": "100:000000000003"}),
)
.await;
cache
.upsert(
"tokens/list",
"key4",
json!({"id": 4, "_seq": "101:000000000001"}),
)
.await;
let after = cache
.get_after("tokens/list", "100:000000000002", None)
.await;
assert_eq!(after.len(), 2);
assert_eq!(after[0].0, "key3");
assert_eq!(after[1].0, "key4");
}
#[tokio::test]
async fn test_get_after_with_limit() {
let cache = EntityCache::new();
cache
.upsert(
"tokens/list",
"key1",
json!({"id": 1, "_seq": "100:000000000001"}),
)
.await;
cache
.upsert(
"tokens/list",
"key2",
json!({"id": 2, "_seq": "100:000000000002"}),
)
.await;
cache
.upsert(
"tokens/list",
"key3",
json!({"id": 3, "_seq": "100:000000000003"}),
)
.await;
let after = cache
.get_after("tokens/list", "100:000000000000", Some(2))
.await;
assert_eq!(after.len(), 2);
assert_eq!(after[0].0, "key1");
assert_eq!(after[1].0, "key2");
}
#[tokio::test]
async fn test_get_after_empty_result() {
let cache = EntityCache::new();
cache
.upsert(
"tokens/list",
"key1",
json!({"id": 1, "_seq": "100:000000000001"}),
)
.await;
let after = cache
.get_after("tokens/list", "999:000000000000", None)
.await;
assert!(after.is_empty());
}
#[tokio::test]
async fn test_get_after_missing_seq() {
let cache = EntityCache::new();
cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
let after = cache.get_after("tokens/list", "0:000000000000", None).await;
assert!(after.is_empty());
}
fn versions<'a>(views: &[(&'a str, &str)]) -> Vec<ViewVersion<'a>> {
views
.iter()
.map(|(view_id, version)| ViewVersion {
view_id,
version: Some(json!(version)),
})
.collect()
}
const VIEWS: [&str; 3] = ["t/list", "t/state", "t/append"];
async fn shared(cache: &EntityCache, key: &str) -> Vec<SharedEntity> {
let mut entities = Vec::new();
for view in VIEWS {
entities.push(cache.get_shared(view, key).await.unwrap());
}
entities
}
async fn write_all(
cache: &EntityCache,
key: &str,
patch: Value,
stamps: [&str; 3],
origin: PatchOrigin,
) -> Vec<CacheWrite> {
let views: Vec<_> = VIEWS.into_iter().zip(stamps).collect();
cache
.upsert_views(
key,
patch,
&versions(&views),
&["events".to_string()],
origin,
LifetimeOrdering::default(),
)
.await
}
#[tokio::test]
async fn views_written_together_share_one_copy() {
let cache = EntityCache::new();
let writes = write_all(
&cache,
"a",
json!({"id": "a", "events": [1]}),
["e:1", "e:2", "e:3"],
PatchOrigin::Creation,
)
.await;
assert_eq!(writes, vec![CacheWrite::Created; 3]);
let [list, state, append] =
<[SharedEntity; 3]>::try_from(shared(&cache, "a").await).unwrap();
assert!(list.shares_fields_with(&state) && list.shares_fields_with(&append));
assert_eq!(Arc::strong_count(list.fields()), 6);
assert_eq!(
cache.get("t/state", "a").await,
Some(json!({"id": "a", "events": [1], "_version": "e:2"}))
);
let writes = write_all(
&cache,
"a",
json!({"n": 1, "events": [2]}),
["e:4", "e:5", "e:6"],
PatchOrigin::Unknown,
)
.await;
assert_eq!(writes, vec![CacheWrite::Merged; 3]);
let merged = shared(&cache, "a").await;
assert!(
merged[0].shares_fields_with(&merged[1]) && merged[0].shares_fields_with(&merged[2])
);
for (view, version) in VIEWS.into_iter().zip(["e:4", "e:5", "e:6"]) {
assert_eq!(
cache.get(view, "a").await,
Some(json!({"id": "a", "n": 1, "events": [1, 2], "_version": version})),
"{view}"
);
}
assert_eq!(
list.to_value(),
json!({"id": "a", "events": [1], "_version": "e:1"})
);
assert!(!list.shares_fields_with(&merged[0]));
}
#[tokio::test]
async fn a_merge_nobody_else_holds_happens_in_place() {
let cache = EntityCache::new();
write_all(
&cache,
"a",
json!({"id": "a"}),
["e:1", "e:2", "e:3"],
PatchOrigin::Creation,
)
.await;
let before = Arc::as_ptr(cache.get_shared("t/list", "a").await.unwrap().fields());
write_all(
&cache,
"a",
json!({"n": 1}),
["e:4", "e:5", "e:6"],
PatchOrigin::Unknown,
)
.await;
let after = cache.get_shared("t/list", "a").await.unwrap();
assert_eq!(Arc::as_ptr(after.fields()), before);
assert_eq!(Arc::strong_count(after.fields()), 4);
assert_eq!(after["n"], 1);
}
#[tokio::test]
async fn a_patch_to_one_view_does_not_reach_another() {
let cache = EntityCache::new();
write_all(
&cache,
"a",
json!({"id": "a", "nested": {"n": 0}}),
["e:1", "e:2", "e:3"],
PatchOrigin::Creation,
)
.await;
assert_eq!(
cache
.upsert_with_append(
"t/list",
"a",
json!({"nested": {"n": 1}, "_version": "e:4"}),
&[],
PatchOrigin::Unknown,
)
.await,
CacheWrite::Merged
);
assert_eq!(
cache.get("t/list", "a").await,
Some(json!({"id": "a", "nested": {"n": 1}, "_version": "e:4"}))
);
for (view, version) in [("t/state", "e:2"), ("t/append", "e:3")] {
assert_eq!(
cache.get(view, "a").await,
Some(json!({"id": "a", "nested": {"n": 0}, "_version": version})),
"{view}"
);
}
let [list, state, append] =
<[SharedEntity; 3]>::try_from(shared(&cache, "a").await).unwrap();
assert!(!list.shares_fields_with(&state));
assert!(state.shares_fields_with(&append));
}
#[tokio::test]
async fn a_view_that_refuses_a_patch_keeps_its_copy() {
let cache = EntityCache::new();
write_all(
&cache,
"a",
json!({"id": "a", "n": 0}),
["e:1", "e:2", "e:3"],
PatchOrigin::Creation,
)
.await;
assert!(cache.delete_current("t/state", "a").await);
let writes = write_all(
&cache,
"a",
json!({"n": 1}),
["e:4", "e:5", "e:6"],
PatchOrigin::Change,
)
.await;
assert_eq!(writes[0], CacheWrite::Merged);
assert_eq!(
writes[1],
CacheWrite::Refused {
patch: json!({"n": 1, "_version": "e:5"})
}
);
assert_eq!(writes[2], CacheWrite::Merged);
assert_eq!(cache.get("t/state", "a").await, None);
let list = cache.get_shared("t/list", "a").await.unwrap();
let append = cache.get_shared("t/append", "a").await.unwrap();
assert!(list.shares_fields_with(&append));
assert_eq!(
append.to_value(),
json!({"id": "a", "n": 1, "_version": "e:6"})
);
}
#[tokio::test]
async fn a_restore_shares_the_copies_it_saved_per_view() {
let cache = EntityCache::new();
write_all(
&cache,
"a",
json!({"id": "a", "events": [1, 2]}),
["e:1", "e:2", "e:3"],
PatchOrigin::Creation,
)
.await;
let mut dump = cache.dump().await;
dump.push((
"other/list".to_string(),
vec![("a".to_string(), json!({"id": "a", "events": [1, 2]}))],
));
dump.sort_by(|left, right| left.0.cmp(&right.0));
let restored = EntityCache::new();
restored.hydrate(dump.clone()).await;
let other = restored.get_shared("other/list", "a").await.unwrap();
let copies = shared(&restored, "a").await;
assert!(!other.shares_fields_with(&copies[0]));
assert!(
copies[0].shares_fields_with(&copies[1]) && copies[0].shares_fields_with(&copies[2])
);
let mut again = restored.dump().await;
again.sort_by(|left, right| left.0.cmp(&right.0));
assert_eq!(again, dump);
}
#[tokio::test]
async fn a_whole_entity_stored_in_several_views_is_one_copy() {
let cache = EntityCache::new();
let views = versions(&[("t/list", "e:1"), ("t/state", "e:2"), ("t/append", "e:3")]);
let stored = cache
.store_whole_views("a", json!({"id": "a"}), &views, None, None)
.await;
assert_eq!(stored, vec![true; 3]);
let copies = shared(&cache, "a").await;
assert!(
copies[0].shares_fields_with(&copies[1]) && copies[0].shares_fields_with(&copies[2])
);
assert_eq!(
cache.get("t/append", "a").await,
Some(json!({"id": "a", "_version": "e:3"}))
);
}
fn member<'a>(entity: &'a SharedEntity, name: &str) -> &'a Arc<Value> {
match &**entity.fields() {
Fields::Object(members) => members.get(name).expect("the field is there"),
Fields::Other(other) => panic!("not an object: {other}"),
}
}
#[tokio::test]
async fn a_patch_copies_only_the_sections_it_changes() {
let cache = EntityCache::new();
let row = json!({
"id": {"address": "a", "mint": "m"},
"balance": {"amount": "1", "state": "initialized"},
"activity": {"transfers": 0},
});
cache.upsert("t/list", "a", row.clone()).await;
let held = cache.get_shared("t/list", "a").await.unwrap();
cache
.upsert("t/list", "a", json!({"balance": {"amount": "2"}}))
.await;
let after = cache.get_shared("t/list", "a").await.unwrap();
assert!(!after.shares_fields_with(&held));
assert!(Arc::ptr_eq(member(&after, "id"), member(&held, "id")));
assert!(Arc::ptr_eq(
member(&after, "activity"),
member(&held, "activity")
));
assert!(!Arc::ptr_eq(
member(&after, "balance"),
member(&held, "balance")
));
assert_eq!(held.to_value(), row, "the other holder's copy is as it was");
assert_eq!(
after.to_value(),
json!({
"id": {"address": "a", "mint": "m"},
"balance": {"amount": "2", "state": "initialized"},
"activity": {"transfers": 0},
})
);
let held = after;
let mut resent = held.to_value();
resent["activity"]["transfers"] = json!(1);
cache.upsert("t/list", "a", resent.clone()).await;
let after = cache.get_shared("t/list", "a").await.unwrap();
assert!(Arc::ptr_eq(member(&after, "id"), member(&held, "id")));
assert!(Arc::ptr_eq(
member(&after, "balance"),
member(&held, "balance")
));
assert!(!Arc::ptr_eq(
member(&after, "activity"),
member(&held, "activity")
));
assert_eq!(after.to_value(), resent);
}
#[test]
fn a_section_merge_matches_the_whole_value_merge() {
let base = json!({
"id": {"address": "a", "tags": ["x", "y"]},
"balance": {"amount": "1", "nested": {"deep": [1, 2], "n": null}},
"events": [1, 2, 3],
"list": [1],
"scalar": 5,
});
let patches = [
json!({}),
json!({"id": {"address": "a", "tags": ["x", "y"]}}),
json!({"balance": {"amount": "2"}}),
json!({"balance": {"nested": {"deep": [3], "extra": {"arr": [1, 2, 3, 4]}}}}),
json!({"balance": {"nested": {"deep": [1, 2, 3, 4]}}}),
json!({"events": [4]}),
json!({"events": []}),
json!({"events": [4, 5, 6, 7, 8]}),
json!({"list": [2, 3, 4, 5]}),
json!({"list": [1]}),
json!({"scalar": {"now": "an object", "arr": [1, 2, 3, 4]}}),
json!({"scalar": 5, "new": [1, 2, 3, 4, 5], "id": null}),
json!({"balance": [1, 2, 3, 4], "events": {"replaced": true}}),
json!({"balance": {"nested": {"n": null}}}),
json!({"balance": {"amount": 1}}),
json!({"id": {"tags": ["x", "y", "z", "w"]}}),
];
let append = ["events".to_string(), "balance.nested.deep".to_string()];
for max_array_length in [3, 100] {
for patch in &patches {
let mut expected = base.clone();
deep_merge_with_append(&mut expected, patch.clone(), &append, max_array_length);
for shared in [false, true] {
let mut entity = SharedEntity::new(base.clone());
let other = shared.then(|| entity.clone());
merge_shared(&mut entity, patch.clone(), &append, max_array_length);
assert_eq!(
serde_json::to_string(&entity).unwrap(),
serde_json::to_string(&expected).unwrap(),
"{patch} (shared: {shared}, max {max_array_length})"
);
if let Some(other) = other {
assert_eq!(other.to_value(), base, "the other holder's copy");
}
}
}
}
}
#[test]
fn a_shared_merge_matches_the_whole_value_merge() {
let cases = [
(
json!({"a": 1, "_version": "e:1"}),
json!({"b": 2, "_version": "e:2"}),
),
(json!({"a": 1, "_version": "e:1"}), json!({"b": 2})),
(json!({"a": 1, "_version": "e:1"}), json!([1, 2])),
(json!([1, 2]), json!([3])),
(json!([1, 2]), json!({"a": 1, "_version": "e:2"})),
(json!("text"), json!(5)),
(
json!({"_version": "e:1", "list": [1]}),
json!({"list": [2, 3, 4]}),
),
];
for (base, patch) in cases {
let append = ["list".to_string()];
let mut expected = base.clone();
deep_merge_with_append(&mut expected, patch.clone(), &append, 2);
let mut entity = SharedEntity::new(base.clone());
let other = entity.clone();
merge_shared(&mut entity, patch.clone(), &append, 2);
assert_eq!(entity.to_value(), expected, "{base} <- {patch}");
assert_eq!(other.to_value(), base, "the other holder's copy");
}
}
}