use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use dashmap::DashMap;
use crate::{
embedded::Query,
error::Result,
prime::{event_store::EventStore, http_core::HttpCore, projection_bundle::GraphProjections},
};
struct Entry {
bundle: Arc<GraphProjections>,
hydrated_at: Instant,
last_access: Mutex<Instant>,
}
pub struct TenantProjectionCache {
base_url: String,
api_key: Option<String>,
ttl: Duration,
capacity: usize,
entries: DashMap<String, Arc<Entry>>,
}
impl TenantProjectionCache {
pub fn new(
base_url: impl Into<String>,
api_key: Option<String>,
capacity: usize,
ttl: Duration,
) -> Self {
Self {
base_url: base_url.into(),
api_key,
ttl,
capacity: capacity.max(1),
entries: DashMap::new(),
}
}
pub async fn get_or_hydrate(&self, tenant_id: &str) -> Result<Arc<GraphProjections>> {
if let Some(entry) = self.entries.get(tenant_id)
&& entry.hydrated_at.elapsed() < self.ttl
{
if let Ok(mut la) = entry.last_access.lock() {
*la = Instant::now();
}
return Ok(Arc::clone(&entry.bundle));
}
let bundle = Arc::new(self.hydrate(tenant_id).await?);
let now = Instant::now();
self.entries.insert(
tenant_id.to_string(),
Arc::new(Entry {
bundle: Arc::clone(&bundle),
hydrated_at: now,
last_access: Mutex::new(now),
}),
);
self.evict_if_over_capacity();
Ok(bundle)
}
pub fn apply(&self, tenant_id: &str, view: &crate::embedded::EventView) {
if let Some(entry) = self.entries.get(tenant_id) {
entry.bundle.apply(view);
}
}
pub fn invalidate(&self, tenant_id: &str) {
self.entries.remove(tenant_id);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
async fn hydrate(&self, tenant_id: &str) -> Result<GraphProjections> {
let core = HttpCore::new(
self.base_url.clone(),
self.api_key.clone(),
Some(tenant_id.to_string()),
);
let events = core.query(Query::new().event_type_prefix("prime.")).await?;
Ok(GraphProjections::from_event_views(&events))
}
fn evict_if_over_capacity(&self) {
while self.entries.len() > self.capacity {
let lru = self
.entries
.iter()
.min_by_key(|e| {
e.value()
.last_access
.lock()
.map_or_else(|_| Instant::now(), |t| *t)
})
.map(|e| e.key().clone());
match lru {
Some(key) => {
self.entries.remove(&key);
}
None => break,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::prime::types::event_types;
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path, query_param},
};
fn node_event(entity_id: &str, tenant: &str, name: &str) -> serde_json::Value {
json!({
"id": "00000000-0000-0000-0000-000000000001",
"event_type": event_types::NODE_CREATED,
"entity_id": entity_id,
"tenant_id": tenant,
"payload": { "id": entity_id, "node_type": "contact", "properties": { "name": name } },
"metadata": null,
"timestamp": "2026-06-07T00:00:00Z",
"version": 1
})
}
#[tokio::test]
async fn miss_hydrates_then_hit_serves_warm_without_requery() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/events/query"))
.and(query_param("tenant_id", "tenant-a"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({ "events": [node_event("node:contact:alice", "tenant-a", "Alice")] }),
))
.expect(1) .mount(&server)
.await;
let cache = TenantProjectionCache::new(server.uri(), None, 8, Duration::from_secs(60));
let b1 = cache.get_or_hydrate("tenant-a").await.unwrap();
assert!(b1.node_state.get_node("node:contact:alice").is_some());
let b2 = cache.get_or_hydrate("tenant-a").await.unwrap();
assert!(Arc::ptr_eq(&b1, &b2));
}
#[tokio::test]
async fn tenants_are_isolated() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/events/query"))
.and(query_param("tenant_id", "tenant-a"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({ "events": [node_event("node:contact:alice", "tenant-a", "Alice")] }),
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/events/query"))
.and(query_param("tenant_id", "tenant-b"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({ "events": [node_event("node:contact:bob", "tenant-b", "Bob")] }),
))
.mount(&server)
.await;
let cache = TenantProjectionCache::new(server.uri(), None, 8, Duration::from_secs(60));
let a = cache.get_or_hydrate("tenant-a").await.unwrap();
let b = cache.get_or_hydrate("tenant-b").await.unwrap();
assert!(a.node_state.get_node("node:contact:alice").is_some());
assert!(a.node_state.get_node("node:contact:bob").is_none());
assert!(b.node_state.get_node("node:contact:bob").is_some());
assert!(b.node_state.get_node("node:contact:alice").is_none());
}
#[tokio::test]
async fn lru_eviction_bounds_capacity() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/events/query"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "events": [] })))
.mount(&server)
.await;
let cache = TenantProjectionCache::new(server.uri(), None, 2, Duration::from_secs(60));
cache.get_or_hydrate("t1").await.unwrap();
cache.get_or_hydrate("t2").await.unwrap();
cache.get_or_hydrate("t3").await.unwrap();
assert_eq!(cache.len(), 2, "cache must not exceed capacity");
}
}