use std::{
collections::HashSet,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
},
time::Duration,
};
use dashmap::{DashMap, DashSet};
use fraiseql_db::ViewName;
use moka::sync::Cache as MokaCache;
use serde::{Deserialize, Serialize};
use super::{
config::CacheConfig,
fence::{InvalidationFence, Reach},
tviews_feed::Collection,
};
use crate::{db::types::JsonbValue, error::Result};
#[derive(Debug)]
pub struct CachedResult {
pub result: Arc<Vec<JsonbValue>>,
pub accessed_views: Box<[ViewName]>,
pub cached_at: u64,
pub ttl_seconds: u64,
pub entity_refs: Box<[(String, String)]>,
pub collected_views: Box<[ViewName]>,
pub collections: Box<[(Collection, String)]>,
pub by_id: Option<(String, String)>,
pub epoch: u64,
pub snapshot: u64,
pub counters: Box<[Arc<AtomicU64>]>,
confirmed: AtomicBool,
}
type IndexRef = (u64, u64);
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum RowRef {
Entity(String, String),
ById(String, String),
Collection(String, String, String),
}
type RowIndex = DashMap<RowRef, DashSet<IndexRef>>;
fn row_refs(entry: &CachedResult) -> impl Iterator<Item = RowRef> + '_ {
let entities = entry.entity_refs.iter().map(|(t, id)| RowRef::Entity(t.clone(), id.clone()));
let by_id = entry.by_id.iter().map(|(t, id)| RowRef::ById(t.clone(), id.clone()));
let collections = entry.collections.iter().map(|(collection, parent)| {
RowRef::Collection(collection.child_type.clone(), collection.column.clone(), parent.clone())
});
entities.chain(by_id).chain(collections)
}
#[derive(Clone, Copy)]
enum Sweep {
Full,
Write,
Feed,
}
#[derive(Debug, Clone, Default)]
pub struct ComposedTags {
pub collected_views: Vec<String>,
pub collections: Vec<(Collection, String)>,
pub entities: Vec<(String, String)>,
}
struct CacheEntryExpiry;
impl moka::Expiry<u64, Arc<CachedResult>> for CacheEntryExpiry {
fn expire_after_create(
&self,
_key: &u64,
value: &Arc<CachedResult>,
_created_at: std::time::Instant,
) -> Option<Duration> {
if value.ttl_seconds == 0 {
None
} else {
Some(Duration::from_secs(value.ttl_seconds))
}
}
}
pub struct QueryResultCache {
store: MokaCache<u64, Arc<CachedResult>>,
config: CacheConfig,
hits: AtomicU64,
misses: AtomicU64,
total_cached: AtomicU64,
invalidations: AtomicU64,
memory_bytes: Arc<AtomicUsize>,
rows: Arc<RowIndex>,
next_epoch: AtomicU64,
fence: InvalidationFence,
suspended: AtomicBool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheMetrics {
pub hits: u64,
pub misses: u64,
pub total_cached: u64,
pub invalidations: u64,
pub size: usize,
pub memory_bytes: usize,
}
const fn entry_overhead() -> usize {
std::mem::size_of::<CachedResult>() + std::mem::size_of::<u64>() * 2
}
fn build_store(
config: &CacheConfig,
memory_bytes: Arc<AtomicUsize>,
rows: Arc<RowIndex>,
) -> MokaCache<u64, Arc<CachedResult>> {
let max_cap = config.max_entries as u64;
let mb = memory_bytes;
MokaCache::builder()
.max_capacity(max_cap)
.expire_after(CacheEntryExpiry)
.eviction_listener(move |key: Arc<u64>, value: Arc<CachedResult>, _cause| {
mb.fetch_sub(entry_overhead(), Ordering::Relaxed);
let reference: IndexRef = (*key, value.epoch);
for row in row_refs(&value) {
let emptied = rows.get(&row).is_some_and(|keys| {
keys.remove(&reference);
keys.is_empty()
});
if emptied {
rows.remove_if(&row, |_, keys| keys.is_empty());
}
}
})
.build()
}
impl QueryResultCache {
#[must_use]
pub fn new(config: CacheConfig) -> Self {
assert!(config.max_entries > 0, "max_entries must be > 0");
let memory_bytes = Arc::new(AtomicUsize::new(0));
let rows: Arc<RowIndex> = Arc::new(DashMap::new());
let store = build_store(&config, Arc::clone(&memory_bytes), Arc::clone(&rows));
Self {
store,
config,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
total_cached: AtomicU64::new(0),
invalidations: AtomicU64::new(0),
memory_bytes,
rows,
next_epoch: AtomicU64::new(0),
fence: InvalidationFence::new(),
suspended: AtomicBool::new(false),
}
}
#[must_use]
pub fn invalidation_generation(&self) -> u64 {
self.fence.snapshot()
}
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.config.enabled
}
#[must_use]
pub const fn config(&self) -> &CacheConfig {
&self.config
}
pub fn get(&self, cache_key: u64) -> Result<Option<Arc<Vec<JsonbValue>>>> {
if !self.config.enabled {
return Ok(None);
}
if let Some(cached) = self.store.get(&cache_key).filter(|cached| {
cached.confirmed.load(Ordering::Acquire)
&& self.fence.current(cached.snapshot, &cached.counters)
}) {
self.hits.fetch_add(1, Ordering::Relaxed);
Ok(Some(Arc::clone(&cached.result)))
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
Ok(None)
}
}
pub fn put_arc(
&self,
cache_key: u64,
result: Arc<Vec<JsonbValue>>,
accessed_views: Vec<String>,
ttl_override: Option<u64>,
entity_type: Option<&str>,
fence: Option<u64>,
) -> Result<()> {
self.put_arc_with(
cache_key,
result,
accessed_views,
ttl_override,
entity_type,
fence,
|| {},
|| {},
)
}
#[allow(clippy::too_many_arguments)] pub(super) fn put_arc_with(
&self,
cache_key: u64,
result: Arc<Vec<JsonbValue>>,
accessed_views: Vec<String>,
ttl_override: Option<u64>,
entity_type: Option<&str>,
fence: Option<u64>,
before_insert: impl FnOnce(),
after_insert: impl FnOnce(),
) -> Result<()> {
self.put_tagged(
cache_key,
result,
accessed_views,
ttl_override,
entity_type,
ComposedTags::default(),
None,
fence,
before_insert,
after_insert,
)
}
pub fn put_by_id(
&self,
cache_key: u64,
result: Arc<Vec<JsonbValue>>,
accessed_views: Vec<String>,
ttl_override: Option<u64>,
by_id: (String, String),
fence: Option<u64>,
) -> Result<()> {
self.put_by_id_with(
cache_key,
result,
accessed_views,
ttl_override,
by_id,
fence,
|| {},
|| {},
)
}
#[allow(clippy::too_many_arguments)] pub(super) fn put_by_id_with(
&self,
cache_key: u64,
result: Arc<Vec<JsonbValue>>,
accessed_views: Vec<String>,
ttl_override: Option<u64>,
by_id: (String, String),
fence: Option<u64>,
before_insert: impl FnOnce(),
after_insert: impl FnOnce(),
) -> Result<()> {
self.put_tagged(
cache_key,
result,
accessed_views,
ttl_override,
None,
ComposedTags::default(),
Some(by_id),
fence,
before_insert,
after_insert,
)
}
#[allow(clippy::too_many_arguments)] fn put_tagged(
&self,
cache_key: u64,
result: Arc<Vec<JsonbValue>>,
accessed_views: Vec<String>,
ttl_override: Option<u64>,
entity_type: Option<&str>,
tags: ComposedTags,
by_id: Option<(String, String)>,
fence: Option<u64>,
before_insert: impl FnOnce(),
after_insert: impl FnOnce(),
) -> Result<()> {
if !self.config.enabled || self.suspended.load(Ordering::SeqCst) {
return Ok(());
}
let entity_type = entity_type.filter(|_| by_id.is_none());
let entity_refs: Box<[(String, String)]> = if let Some(et) = entity_type {
result
.iter()
.filter_map(|row| {
row.as_value()
.as_object()?
.get("id")?
.as_str()
.map(|id| (et.to_string(), id.to_string()))
})
.chain(tags.entities.iter().cloned())
.collect::<Vec<_>>()
.into_boxed_slice()
} else {
tags.entities.clone().into_boxed_slice()
};
let reach = Reach {
views: by_id
.is_none()
.then(|| accessed_views.iter().map(String::as_str))
.into_iter()
.flatten()
.collect(),
collected_views: tags.collected_views.iter().map(String::as_str).collect(),
entities: entity_refs.iter().map(|(t, id)| (t.as_str(), id.as_str())).collect(),
collections: tags
.collections
.iter()
.map(|(c, p)| (c.child_type.as_str(), c.column.as_str(), p.as_str()))
.collect(),
by_id: by_id.as_ref().map(|(t, id)| {
((t.as_str(), id.as_str()), accessed_views.iter().map(String::as_str).collect())
}),
};
if fence.is_some_and(|snapshot| self.fence.moved_since(snapshot, &reach)) {
return Ok(());
}
let ttl_seconds = ttl_override.unwrap_or(self.config.ttl_seconds);
if !self.config.cache_list_queries && result.len() > 1 {
return Ok(());
}
if let Some(max_entry) = self.config.max_entry_bytes {
let estimated = serde_json::to_vec(&*result).map_or(0, |v| v.len());
if estimated > max_entry {
return Ok(()); }
}
if let Some(max_total) = self.config.max_total_bytes {
if self.memory_bytes.load(Ordering::Relaxed) >= max_total {
return Ok(()); }
}
let snapshot = fence.unwrap_or_else(|| self.fence.snapshot());
let counters = self.fence.counters(&reach);
let accessed_views: Box<[ViewName]> =
accessed_views.into_iter().map(ViewName::from).collect();
let collected_views: Box<[ViewName]> =
tags.collected_views.into_iter().map(ViewName::from).collect();
let collections: Box<[(Collection, String)]> = tags.collections.into_boxed_slice();
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
let reference: IndexRef = (cache_key, epoch);
let cached = CachedResult {
result,
accessed_views,
cached_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
ttl_seconds,
entity_refs,
collected_views,
collections,
by_id,
epoch,
snapshot,
counters,
confirmed: AtomicBool::new(fence.is_none()),
};
for row in row_refs(&cached) {
self.rows.entry(row).or_default().insert(reference);
}
before_insert();
self.memory_bytes.fetch_add(entry_overhead(), Ordering::Relaxed);
let entry = Arc::new(cached);
self.store.insert(cache_key, Arc::clone(&entry));
after_insert();
if fence.is_some_and(|snapshot| {
self.fence.moved_since_inserted(
snapshot,
&Reach {
entities: entry
.entity_refs
.iter()
.map(|(t, id)| (t.as_str(), id.as_str()))
.collect(),
collections: entry
.collections
.iter()
.map(|(c, p)| (c.child_type.as_str(), c.column.as_str(), p.as_str()))
.collect(),
by_id: entry
.by_id
.as_ref()
.map(|(t, id)| ((t.as_str(), id.as_str()), Vec::new())),
..Reach::default()
},
)
}) {
self.store.invalidate(&cache_key);
return Ok(());
}
entry.confirmed.store(true, Ordering::Release);
self.total_cached.fetch_add(1, Ordering::Relaxed);
Ok(())
}
pub fn put(
&self,
cache_key: u64,
result: Vec<JsonbValue>,
accessed_views: Vec<String>,
ttl_override: Option<u64>,
entity_type: Option<&str>,
fence: Option<u64>,
) -> Result<()> {
self.put_arc(cache_key, Arc::new(result), accessed_views, ttl_override, entity_type, fence)
}
#[allow(clippy::too_many_arguments)] pub fn put_composed(
&self,
cache_key: u64,
result: Arc<Vec<JsonbValue>>,
accessed_views: Vec<String>,
ttl_override: Option<u64>,
entity_type: Option<&str>,
tags: ComposedTags,
fence: Option<u64>,
) -> Result<()> {
self.put_tagged(
cache_key,
result,
accessed_views,
ttl_override,
entity_type,
tags,
None,
fence,
|| {},
|| {},
)
}
pub fn invalidate_by_id(&self, type_name: &str, id: &str) -> Result<u64> {
if !self.config.enabled {
return Ok(0);
}
self.fence.stamp_by_id(type_name, id);
Ok(self.evict_row(&RowRef::ById(type_name.to_string(), id.to_string())))
}
pub fn invalidate_by_id_type(&self, type_name: &str) -> Result<()> {
if self.config.enabled {
self.fence.stamp_by_id_type(type_name);
}
Ok(())
}
pub fn invalidate_collection(&self, collection: &Collection, parent_id: &str) -> Result<u64> {
if !self.config.enabled {
return Ok(0);
}
self.fence
.stamp_collection(&collection.child_type, &collection.column, parent_id);
Ok(self.evict_row(&RowRef::Collection(
collection.child_type.clone(),
collection.column.clone(),
parent_id.to_string(),
)))
}
pub fn invalidate_collections_of(&self, child_type: &str) -> Result<()> {
if self.config.enabled {
self.fence.stamp_collection_type(child_type);
}
Ok(())
}
fn evict_row(&self, row: &RowRef) -> u64 {
let keys: HashSet<u64> = self
.rows
.get(row)
.map(|refs| refs.iter().map(|r| r.0).collect())
.unwrap_or_default();
#[allow(clippy::cast_possible_truncation)]
let count = keys.len() as u64;
for key in keys {
self.store.invalidate(&key);
}
self.invalidations.fetch_add(count, Ordering::Relaxed);
count
}
pub fn invalidate_views_except_collections(&self, views: &[ViewName]) -> Result<()> {
self.sweep_views(views, Sweep::Feed)
}
pub fn invalidate_views_except_by_id(&self, views: &[ViewName]) -> Result<()> {
self.sweep_views(views, Sweep::Write)
}
pub fn invalidate_views(&self, views: &[ViewName]) -> Result<()> {
self.sweep_views(views, Sweep::Full)
}
fn sweep_views(&self, views: &[ViewName], sweep: Sweep) -> Result<()> {
if !self.config.enabled {
return Ok(());
}
let names = views.iter().map(ViewName::as_str);
match sweep {
Sweep::Full => self.fence.stamp_views(names),
Sweep::Write => self.fence.stamp_write_views(names),
Sweep::Feed => self.fence.stamp_feed_views(names),
}
Ok(())
}
pub fn invalidate_by_entity(&self, entity_type: &str, entity_id: &str) -> Result<u64> {
if !self.config.enabled {
return Ok(0);
}
let by_id = self.invalidate_by_id(entity_type, entity_id)?;
self.fence.stamp_entity(entity_type, entity_id);
Ok(by_id + self.evict_row(&RowRef::Entity(entity_type.to_string(), entity_id.to_string())))
}
pub fn metrics(&self) -> Result<CacheMetrics> {
Ok(CacheMetrics {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
total_cached: self.total_cached.load(Ordering::Relaxed),
invalidations: self.invalidations.load(Ordering::Relaxed),
#[allow(clippy::cast_possible_truncation)]
size: self.store.entry_count() as usize,
memory_bytes: self.memory_bytes.load(Ordering::Relaxed),
})
}
pub fn clear(&self) -> Result<()> {
self.fence.stamp_everything();
self.store.invalidate_all();
self.rows.clear();
self.memory_bytes.store(0, Ordering::Relaxed);
Ok(())
}
pub fn suspend(&self) {
self.suspended.store(true, Ordering::SeqCst);
let _ = self.clear();
}
pub fn resume(&self) {
self.suspended.store(false, Ordering::SeqCst);
let _ = self.clear();
}
#[cfg(test)]
fn row_key_count(&self) -> usize {
self.rows.len()
}
pub fn run_pending_tasks(&self) {
self.store.run_pending_tasks();
}
}
impl CacheMetrics {
#[must_use]
pub fn hit_rate(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
return 0.0;
}
#[allow(clippy::cast_precision_loss)]
{
self.hits as f64 / total as f64
}
}
#[must_use]
pub fn is_healthy(&self) -> bool {
self.hit_rate() > 0.6
}
}
#[cfg(test)]
#[path = "result_collections_tests.rs"]
mod result_collections_tests;
#[cfg(test)]
#[path = "result_by_id_tests.rs"]
mod result_by_id_tests;
#[cfg(test)]
#[path = "result_names_tests.rs"]
mod result_names_tests;
#[cfg(test)]
#[path = "result_rows_tests.rs"]
mod result_rows_tests;
#[cfg(test)]
#[path = "result_stress_tests.rs"]
mod result_stress_tests;