use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::time::{Duration, Instant};
pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
pub const DEFAULT_MAX_BYTES: u64 = 64 * 1024 * 1024;
pub const DEFAULT_BATCH_MAX_DIRTY: usize = 128;
pub const DEFAULT_BATCH_MAX_AGE: Duration = Duration::from_secs(5);
pub const DEFAULT_SEMANTIC_THRESHOLD: f32 = 0.97;
pub const KEY_PREVIEW_MAX_CHARS: usize = 120;
#[derive(Clone, Debug)]
pub struct CachePutOpts {
pub content_type: String,
pub ttl_secs: Option<u64>,
pub cost_hint_ms: Option<u32>,
}
impl Default for CachePutOpts {
fn default() -> Self {
Self {
content_type: "text".into(),
ttl_secs: None,
cost_hint_ms: None,
}
}
}
#[derive(Clone, Debug)]
pub struct SemanticCachePutOpts {
pub content_type: String,
pub ttl_secs: Option<u64>,
pub cost_hint_ms: Option<u32>,
pub model: String,
}
impl SemanticCachePutOpts {
pub fn new(model: impl Into<String>) -> Self {
Self {
content_type: "text".into(),
ttl_secs: None,
cost_hint_ms: None,
model: model.into(),
}
}
}
#[derive(Clone, Debug)]
pub struct CachedValue {
pub value: Vec<u8>,
pub content_type: String,
pub created_at: i64,
pub accessed_at: i64,
pub access_count: u32,
pub ttl_secs: Option<u64>,
}
#[derive(Clone, Copy, Debug)]
pub struct CacheBudget {
pub max_entries: usize,
pub max_bytes: u64,
}
impl Default for CacheBudget {
fn default() -> Self {
Self {
max_entries: DEFAULT_MAX_ENTRIES,
max_bytes: DEFAULT_MAX_BYTES,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub enum CacheFlushPolicy {
#[default]
Strict,
Batched {
max_dirty: usize,
max_age: Duration,
},
}
#[derive(Clone, Copy, Debug, Default)]
pub struct CacheStats {
pub entries: usize,
pub bytes: u64,
pub hits: u64,
pub misses: u64,
pub hit_rate: f64,
pub evictions: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct CacheEntry {
pub namespace: String,
pub key_hash: [u8; 32],
pub key_preview: String,
pub value: Vec<u8>,
pub content_type: String,
pub created_at: i64,
pub accessed_at: i64,
pub access_count: u32,
pub ttl_secs: Option<u64>,
pub cost_hint_ms: Option<u32>,
pub size_bytes: u64,
#[serde(default)]
pub vector: Option<Vec<f32>>,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct CacheDirectoryEntry {
pub namespace: String,
pub key_hash: [u8; 32],
pub start_page: u64,
pub page_count: u32,
pub len: u32,
pub deleted: bool,
pub accessed_at: i64,
pub access_count: u32,
pub size_bytes: u64,
pub ttl_secs: Option<u64>,
pub created_at: i64,
#[serde(default)]
pub vector: Option<Vec<f32>>,
#[serde(default)]
pub model: Option<String>,
}
impl CacheDirectoryEntry {
pub(crate) fn is_expired(&self, now: i64) -> bool {
match self.ttl_secs {
Some(ttl) => now.saturating_sub(self.created_at) as u64 >= ttl,
None => false,
}
}
}
pub fn hash_key(key: &str) -> [u8; 32] {
let mut h = Sha256::new();
h.update(key.as_bytes());
h.finalize().into()
}
pub fn key_preview(key: &str) -> String {
if key.chars().count() <= KEY_PREVIEW_MAX_CHARS {
return key.to_string();
}
let mut out: String = key.chars().take(KEY_PREVIEW_MAX_CHARS - 1).collect();
out.push('…');
out
}
#[derive(Debug, Default)]
pub(crate) struct DirectoryIndex {
by_ns: HashMap<String, HashMap<[u8; 32], usize>>,
}
impl DirectoryIndex {
pub(crate) fn new() -> Self {
Self {
by_ns: HashMap::new(),
}
}
pub(crate) fn build(entries: &[CacheDirectoryEntry]) -> Self {
let mut idx = Self::new();
for (i, e) in entries.iter().enumerate() {
if !e.deleted {
idx.insert(&e.namespace, e.key_hash, i);
}
}
idx
}
pub(crate) fn insert(&mut self, namespace: &str, key_hash: [u8; 32], pos: usize) {
self.by_ns
.entry(namespace.to_string())
.or_default()
.insert(key_hash, pos);
}
pub(crate) fn remove(&mut self, namespace: &str, key_hash: &[u8; 32]) {
if let Some(ns) = self.by_ns.get_mut(namespace) {
ns.remove(key_hash);
}
}
pub(crate) fn get(&self, namespace: &str, key_hash: &[u8; 32]) -> Option<usize> {
self.by_ns.get(namespace)?.get(key_hash).copied()
}
}
#[derive(Debug, Default)]
pub(crate) struct CounterState {
pub hits: u64,
pub misses: u64,
pub evictions: u64,
}
#[derive(Debug, Default)]
pub(crate) struct BatchState {
pub dirty_count: usize,
pub first_dirty_at: Option<Instant>,
}
impl BatchState {
pub(crate) fn record_dirty(&mut self) {
self.dirty_count += 1;
if self.first_dirty_at.is_none() {
self.first_dirty_at = Some(Instant::now());
}
}
pub(crate) fn reset(&mut self) {
self.dirty_count = 0;
self.first_dirty_at = None;
}
pub(crate) fn should_auto_flush(&self, policy: &CacheFlushPolicy) -> bool {
match policy {
CacheFlushPolicy::Strict => false,
CacheFlushPolicy::Batched { max_dirty, max_age } => {
if self.dirty_count >= *max_dirty {
return true;
}
if let Some(t0) = self.first_dirty_at {
if t0.elapsed() >= *max_age {
return true;
}
}
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_key_is_deterministic_and_32_bytes() {
let a = hash_key("hello");
let b = hash_key("hello");
assert_eq!(a, b);
assert_eq!(a.len(), 32);
assert_ne!(a, hash_key("hello!"));
}
#[test]
fn key_preview_truncates_with_ellipsis() {
let short = "hi there";
assert_eq!(key_preview(short), short);
let long = "x".repeat(200);
let out = key_preview(&long);
assert_eq!(out.chars().count(), KEY_PREVIEW_MAX_CHARS);
assert!(out.ends_with('…'));
}
#[test]
fn directory_index_locates_live_entries() {
let entries = vec![
CacheDirectoryEntry {
namespace: "llm".into(),
key_hash: [1u8; 32],
start_page: 100,
page_count: 1,
len: 42,
deleted: false,
accessed_at: 0,
access_count: 0,
size_bytes: 42,
ttl_secs: None,
created_at: 0,
vector: None,
model: None,
},
CacheDirectoryEntry {
namespace: "http".into(),
key_hash: [2u8; 32],
start_page: 101,
page_count: 1,
len: 42,
deleted: true, accessed_at: 0,
access_count: 0,
size_bytes: 42,
ttl_secs: None,
created_at: 0,
vector: None,
model: None,
},
];
let idx = DirectoryIndex::build(&entries);
assert_eq!(idx.get("llm", &[1u8; 32]), Some(0));
assert_eq!(idx.get("http", &[2u8; 32]), None, "tombstones not indexed");
}
#[test]
fn batch_state_triggers_on_count() {
let mut b = BatchState::default();
let p = CacheFlushPolicy::Batched {
max_dirty: 3,
max_age: Duration::from_secs(3600),
};
b.record_dirty();
b.record_dirty();
assert!(!b.should_auto_flush(&p));
b.record_dirty();
assert!(b.should_auto_flush(&p));
}
#[test]
fn ttl_expiry_matches_created_at() {
let now = 1_000_000_i64;
let fresh = CacheDirectoryEntry {
namespace: "".into(),
key_hash: [0u8; 32],
start_page: 0,
page_count: 0,
len: 0,
deleted: false,
accessed_at: 0,
access_count: 0,
size_bytes: 0,
ttl_secs: Some(60),
created_at: now - 30,
vector: None,
model: None,
};
assert!(!fresh.is_expired(now), "30s old with 60s TTL is fresh");
let stale = CacheDirectoryEntry {
created_at: now - 120,
..fresh.clone()
};
assert!(stale.is_expired(now), "120s old with 60s TTL is expired");
let no_ttl = CacheDirectoryEntry {
ttl_secs: None,
..fresh
};
assert!(!no_ttl.is_expired(now + 1_000_000_000));
}
}