use crate::cache::Cache;
use crate::error::CacheError;
use crate::value::Value;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::time::Instant;
#[derive(Debug, Clone)]
pub enum InvalidationMessage {
InvalidateKey(String),
InvalidateTable(String),
InvalidateAll,
}
pub trait InvalidationBus: Send + Sync {
fn publish(&self, message: InvalidationMessage);
fn subscribe(&self) -> Box<dyn Iterator<Item = InvalidationMessage> + Send>;
}
pub struct LocalInvalidationBus {
tx: tokio::sync::broadcast::Sender<InvalidationMessage>,
}
impl LocalInvalidationBus {
pub fn new(capacity: usize) -> Self {
let (tx, _rx) = tokio::sync::broadcast::channel(capacity.max(1));
Self { tx }
}
}
impl Default for LocalInvalidationBus {
fn default() -> Self {
Self::new(256)
}
}
impl InvalidationBus for LocalInvalidationBus {
fn publish(&self, message: InvalidationMessage) {
let _ = self.tx.send(message);
}
fn subscribe(&self) -> Box<dyn Iterator<Item = InvalidationMessage> + Send> {
let mut rx = self.tx.subscribe();
Box::new(std::iter::from_fn(move || loop {
match rx.try_recv() {
Ok(msg) => return Some(msg),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
| Err(tokio::sync::broadcast::error::TryRecvError::Closed) => return None,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
}
}))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub table: String,
pub kind: CacheKeyKind,
pub identifier: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CacheKeyKind {
ByPk,
ByQuery,
ByRelation,
}
impl CacheKey {
pub fn by_pk(table: impl Into<String>, pk: impl std::fmt::Display) -> Self {
Self {
table: table.into(),
kind: CacheKeyKind::ByPk,
identifier: pk.to_string(),
}
}
pub fn by_query(table: impl Into<String>, query_hash: impl std::fmt::Display) -> Self {
Self {
table: table.into(),
kind: CacheKeyKind::ByQuery,
identifier: query_hash.to_string(),
}
}
pub fn by_relation(table: impl Into<String>, relation: impl std::fmt::Display) -> Self {
Self {
table: table.into(),
kind: CacheKeyKind::ByRelation,
identifier: relation.to_string(),
}
}
pub fn to_string_key(&self) -> String {
let kind_str = match self.kind {
CacheKeyKind::ByPk => "pk",
CacheKeyKind::ByQuery => "q",
CacheKeyKind::ByRelation => "rel",
};
format!("l2:{}:{}:{}", self.table, kind_str, self.identifier)
}
}
impl std::fmt::Display for CacheKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_string_key())
}
}
#[derive(Debug, Clone, Default)]
pub struct L2CacheStats {
pub hits: u64,
pub misses: u64,
pub sets: u64,
pub evictions: u64,
pub size: usize,
}
#[derive(Debug, Clone, Default)]
pub struct PerTableStats {
pub hits: u64,
pub misses: u64,
pub sets: u64,
pub evictions: u64,
}
impl PerTableStats {
pub fn total_lookups(&self) -> u64 {
self.hits + self.misses
}
pub fn hit_rate(&self) -> f64 {
let total = self.total_lookups();
if total == 0 {
0.0
} else {
self.hits as f64 / total as f64
}
}
}
impl L2CacheStats {
pub fn total_lookups(&self) -> u64 {
self.hits + self.misses
}
pub fn hit_rate(&self) -> f64 {
let total = self.total_lookups();
if total == 0 {
0.0
} else {
self.hits as f64 / total as f64
}
}
pub fn miss_rate(&self) -> f64 {
1.0 - self.hit_rate()
}
pub fn merge(&mut self, other: &L2CacheStats) {
self.hits += other.hits;
self.misses += other.misses;
self.sets += other.sets;
self.evictions += other.evictions;
self.size += other.size;
}
}
#[derive(Debug, Clone)]
struct CacheEntry {
value: Value,
expires_at: Option<Instant>,
}
impl CacheEntry {
fn new(value: Value, ttl: Option<Duration>) -> Self {
let expires_at = ttl.and_then(|d| {
if d == Duration::MAX {
None
} else {
Some(Instant::now() + d)
}
});
Self { value, expires_at }
}
fn is_expired(&self) -> bool {
self.expires_at
.map(|t| t <= Instant::now())
.unwrap_or(false)
}
}
struct LruOrder {
nodes: Vec<LruNode>,
free_list: Vec<usize>,
index: HashMap<String, usize>,
head: Option<usize>,
tail: Option<usize>,
}
struct LruNode {
key: String,
prev: Option<usize>,
next: Option<usize>,
}
impl LruOrder {
fn new() -> Self {
Self {
nodes: Vec::new(),
free_list: Vec::new(),
index: HashMap::new(),
head: None,
tail: None,
}
}
fn touch(&mut self, key: &str) {
if let Some(&idx) = self.index.get(key) {
self.unlink(idx);
self.link_tail(idx);
} else {
let idx = self.alloc_node(key.to_string());
self.link_tail(idx);
self.index.insert(key.to_string(), idx);
}
}
fn remove(&mut self, key: &str) {
if let Some(idx) = self.index.remove(key) {
self.unlink(idx);
self.free_node(idx);
}
}
fn lru_key(&self) -> Option<&str> {
self.head.map(|idx| self.nodes[idx].key.as_str())
}
fn iter_keys(&self) -> impl Iterator<Item = &str> {
LruIter {
nodes: &self.nodes,
current: self.head,
}
}
fn clear(&mut self) {
self.nodes.clear();
self.free_list.clear();
self.index.clear();
self.head = None;
self.tail = None;
}
#[allow(dead_code)]
fn len(&self) -> usize {
self.index.len()
}
fn alloc_node(&mut self, key: String) -> usize {
if let Some(idx) = self.free_list.pop() {
self.nodes[idx] = LruNode {
key,
prev: None,
next: None,
};
idx
} else {
self.nodes.push(LruNode {
key,
prev: None,
next: None,
});
self.nodes.len() - 1
}
}
fn free_node(&mut self, idx: usize) {
self.free_list.push(idx);
}
fn unlink(&mut self, idx: usize) {
let prev = self.nodes[idx].prev;
let next = self.nodes[idx].next;
match prev {
Some(p) => self.nodes[p].next = next,
None => self.head = next,
}
match next {
Some(n) => self.nodes[n].prev = prev,
None => self.tail = prev,
}
self.nodes[idx].prev = None;
self.nodes[idx].next = None;
}
fn link_tail(&mut self, idx: usize) {
match self.tail {
Some(t) => {
self.nodes[t].next = Some(idx);
self.nodes[idx].prev = Some(t);
}
None => self.head = Some(idx),
}
self.nodes[idx].next = None;
self.tail = Some(idx);
}
}
struct LruIter<'a> {
nodes: &'a [LruNode],
current: Option<usize>,
}
impl<'a> Iterator for LruIter<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<Self::Item> {
let idx = self.current?;
let node = &self.nodes[idx];
self.current = node.next;
Some(node.key.as_str())
}
}
pub struct L2Cache {
data: RwLock<HashMap<String, CacheEntry>>,
table_index: RwLock<HashMap<String, Vec<String>>>,
access_order: RwLock<LruOrder>,
stats: RwLock<L2CacheStats>,
table_stats: RwLock<HashMap<String, PerTableStats>>,
default_ttl: Option<Duration>,
max_size: usize,
invalidation_bus: Option<Arc<dyn InvalidationBus>>,
}
impl Default for L2Cache {
fn default() -> Self {
Self::new()
}
}
impl L2Cache {
pub fn new() -> Self {
Self {
data: RwLock::new(HashMap::new()),
table_index: RwLock::new(HashMap::new()),
access_order: RwLock::new(LruOrder::new()),
stats: RwLock::new(L2CacheStats::default()),
table_stats: RwLock::new(HashMap::new()),
default_ttl: None,
max_size: 10_000,
invalidation_bus: None,
}
}
pub fn with_default_ttl(mut self, ttl: Duration) -> Self {
self.default_ttl = Some(ttl);
self
}
pub fn with_max_size(mut self, max_size: usize) -> Self {
self.max_size = max_size;
self
}
pub fn with_invalidation_bus(mut self, bus: Arc<dyn InvalidationBus>) -> Self {
self.invalidation_bus = Some(bus);
self
}
pub fn put(&self, key: &CacheKey, value: Value, ttl: Option<Duration>) {
let actual_ttl = ttl.or(self.default_ttl);
let entry = CacheEntry::new(value, actual_ttl);
let key_str = key.to_string_key();
{
let mut data = self.data.write().expect("L2Cache data lock poisoned (put)");
let exists = data.contains_key(&key_str);
if !exists && data.len() >= self.max_size {
let victim = {
let order = self
.access_order
.read()
.expect("L2Cache access_order lock poisoned (put-victim-read)");
let expired = order
.iter_keys()
.find(|k| data.get(*k).map(|e| e.is_expired()).unwrap_or(false))
.map(|s| s.to_string());
let lru = order.lru_key().map(|s| s.to_string());
expired.or(lru)
};
if let Some(victim) = victim {
data.remove(&victim);
let mut order = self
.access_order
.write()
.expect("L2Cache access_order lock poisoned (put-victim-remove)");
order.remove(&victim);
}
}
data.insert(key_str.clone(), entry);
};
{
let mut order = self
.access_order
.write()
.expect("L2Cache access_order lock poisoned (put-touch)");
order.touch(&key_str);
}
{
let mut idx = self
.table_index
.write()
.expect("L2Cache table_index lock poisoned (put)");
let keys = idx.entry(key.table.clone()).or_default();
if !keys.contains(&key_str) {
keys.push(key_str);
}
}
{
let mut stats = self
.stats
.write()
.expect("L2Cache stats lock poisoned (put)");
stats.sets += 1;
}
{
if let Ok(mut tbl_stats) = self.table_stats.write() {
tbl_stats.entry(key.table.clone()).or_default().sets += 1;
}
}
}
pub fn get(&self, key: &CacheKey) -> Option<Value> {
let key_str = key.to_string_key();
let table_name = key.table.clone();
let result = {
let data = self.data.read().ok()?;
if let Some(entry) = data.get(&key_str) {
if entry.is_expired() {
None
} else {
Some(entry.value.clone())
}
} else {
None
}
};
if result.is_some() {
let mut order = self
.access_order
.write()
.expect("L2Cache access_order lock poisoned (get)");
order.touch(&key_str);
}
if let Ok(mut stats) = self.stats.write() {
if result.is_some() {
stats.hits += 1;
} else {
stats.misses += 1;
}
}
if let Ok(mut tbl_stats) = self.table_stats.write() {
let entry = tbl_stats.entry(table_name).or_default();
if result.is_some() {
entry.hits += 1;
} else {
entry.misses += 1;
}
}
result
}
pub fn invalidate(&self, key: &CacheKey) {
let key_str = key.to_string_key();
let table_name = key.table.clone();
let removed = {
let mut data = self
.data
.write()
.expect("L2Cache data lock poisoned (invalidate)");
data.remove(&key_str).is_some()
};
if removed {
let mut order = self
.access_order
.write()
.expect("L2Cache access_order lock poisoned (invalidate)");
order.remove(&key_str);
}
if removed {
let mut stats = self
.stats
.write()
.expect("L2Cache stats lock poisoned (invalidate)");
stats.evictions += 1;
if let Ok(mut tbl_stats) = self.table_stats.write() {
tbl_stats.entry(table_name).or_default().evictions += 1;
}
}
}
pub fn invalidate_table(&self, table: &str) {
let keys_to_remove: Vec<String> = {
let idx = match self.table_index.read() {
Ok(i) => i,
Err(_) => return,
};
idx.get(table).cloned().unwrap_or_default()
};
let mut actually_removed: usize = 0;
{
let mut data = self
.data
.write()
.expect("L2Cache data lock poisoned (invalidate_table)");
for k in &keys_to_remove {
if data.remove(k).is_some() {
actually_removed += 1;
}
}
}
if actually_removed > 0 {
let mut order = self
.access_order
.write()
.expect("L2Cache access_order lock poisoned (invalidate_table)");
for k in &keys_to_remove {
order.remove(k);
}
}
if let Ok(mut idx) = self.table_index.write() {
idx.remove(table);
}
if actually_removed > 0 {
let mut stats = self
.stats
.write()
.expect("L2Cache stats lock poisoned (invalidate_table)");
stats.evictions += actually_removed as u64;
if let Ok(mut tbl_stats) = self.table_stats.write() {
tbl_stats.entry(table.to_string()).or_default().evictions +=
actually_removed as u64;
}
}
if let Some(bus) = &self.invalidation_bus {
bus.publish(InvalidationMessage::InvalidateTable(table.to_string()));
}
}
pub fn clear(&self) {
let removed = {
let mut data = self
.data
.write()
.expect("L2Cache data lock poisoned (clear)");
let n = data.len();
data.clear();
n
};
if let Ok(mut order) = self.access_order.write() {
order.clear();
}
if let Ok(mut idx) = self.table_index.write() {
idx.clear();
}
if let Ok(mut tbl_stats) = self.table_stats.write() {
tbl_stats.clear();
}
if removed > 0 {
let mut stats = self
.stats
.write()
.expect("L2Cache stats lock poisoned (clear)");
stats.evictions += removed as u64;
stats.size = 0;
}
}
pub fn size(&self) -> usize {
self.data.read().map(|d| d.len()).unwrap_or(0)
}
pub fn stats(&self) -> L2CacheStats {
let mut s = self.stats.read().map(|s| s.clone()).unwrap_or_default();
s.size = self.size();
s
}
pub fn reset_stats(&self) {
if let Ok(mut stats) = self.stats.write() {
*stats = L2CacheStats::default();
}
if let Ok(mut tbl_stats) = self.table_stats.write() {
tbl_stats.clear();
}
}
pub fn table_stats(&self, table: &str) -> Option<PerTableStats> {
self.table_stats
.read()
.ok()
.and_then(|s| s.get(table).cloned())
}
pub fn all_table_stats(&self) -> HashMap<String, PerTableStats> {
self.table_stats
.read()
.map(|s| s.clone())
.unwrap_or_default()
}
pub fn contains(&self, key: &CacheKey) -> bool {
let key_str = key.to_string_key();
self.data
.read()
.map(|d| d.get(&key_str).map(|e| !e.is_expired()).unwrap_or(false))
.unwrap_or(false)
}
pub fn evict_expired(&self) -> usize {
let expired_keys: Vec<String> = {
let data = self
.data
.read()
.expect("L2Cache data lock poisoned (evict_expired-read)");
data.iter()
.filter(|(_, e)| e.is_expired())
.map(|(k, _)| k.clone())
.collect()
};
let key_to_table: HashMap<String, String> = {
let idx = self
.table_index
.read()
.expect("L2Cache table_index lock poisoned (evict_expired-idx)");
let mut map = HashMap::new();
for (table, keys) in idx.iter() {
for k in keys {
map.insert(k.clone(), table.clone());
}
}
map
};
let mut removed = 0;
if !expired_keys.is_empty() {
let mut data = self
.data
.write()
.expect("L2Cache data lock poisoned (evict_expired-write)");
for k in &expired_keys {
if data.remove(k).is_some() {
removed += 1;
}
}
}
if removed > 0 {
let mut order = self
.access_order
.write()
.expect("L2Cache access_order lock poisoned (evict_expired)");
for k in &expired_keys {
order.remove(k);
}
{
let mut stats = self
.stats
.write()
.expect("L2Cache stats lock poisoned (evict_expired)");
stats.evictions += removed as u64;
}
if let Ok(mut tbl_stats) = self.table_stats.write() {
for k in &expired_keys {
if let Some(table) = key_to_table.get(k) {
tbl_stats.entry(table.clone()).or_default().evictions += 1;
}
}
}
}
removed
}
pub fn update_ttl(&self, key: &CacheKey, ttl: Duration) -> bool {
let key_str = key.to_string_key();
let mut data = match self.data.write() {
Ok(d) => d,
Err(_) => return false,
};
if let Some(entry) = data.get_mut(&key_str) {
entry.expires_at = Some(Instant::now() + ttl);
true
} else {
false
}
}
pub fn remaining_ttl(&self, key: &CacheKey) -> Option<Option<Duration>> {
let key_str = key.to_string_key();
let data = self.data.read().ok()?;
let entry = data.get(&key_str)?;
match entry.expires_at {
Some(expires_at) => {
let now = Instant::now();
if expires_at <= now {
None
} else {
Some(Some(expires_at.duration_since(now)))
}
}
None => Some(None),
}
}
}
impl Cache for L2Cache {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
let cache_key = CacheKey::by_pk("__cache__", key);
match L2Cache::get(self, &cache_key) {
Some(Value::Bytes(bytes)) => Ok(Some(bytes)),
Some(other) => {
let json = serde_json::to_vec(&other)
.map_err(|e| CacheError::SerializationError(e.to_string()))?;
Ok(Some(json))
}
None => Ok(None),
}
}
fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) -> Result<(), CacheError> {
let cache_key = CacheKey::by_pk("__cache__", key);
self.put(&cache_key, Value::Bytes(value), ttl);
Ok(())
}
fn delete(&self, key: &str) -> Result<(), CacheError> {
let cache_key = CacheKey::by_pk("__cache__", key);
self.invalidate(&cache_key);
Ok(())
}
fn clear(&self) -> Result<(), CacheError> {
self.invalidate_table("__cache__");
Ok(())
}
fn exists(&self, key: &str) -> Result<bool, CacheError> {
let cache_key = CacheKey::by_pk("__cache__", key);
Ok(self.contains(&cache_key))
}
fn expire(&self, key: &str, ttl: Duration) -> Result<(), CacheError> {
let cache_key = CacheKey::by_pk("__cache__", key);
if self.update_ttl(&cache_key, ttl) {
Ok(())
} else {
Err(CacheError::NotFound(key.to_string()))
}
}
fn ttl(&self, key: &str) -> Result<Option<Duration>, CacheError> {
let cache_key = CacheKey::by_pk("__cache__", key);
match self.remaining_ttl(&cache_key) {
None => Err(CacheError::NotFound(key.to_string())),
Some(None) => Ok(None),
Some(Some(d)) => Ok(Some(d)),
}
}
}
pub type L2CacheFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, CacheError>> + Send + 'a>>;
pub trait L2CacheBackend: Send + Sync {
fn get<'a>(&'a self, key: &'a str) -> L2CacheFuture<'a, Option<Vec<u8>>>;
fn set<'a>(
&'a self,
key: &'a str,
value: &'a [u8],
ttl: Option<Duration>,
) -> L2CacheFuture<'a, ()>;
fn delete<'a>(&'a self, key: &'a str) -> L2CacheFuture<'a, ()>;
fn invalidate_prefix<'a>(&'a self, prefix: &'a str) -> L2CacheFuture<'a, ()>;
}
pub struct InMemoryBackend {
data: RwLock<InMemoryCacheData>,
}
type InMemoryCacheData = HashMap<String, (Vec<u8>, Option<Instant>)>;
impl Default for InMemoryBackend {
fn default() -> Self {
Self::new()
}
}
impl InMemoryBackend {
pub fn new() -> Self {
Self {
data: RwLock::new(HashMap::new()),
}
}
}
impl L2CacheBackend for InMemoryBackend {
fn get<'a>(&'a self, key: &'a str) -> L2CacheFuture<'a, Option<Vec<u8>>> {
let result = {
let data = match self.data.read() {
Ok(d) => d,
Err(e) => {
let err = CacheError::from(e);
return Box::pin(async move { Err(err) });
}
};
match data.get(key) {
Some((value, expiry)) => {
if expiry.map(|t| t <= Instant::now()).unwrap_or(false) {
Ok(None)
} else {
Ok(Some(value.clone()))
}
}
None => Ok(None),
}
};
Box::pin(async move { result })
}
fn set<'a>(
&'a self,
key: &'a str,
value: &'a [u8],
ttl: Option<Duration>,
) -> L2CacheFuture<'a, ()> {
let result = {
let mut data = match self.data.write() {
Ok(d) => d,
Err(e) => {
let err = CacheError::from(e);
return Box::pin(async move { Err(err) });
}
};
let expiry = ttl.map(|d| Instant::now() + d);
data.insert(key.to_string(), (value.to_vec(), expiry));
Ok(())
};
Box::pin(async move { result })
}
fn delete<'a>(&'a self, key: &'a str) -> L2CacheFuture<'a, ()> {
let result = {
let mut data = match self.data.write() {
Ok(d) => d,
Err(e) => {
let err = CacheError::from(e);
return Box::pin(async move { Err(err) });
}
};
data.remove(key);
Ok(())
};
Box::pin(async move { result })
}
fn invalidate_prefix<'a>(&'a self, prefix: &'a str) -> L2CacheFuture<'a, ()> {
let result = {
let mut data = match self.data.write() {
Ok(d) => d,
Err(e) => {
let err = CacheError::from(e);
return Box::pin(async move { Err(err) });
}
};
let keys_to_remove: Vec<String> = data
.keys()
.filter(|k| k.starts_with(prefix))
.cloned()
.collect();
for k in keys_to_remove {
data.remove(&k);
}
Ok(())
};
Box::pin(async move { result })
}
}
#[cfg(feature = "redis")]
pub struct RedisBackend {
manager: redis::aio::ConnectionManager,
}
#[cfg(feature = "redis")]
impl RedisBackend {
pub async fn new(url: impl Into<String>) -> Result<Self, CacheError> {
let url = url.into();
let client = redis::Client::open(url.as_str())
.map_err(|e| CacheError::Internal(format!("Redis client create failed: {}", e)))?;
let manager = redis::aio::ConnectionManager::new(client)
.await
.map_err(|e| CacheError::Internal(format!("Redis connect failed: {}", e)))?;
Ok(Self { manager })
}
pub fn from_manager(manager: redis::aio::ConnectionManager) -> Self {
Self { manager }
}
async fn invalidate_prefix_inner(&self, prefix: &str) -> Result<(), CacheError> {
let pattern = format!("{}*", prefix);
let mut cursor: u64 = 0;
loop {
let mut conn = self.manager.clone();
let scan_result: redis::RedisResult<(u64, Vec<String>)> = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(100usize)
.query_async(&mut conn)
.await;
let (next_cursor, keys): (u64, Vec<String>) = scan_result
.map_err(|e| CacheError::Internal(format!("Redis SCAN failed: {}", e)))?;
if !keys.is_empty() {
let mut pipe = redis::pipe();
for k in &keys {
pipe.del(k);
}
let del_result: redis::RedisResult<()> = pipe.query_async(&mut conn).await;
del_result.map_err(|e| {
CacheError::Internal(format!("Redis DEL pipeline failed: {}", e))
})?;
}
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
Ok(())
}
}
#[cfg(feature = "redis")]
impl L2CacheBackend for RedisBackend {
fn get<'a>(&'a self, key: &'a str) -> L2CacheFuture<'a, Option<Vec<u8>>> {
Box::pin(async move {
use redis::AsyncCommands;
let mut conn = self.manager.clone();
let value: Option<Vec<u8>> = conn
.get(key)
.await
.map_err(|e| CacheError::Internal(format!("Redis GET failed: {}", e)))?;
Ok(value)
})
}
fn set<'a>(
&'a self,
key: &'a str,
value: &'a [u8],
ttl: Option<Duration>,
) -> L2CacheFuture<'a, ()> {
Box::pin(async move {
use redis::AsyncCommands;
let mut conn = self.manager.clone();
match ttl {
Some(d) => {
let secs = d.as_secs();
if secs > 0 {
let _: () = conn.set_ex(key, value, secs).await.map_err(|e| {
CacheError::Internal(format!("Redis SET EX failed: {}", e))
})?;
} else {
let _: () = conn.set(key, value).await.map_err(|e| {
CacheError::Internal(format!("Redis SET failed: {}", e))
})?;
let ms: i64 = d.as_millis().min(i64::MAX as u128) as i64;
let _: () = conn.pexpire(key, ms).await.map_err(|e| {
CacheError::Internal(format!("Redis PEXPIRE failed: {}", e))
})?;
}
}
None => {
let _: () = conn
.set(key, value)
.await
.map_err(|e| CacheError::Internal(format!("Redis SET failed: {}", e)))?;
}
}
Ok(())
})
}
fn delete<'a>(&'a self, key: &'a str) -> L2CacheFuture<'a, ()> {
Box::pin(async move {
use redis::AsyncCommands;
let mut conn = self.manager.clone();
let _: () = conn
.del(key)
.await
.map_err(|e| CacheError::Internal(format!("Redis DEL failed: {}", e)))?;
Ok(())
})
}
fn invalidate_prefix<'a>(&'a self, prefix: &'a str) -> L2CacheFuture<'a, ()> {
Box::pin(async move { self.invalidate_prefix_inner(prefix).await })
}
}
#[cfg(not(feature = "redis"))]
pub struct RedisBackend {
url: String,
}
#[cfg(not(feature = "redis"))]
impl RedisBackend {
pub fn new(_url: impl Into<String>) -> Self {
Self { url: _url.into() }
}
}
#[cfg(not(feature = "redis"))]
impl L2CacheBackend for RedisBackend {
fn get<'a>(&'a self, _key: &'a str) -> L2CacheFuture<'a, Option<Vec<u8>>> {
let url = self.url.clone();
Box::pin(async move {
Err(CacheError::Internal(format!(
"RedisBackend not compiled: enable 'redis' feature in sz-orm-core. URL: {}",
url
)))
})
}
fn set<'a>(
&'a self,
_key: &'a str,
_value: &'a [u8],
_ttl: Option<Duration>,
) -> L2CacheFuture<'a, ()> {
let url = self.url.clone();
Box::pin(async move {
Err(CacheError::Internal(format!(
"RedisBackend not compiled: enable 'redis' feature in sz-orm-core. URL: {}",
url
)))
})
}
fn delete<'a>(&'a self, _key: &'a str) -> L2CacheFuture<'a, ()> {
let url = self.url.clone();
Box::pin(async move {
Err(CacheError::Internal(format!(
"RedisBackend not compiled: enable 'redis' feature in sz-orm-core. URL: {}",
url
)))
})
}
fn invalidate_prefix<'a>(&'a self, _prefix: &'a str) -> L2CacheFuture<'a, ()> {
let url = self.url.clone();
Box::pin(async move {
Err(CacheError::Internal(format!(
"RedisBackend not compiled: enable 'redis' feature in sz-orm-core. URL: {}",
url
)))
})
}
}
#[derive(Debug, Clone)]
pub enum WriteOp {
Set {
key: String,
value: Vec<u8>,
ttl: Option<Duration>,
},
Delete {
key: String,
},
}
pub type FlushCallback = Arc<
dyn Fn(Vec<WriteOp>) -> Pin<Box<dyn Future<Output = Result<(), CacheError>> + Send>>
+ Send
+ Sync,
>;
pub type ErrorCallback = Arc<dyn Fn(Vec<WriteOp>, CacheError) + Send + Sync>;
pub struct WriteBehindWriter {
backend: Arc<dyn L2CacheBackend>,
queue: tokio::sync::Mutex<Vec<WriteOp>>,
on_flush: FlushCallback,
on_error: Option<ErrorCallback>,
}
impl WriteBehindWriter {
pub fn new(backend: Arc<dyn L2CacheBackend>, on_flush: FlushCallback) -> Self {
Self {
backend,
queue: tokio::sync::Mutex::new(Vec::new()),
on_flush,
on_error: None,
}
}
pub fn with_error_callback(mut self, on_error: ErrorCallback) -> Self {
self.on_error = Some(on_error);
self
}
pub async fn write(
&self,
key: &[u8],
value: &[u8],
ttl: Option<Duration>,
) -> Result<(), CacheError> {
let key_str = String::from_utf8_lossy(key).into_owned();
self.backend.set(&key_str, value, ttl).await?;
let op = WriteOp::Set {
key: key_str,
value: value.to_vec(),
ttl,
};
self.queue.lock().await.push(op);
Ok(())
}
pub async fn delete(&self, key: &[u8]) -> Result<(), CacheError> {
let key_str = String::from_utf8_lossy(key).into_owned();
self.backend.delete(&key_str).await?;
let op = WriteOp::Delete { key: key_str };
self.queue.lock().await.push(op);
Ok(())
}
pub async fn flush(&self) -> Result<(), CacheError> {
let ops: Vec<WriteOp> = {
let mut guard = self.queue.lock().await;
std::mem::take(&mut *guard)
};
if ops.is_empty() {
return Ok(());
}
match (self.on_flush)(ops.clone()).await {
Ok(()) => Ok(()),
Err(e) => {
let mut guard = self.queue.lock().await;
guard.extend(ops.clone());
if let Some(ref on_error) = self.on_error {
on_error(ops, e.clone());
}
Err(e)
}
}
}
pub async fn pending_count(&self) -> usize {
self.queue.lock().await.len()
}
pub fn spawn_auto_flush(self: Arc<Self>, interval: Duration) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = self.flush().await {
eprintln!("[WriteBehind] auto flush failed: {}", e);
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Value;
use std::thread;
use std::time::Duration;
#[test]
fn test_cache_key_by_pk() {
let key = CacheKey::by_pk("users", 1);
assert_eq!(key.table, "users");
assert_eq!(key.kind, CacheKeyKind::ByPk);
assert_eq!(key.identifier, "1");
assert_eq!(key.to_string_key(), "l2:users:pk:1");
}
#[test]
fn test_cache_key_by_query() {
let key = CacheKey::by_query("orders", "abc123");
assert_eq!(key.kind, CacheKeyKind::ByQuery);
assert_eq!(key.to_string_key(), "l2:orders:q:abc123");
}
#[test]
fn test_cache_key_by_relation() {
let key = CacheKey::by_relation("users", "posts:1");
assert_eq!(key.kind, CacheKeyKind::ByRelation);
assert_eq!(key.to_string_key(), "l2:users:rel:posts:1");
}
#[test]
fn test_cache_key_equality() {
let k1 = CacheKey::by_pk("users", 1);
let k2 = CacheKey::by_pk("users", 1);
let k3 = CacheKey::by_pk("users", 2);
assert_eq!(k1, k2);
assert_ne!(k1, k3);
}
#[test]
fn test_cache_key_display() {
let key = CacheKey::by_pk("users", 42);
assert_eq!(format!("{}", key), "l2:users:pk:42");
}
#[test]
fn test_stats_hit_rate_empty() {
let stats = L2CacheStats::default();
assert_eq!(stats.hit_rate(), 0.0);
assert_eq!(stats.total_lookups(), 0);
}
#[test]
fn test_stats_hit_rate_calculation() {
let stats = L2CacheStats {
hits: 80,
misses: 20,
..Default::default()
};
assert_eq!(stats.total_lookups(), 100);
assert!((stats.hit_rate() - 0.8).abs() < 0.001);
assert!((stats.miss_rate() - 0.2).abs() < 0.001);
}
#[test]
fn test_stats_merge() {
let mut s1 = L2CacheStats {
hits: 10,
misses: 5,
sets: 15,
evictions: 2,
size: 100,
};
let s2 = L2CacheStats {
hits: 20,
misses: 10,
sets: 30,
evictions: 5,
size: 200,
};
s1.merge(&s2);
assert_eq!(s1.hits, 30);
assert_eq!(s1.misses, 15);
assert_eq!(s1.sets, 45);
assert_eq!(s1.evictions, 7);
assert_eq!(s1.size, 300);
}
#[test]
fn test_put_and_get() {
let cache = L2Cache::new();
let key = CacheKey::by_pk("users", 1);
cache.put(&key, Value::String("Alice".to_string()), None);
let val = cache.get(&key);
assert_eq!(val, Some(Value::String("Alice".to_string())));
}
#[test]
fn test_get_missing_returns_none() {
let cache = L2Cache::new();
let key = CacheKey::by_pk("users", 999);
assert_eq!(cache.get(&key), None);
}
#[test]
fn test_overwrite_existing_key() {
let cache = L2Cache::new();
let key = CacheKey::by_pk("users", 1);
cache.put(&key, Value::String("Alice".to_string()), None);
cache.put(&key, Value::String("Bob".to_string()), None);
assert_eq!(cache.get(&key), Some(Value::String("Bob".to_string())));
}
#[test]
fn test_invalidate_single_key() {
let cache = L2Cache::new();
let key = CacheKey::by_pk("users", 1);
cache.put(&key, Value::I64(42), None);
assert!(cache.get(&key).is_some());
cache.invalidate(&key);
assert!(cache.get(&key).is_none());
}
#[test]
fn test_invalidate_table_removes_all_entries_for_table() {
let cache = L2Cache::new();
let k1 = CacheKey::by_pk("users", 1);
let k2 = CacheKey::by_pk("users", 2);
let k3 = CacheKey::by_query("users", "hash1");
let k4 = CacheKey::by_pk("orders", 1);
cache.put(&k1, Value::I64(1), None);
cache.put(&k2, Value::I64(2), None);
cache.put(&k3, Value::I64(3), None);
cache.put(&k4, Value::I64(4), None);
cache.invalidate_table("users");
assert!(cache.get(&k1).is_none());
assert!(cache.get(&k2).is_none());
assert!(cache.get(&k3).is_none());
assert!(cache.get(&k4).is_some());
}
#[test]
fn test_invalidate_table_no_op_for_unknown_table() {
let cache = L2Cache::new();
let k1 = CacheKey::by_pk("users", 1);
cache.put(&k1, Value::I64(1), None);
cache.invalidate_table("nonexistent");
assert!(cache.get(&k1).is_some());
}
#[test]
fn test_ttl_expiration() {
let cache = L2Cache::new();
let key = CacheKey::by_pk("users", 1);
cache.put(&key, Value::I64(42), Some(Duration::from_millis(50)));
assert!(cache.get(&key).is_some());
thread::sleep(Duration::from_millis(100));
assert!(cache.get(&key).is_none());
}
#[test]
fn test_default_ttl_applied_when_no_explicit_ttl() {
let cache = L2Cache::new().with_default_ttl(Duration::from_millis(50));
let key = CacheKey::by_pk("users", 1);
cache.put(&key, Value::I64(42), None); assert!(cache.get(&key).is_some());
thread::sleep(Duration::from_millis(100));
assert!(cache.get(&key).is_none());
}
#[test]
fn test_explicit_ttl_overrides_default() {
let cache = L2Cache::new().with_default_ttl(Duration::from_millis(50));
let key = CacheKey::by_pk("users", 1);
cache.put(&key, Value::I64(42), Some(Duration::MAX));
thread::sleep(Duration::from_millis(100));
assert!(cache.get(&key).is_some());
}
#[test]
fn test_none_ttl_uses_default_ttl() {
let cache = L2Cache::new().with_default_ttl(Duration::from_millis(50));
let key = CacheKey::by_pk("users", 1);
cache.put(&key, Value::I64(42), None);
assert!(cache.get(&key).is_some());
thread::sleep(Duration::from_millis(100));
assert!(cache.get(&key).is_none());
}
#[test]
fn test_stats_tracks_hits_and_misses() {
let cache = L2Cache::new();
let k1 = CacheKey::by_pk("users", 1);
let k2 = CacheKey::by_pk("users", 2);
cache.put(&k1, Value::I64(1), None);
cache.get(&k1);
cache.get(&k2);
cache.get(&k2);
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 2);
assert_eq!(stats.sets, 1);
}
#[test]
fn test_stats_tracks_evictions() {
let cache = L2Cache::new();
let k1 = CacheKey::by_pk("users", 1);
let k2 = CacheKey::by_pk("users", 2);
cache.put(&k1, Value::I64(1), None);
cache.put(&k2, Value::I64(2), None);
cache.invalidate(&k1); cache.invalidate_table("users");
let stats = cache.stats();
assert_eq!(stats.evictions, 2);
}
#[test]
fn test_stats_reset() {
let cache = L2Cache::new();
let k1 = CacheKey::by_pk("users", 1);
cache.put(&k1, Value::I64(1), None);
cache.get(&k1);
cache.get(&k1);
let stats_before = cache.stats();
assert!(stats_before.hits > 0);
cache.reset_stats();
let stats_after = cache.stats();
assert_eq!(stats_after.hits, 0);
assert_eq!(stats_after.misses, 0);
assert_eq!(stats_after.sets, 0);
}
#[test]
fn test_max_size_eviction() {
let cache = L2Cache::new().with_max_size(3);
for i in 0..5 {
let k = CacheKey::by_pk("users", i);
cache.put(&k, Value::I64(i), None);
}
let size = cache.size();
assert_eq!(
size, 3,
"size should be exactly max_size after LRU eviction, got {}",
size
);
}
#[test]
fn test_lru_eviction_order() {
let cache = L2Cache::new().with_max_size(3);
let k0 = CacheKey::by_pk("users", 0);
let k1 = CacheKey::by_pk("users", 1);
let k2 = CacheKey::by_pk("users", 2);
let k3 = CacheKey::by_pk("users", 3);
cache.put(&k0, Value::I64(0), None);
cache.put(&k1, Value::I64(1), None);
cache.put(&k2, Value::I64(2), None);
let _ = cache.get(&k0);
cache.put(&k3, Value::I64(3), None);
assert!(
cache.get(&k0).is_some(),
"k0 should survive (recently accessed)"
);
assert!(
cache.get(&k1).is_none(),
"k1 should be evicted (LRU victim)"
);
assert!(cache.get(&k2).is_some(), "k2 should survive");
assert!(
cache.get(&k3).is_some(),
"k3 should survive (just inserted)"
);
}
#[test]
fn test_clear_all() {
let cache = L2Cache::new();
cache.put(&CacheKey::by_pk("users", 1), Value::I64(1), None);
cache.put(&CacheKey::by_pk("users", 2), Value::I64(2), None);
cache.put(&CacheKey::by_pk("orders", 1), Value::I64(3), None);
assert_eq!(cache.size(), 3);
cache.clear();
assert_eq!(cache.size(), 0);
}
#[test]
fn test_contains_does_not_update_stats() {
let cache = L2Cache::new();
let k1 = CacheKey::by_pk("users", 1);
cache.put(&k1, Value::I64(1), None);
let exists = cache.contains(&k1);
assert!(exists);
let stats = cache.stats();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
}
#[test]
fn test_contains_returns_false_for_missing() {
let cache = L2Cache::new();
let k = CacheKey::by_pk("users", 999);
assert!(!cache.contains(&k));
}
#[test]
fn test_contains_returns_false_for_expired() {
let cache = L2Cache::new();
let k = CacheKey::by_pk("users", 1);
cache.put(&k, Value::I64(1), Some(Duration::from_millis(10)));
thread::sleep(Duration::from_millis(50));
assert!(!cache.contains(&k));
}
#[test]
fn test_evict_expired_removes_only_expired_entries() {
let cache = L2Cache::new();
let k1 = CacheKey::by_pk("users", 1);
let k2 = CacheKey::by_pk("users", 2);
cache.put(&k1, Value::I64(1), Some(Duration::from_millis(10)));
cache.put(&k2, Value::I64(2), None);
thread::sleep(Duration::from_millis(50));
let removed = cache.evict_expired();
assert_eq!(removed, 1);
assert!(cache.get(&k1).is_none());
assert!(cache.get(&k2).is_some());
}
#[test]
fn test_evict_expired_returns_zero_if_no_expired() {
let cache = L2Cache::new();
let k1 = CacheKey::by_pk("users", 1);
cache.put(&k1, Value::I64(1), None);
let removed = cache.evict_expired();
assert_eq!(removed, 0);
}
#[test]
fn test_concurrent_access() {
let cache = std::sync::Arc::new(L2Cache::new());
let mut handles = Vec::new();
for i in 0..4 {
let c = cache.clone();
handles.push(thread::spawn(move || {
for j in 0..10 {
let k = CacheKey::by_pk("users", i * 10 + j);
c.put(&k, Value::I64(i * 10 + j), None);
}
}));
}
for h in handles {
h.join().unwrap();
}
assert_eq!(cache.size(), 40);
let mut handles = Vec::new();
for i in 0..4 {
let c = cache.clone();
handles.push(thread::spawn(move || {
for j in 0..10 {
let k = CacheKey::by_pk("users", i * 10 + j);
let v = c.get(&k);
assert!(v.is_some());
}
}));
}
for h in handles {
h.join().unwrap();
}
let stats = cache.stats();
assert_eq!(stats.hits, 40);
}
#[test]
fn test_default() {
let cache = L2Cache::default();
assert_eq!(cache.size(), 0);
}
#[test]
fn test_realistic_scenario() {
let cache = L2Cache::new();
for i in 1..=5 {
cache.put(
&CacheKey::by_pk("users", i),
Value::String(format!("user_{}", i)),
None,
);
}
cache.put(
&CacheKey::by_query("users", "active_users_hash"),
Value::I64(5),
None,
);
for i in 1..=10 {
let _ = cache.get(&CacheKey::by_pk("users", i));
}
let stats = cache.stats();
assert_eq!(stats.hits, 5); assert_eq!(stats.misses, 5); assert_eq!(stats.sets, 6);
cache.invalidate_table("users");
cache.reset_stats();
for i in 1..=5 {
let _ = cache.get(&CacheKey::by_pk("users", i));
}
let stats2 = cache.stats();
assert_eq!(stats2.hits, 0);
assert_eq!(stats2.misses, 5);
}
#[tokio::test]
async fn test_write_behind_basic_write_and_flush() {
use std::sync::atomic::{AtomicUsize, Ordering};
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = counter.clone();
let on_flush: FlushCallback = Arc::new(move |ops: Vec<WriteOp>| {
let c = counter_clone.clone();
Box::pin(async move {
c.fetch_add(ops.len(), Ordering::SeqCst);
Ok(())
})
});
let backend = Arc::new(InMemoryBackend::new());
let writer = WriteBehindWriter::new(backend.clone(), on_flush);
writer.write(b"k1", b"v1", None).await.unwrap();
writer.write(b"k2", b"v2", None).await.unwrap();
writer.write(b"k3", b"v3", None).await.unwrap();
let v1 = backend.get("k1").await.unwrap();
assert_eq!(v1, Some(b"v1".to_vec()));
assert_eq!(writer.pending_count().await, 3);
writer.flush().await.unwrap();
assert_eq!(counter.load(Ordering::SeqCst), 3);
assert_eq!(writer.pending_count().await, 0);
}
#[tokio::test]
async fn test_write_behind_delete() {
let on_flush: FlushCallback =
Arc::new(|_ops: Vec<WriteOp>| Box::pin(async move { Ok(()) }));
let backend = Arc::new(InMemoryBackend::new());
let writer = WriteBehindWriter::new(backend.clone(), on_flush);
writer.write(b"k1", b"v1", None).await.unwrap();
assert!(backend.get("k1").await.unwrap().is_some());
writer.delete(b"k1").await.unwrap();
assert!(backend.get("k1").await.unwrap().is_none());
writer.flush().await.unwrap();
assert_eq!(writer.pending_count().await, 0);
}
#[tokio::test]
async fn test_write_behind_flush_failure_retries() {
let on_flush: FlushCallback = Arc::new(|_ops: Vec<WriteOp>| {
Box::pin(async move { Err(CacheError::Internal("backend down".to_string())) })
});
let backend = Arc::new(InMemoryBackend::new());
let writer = WriteBehindWriter::new(backend.clone(), on_flush);
writer.write(b"k1", b"v1", None).await.unwrap();
let result = writer.flush().await;
assert!(result.is_err());
assert_eq!(writer.pending_count().await, 1);
}
#[tokio::test]
async fn test_write_behind_empty_flush_noop() {
let on_flush: FlushCallback =
Arc::new(|_ops: Vec<WriteOp>| Box::pin(async move { Ok(()) }));
let backend = Arc::new(InMemoryBackend::new());
let writer = WriteBehindWriter::new(backend, on_flush);
writer.flush().await.unwrap();
assert_eq!(writer.pending_count().await, 0);
}
#[tokio::test]
async fn test_write_behind_error_callback_invoked() {
use std::sync::atomic::{AtomicUsize, Ordering};
let error_counter = Arc::new(AtomicUsize::new(0));
let ec = error_counter.clone();
let on_error: ErrorCallback = Arc::new(move |_ops, _err| {
ec.fetch_add(1, Ordering::SeqCst);
});
let on_flush: FlushCallback = Arc::new(|_ops: Vec<WriteOp>| {
Box::pin(async move { Err(CacheError::Internal("fail".to_string())) })
});
let backend = Arc::new(InMemoryBackend::new());
let writer = WriteBehindWriter::new(backend, on_flush).with_error_callback(on_error);
writer.write(b"k1", b"v1", None).await.unwrap();
let _ = writer.flush().await;
assert_eq!(error_counter.load(Ordering::SeqCst), 1);
}
}