use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use super::RoleClient;
use crate::{
model::CacheScope,
service::{Peer, ServiceRole},
};
pub const MAX_CLIENT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ClientCacheConfig {
pub enabled: bool,
pub default_ttl: Duration,
pub max_ttl: Duration,
pub private_partition: Option<String>,
pub max_entries: usize,
pub serve_stale_on_error: bool,
}
impl Default for ClientCacheConfig {
fn default() -> Self {
Self {
enabled: true,
default_ttl: Duration::ZERO,
max_ttl: MAX_CLIENT_CACHE_TTL,
private_partition: None,
max_entries: 512,
serve_stale_on_error: true,
}
}
}
impl ClientCacheConfig {
pub fn disabled() -> Self {
Self {
enabled: false,
..Self::default()
}
}
pub fn with_default_ttl(mut self, default_ttl: Duration) -> Self {
self.default_ttl = default_ttl;
self
}
pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
self.max_ttl = max_ttl;
self
}
pub fn with_private_partition(mut self, partition: impl Into<String>) -> Self {
self.private_partition = Some(partition.into());
self
}
pub fn with_max_entries(mut self, max_entries: usize) -> Self {
self.max_entries = max_entries;
self
}
pub fn with_serve_stale_on_error(mut self, serve_stale_on_error: bool) -> Self {
self.serve_stale_on_error = serve_stale_on_error;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum CachePartition {
Public,
Private(Arc<str>),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CacheKey {
logical_key: String,
partition: CachePartition,
}
#[derive(Debug, Clone)]
struct CachedPeerResponse<T> {
value: T,
expires_at: Instant,
inserted_at: Instant,
scope: CacheScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CacheGeneration(u64);
#[derive(Debug)]
pub(crate) struct PeerResponseCacheState<R: ServiceRole> {
entries: HashMap<CacheKey, CachedPeerResponse<R::PeerResp>>,
config: ClientCacheConfig,
generation: u64,
}
impl<R: ServiceRole> Default for PeerResponseCacheState<R> {
fn default() -> Self {
Self {
entries: HashMap::new(),
config: ClientCacheConfig::default(),
generation: 0,
}
}
}
impl<R: ServiceRole> PeerResponseCacheState<R> {
fn trim_to_limit(&mut self) {
while self.config.max_entries > 0 && self.entries.len() > self.config.max_entries {
let Some(oldest_key) = self
.entries
.iter()
.min_by_key(|(_, entry)| entry.inserted_at)
.map(|(key, _)| key.clone())
else {
break;
};
self.entries.remove(&oldest_key);
}
}
}
pub(crate) type PeerResponseCache<R> = Arc<tokio::sync::RwLock<PeerResponseCacheState<R>>>;
impl<R: ServiceRole> Peer<R> {
fn private_partition(config: &ClientCacheConfig) -> Arc<str> {
Arc::from(config.private_partition.as_deref().unwrap_or("connection"))
}
fn cache_key(logical_key: &str, partition: CachePartition) -> CacheKey {
CacheKey {
logical_key: logical_key.to_owned(),
partition,
}
}
fn scoped_cache_key(
logical_key: &str,
scope: CacheScope,
config: &ClientCacheConfig,
) -> CacheKey {
let partition = match scope {
CacheScope::Public => CachePartition::Public,
CacheScope::Private => CachePartition::Private(Self::private_partition(config)),
};
Self::cache_key(logical_key, partition)
}
pub(crate) async fn capture_response_cache_generation(&self) -> CacheGeneration {
CacheGeneration(self.response_cache.read().await.generation)
}
pub(crate) async fn cached_response(&self, logical_key: &str) -> Option<R::PeerResp> {
let now = Instant::now();
let mut cache = self.response_cache.write().await;
if !cache.config.enabled {
return None;
}
let keep_stale = cache.config.serve_stale_on_error;
let private_key = Self::cache_key(
logical_key,
CachePartition::Private(Self::private_partition(&cache.config)),
);
let private_fresh = cache.entries.get(&private_key).and_then(|entry| {
(entry.expires_at > now && entry.scope == CacheScope::Private)
.then(|| entry.value.clone())
});
if let Some(value) = private_fresh {
return Some(value);
}
if !keep_stale {
cache.entries.remove(&private_key);
}
let public_key = Self::cache_key(logical_key, CachePartition::Public);
let public_fresh = cache.entries.get(&public_key).and_then(|entry| {
(entry.expires_at > now && entry.scope == CacheScope::Public)
.then(|| entry.value.clone())
});
if let Some(value) = public_fresh {
return Some(value);
}
if !keep_stale {
cache.entries.remove(&public_key);
}
None
}
pub(crate) async fn stale_cached_response(&self, logical_key: &str) -> Option<R::PeerResp> {
let cache = self.response_cache.read().await;
if !cache.config.enabled || !cache.config.serve_stale_on_error {
return None;
}
let private_key = Self::cache_key(
logical_key,
CachePartition::Private(Self::private_partition(&cache.config)),
);
if let Some(entry) = cache.entries.get(&private_key)
&& entry.scope == CacheScope::Private
{
return Some(entry.value.clone());
}
let public_key = Self::cache_key(logical_key, CachePartition::Public);
if let Some(entry) = cache.entries.get(&public_key)
&& entry.scope == CacheScope::Public
{
return Some(entry.value.clone());
}
None
}
pub(crate) async fn cache_response_with_generation(
&self,
logical_key: String,
value: R::PeerResp,
ttl_ms: Option<u64>,
cache_scope: Option<CacheScope>,
generation: CacheGeneration,
) {
let now = Instant::now();
let mut cache = self.response_cache.write().await;
if !cache.config.enabled || generation.0 != cache.generation {
return;
}
let requested_ttl = ttl_ms
.map(Duration::from_millis)
.unwrap_or(cache.config.default_ttl);
let ttl = requested_ttl.min(cache.config.max_ttl);
if ttl.is_zero() {
return;
}
let Some(expires_at) = now.checked_add(ttl) else {
return;
};
let scope = cache_scope.unwrap_or(CacheScope::Private);
let target_key = Self::scoped_cache_key(&logical_key, scope, &cache.config);
let opposite_key = match scope {
CacheScope::Public => Self::cache_key(
&logical_key,
CachePartition::Private(Self::private_partition(&cache.config)),
),
CacheScope::Private => Self::cache_key(&logical_key, CachePartition::Public),
};
if !cache.config.serve_stale_on_error {
cache.entries.retain(|_, entry| entry.expires_at > now);
}
cache.entries.remove(&opposite_key);
if cache.config.max_entries > 0
&& !cache.entries.contains_key(&target_key)
&& cache.entries.len() >= cache.config.max_entries
&& let Some(oldest_key) = cache
.entries
.iter()
.min_by_key(|(_, entry)| entry.inserted_at)
.map(|(key, _)| key.clone())
{
cache.entries.remove(&oldest_key);
}
cache.entries.insert(
target_key,
CachedPeerResponse {
value,
expires_at,
inserted_at: now,
scope,
},
);
}
#[cfg(test)]
pub(crate) async fn cache_response(
&self,
logical_key: String,
value: R::PeerResp,
ttl_ms: Option<u64>,
cache_scope: Option<CacheScope>,
) {
let generation = self.capture_response_cache_generation().await;
self.cache_response_with_generation(logical_key, value, ttl_ms, cache_scope, generation)
.await;
}
pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) {
let mut cache = self.response_cache.write().await;
cache.generation = cache.generation.wrapping_add(1);
cache
.entries
.retain(|key, _| !key.logical_key.starts_with(prefix));
}
}
impl Peer<RoleClient> {
pub async fn set_response_cache_config(&self, config: ClientCacheConfig) {
let mut cache = self.response_cache.write().await;
let config_changed = cache.config != config;
let partition_changed = cache.config.private_partition != config.private_partition;
let ttl_policy_changed = cache.config.default_ttl != config.default_ttl
|| cache.config.max_ttl != config.max_ttl;
cache.config = config;
if config_changed {
cache.generation = cache.generation.wrapping_add(1);
}
if !cache.config.enabled || ttl_policy_changed {
cache.entries.clear();
} else if partition_changed {
cache
.entries
.retain(|_, entry| entry.scope == CacheScope::Public);
}
cache.trim_to_limit();
}
pub async fn response_cache_config(&self) -> ClientCacheConfig {
self.response_cache.read().await.config.clone()
}
pub async fn clear_response_cache(&self) {
let mut cache = self.response_cache.write().await;
cache.generation = cache.generation.wrapping_add(1);
cache.entries.clear();
}
}