use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
#[cfg(feature = "telemetry")]
use crate::i18n::messages::{MSG_LOG_STALE_HIT_SERVED, t};
use crate::backend::{
BackendKind, CacheBackend, CacheConnector, CacheReader, CacheSetItem, CacheWriter,
};
use crate::core::events::{CacheEvent, CacheEventType};
use crate::error::OxCacheResult;
const STALE_MAGIC: u32 = 0x4F_57_52_53;
const ENV_HEADER: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleState {
Fresh,
Stale,
Expired,
Opaque,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum StalePolicy {
#[default]
Return,
Revalidate,
OffloadRevalidate,
}
fn encode_envelope(expires_at_ms: u64, stale_at_ms: u64, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len() + ENV_HEADER);
out.extend_from_slice(&STALE_MAGIC.to_le_bytes());
out.extend_from_slice(&(expires_at_ms as i64).to_le_bytes());
out.extend_from_slice(&(stale_at_ms as i64).to_le_bytes());
out.extend_from_slice(payload);
out
}
fn decode_envelope(bytes: &[u8]) -> Option<(u64, u64, &[u8])> {
if bytes.len() < ENV_HEADER {
return None;
}
let magic = u32::from_le_bytes(bytes[0..4].try_into().ok()?);
if magic != STALE_MAGIC {
return None;
}
let expire_at = i64::from_le_bytes(bytes[4..12].try_into().ok()?);
let stale_at = i64::from_le_bytes(bytes[12..20].try_into().ok()?);
if expire_at < 0 || stale_at < 0 {
return None;
}
Some((expire_at as u64, stale_at as u64, &bytes[ENV_HEADER..]))
}
fn now_epoch_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Clone)]
pub struct StaleWhileRevalidateBackend {
inner: Arc<dyn CacheBackend>,
stale_ttl: Duration,
policy: StalePolicy,
event_publisher: Option<Arc<dyn crate::core::events::EventPublisher>>,
}
impl StaleWhileRevalidateBackend {
pub fn new(inner: Arc<dyn CacheBackend>, stale_ttl: Duration) -> Self {
Self {
inner,
stale_ttl,
policy: StalePolicy::default(),
event_publisher: None,
}
}
pub fn with_policy(mut self, policy: StalePolicy) -> Self {
self.policy = policy;
self
}
pub fn with_event_publisher(
mut self,
publisher: Arc<dyn crate::core::events::EventPublisher>,
) -> Self {
self.event_publisher = Some(publisher);
self
}
pub fn stale_ttl(&self) -> Duration {
self.stale_ttl
}
async fn emit_stale_event(&self, key: &str) {
#[cfg(feature = "metrics")]
crate::infra::metrics::unified::GLOBAL_UNIFIED_METRICS
.increment_counter("oxcache_stale_hits_total", 1);
if let Some(publisher) = &self.event_publisher {
let event = CacheEvent::new(CacheEventType::Expire)
.with_key(key.to_string())
.with_metadata("state", "stale");
let _ = publisher.publish(event).await;
}
telemetry_stale_hit(key);
}
pub async fn get_with_state(&self, key: &str) -> OxCacheResult<(Option<Vec<u8>>, StaleState)> {
let Some(raw) = self.inner.get(key).await? else {
return Ok((None, StaleState::Expired));
};
let Some((expire_at, stale_at, payload)) = decode_envelope(&raw) else {
return Ok((Some(raw.to_vec()), StaleState::Opaque));
};
let now = now_epoch_ms();
if now < expire_at {
Ok((Some(payload.to_vec()), StaleState::Fresh))
} else if now < stale_at {
self.emit_stale_event(key).await;
Ok((Some(payload.to_vec()), StaleState::Stale))
} else {
let _ = self.inner.delete(key).await;
Ok((None, StaleState::Expired))
}
}
}
#[cfg(feature = "telemetry")]
#[inline]
fn telemetry_stale_hit(key: &str) {
tracing::debug!(
target: "oxcache::stale",
key,
"{}",
t(MSG_LOG_STALE_HIT_SERVED, &[])
);
}
#[cfg(not(feature = "telemetry"))]
#[inline]
fn telemetry_stale_hit(_key: &str) {}
#[async_trait]
impl CacheReader for StaleWhileRevalidateBackend {
async fn get(&self, key: &str) -> OxCacheResult<Option<Vec<u8>>> {
Ok(self.get_with_state(key).await?.0)
}
async fn exists(&self, key: &str) -> OxCacheResult<bool> {
match self.get_with_state(key).await? {
(_, StaleState::Fresh) | (_, StaleState::Stale) | (_, StaleState::Opaque) => Ok(true),
(Some(_), _) => Ok(true),
(None, _) => Ok(false),
}
}
async fn ttl(&self, key: &str) -> OxCacheResult<Option<Duration>> {
let Some(raw) = self.inner.get(key).await? else {
return Ok(None);
};
let Some((expire_at, _, _)) = decode_envelope(&raw) else {
return self.inner.ttl(key).await;
};
let now = now_epoch_ms();
if now < expire_at {
Ok(Some(Duration::from_millis(expire_at - now)))
} else {
Ok(None)
}
}
async fn len(&self) -> OxCacheResult<u64> {
self.inner.len().await
}
async fn capacity(&self) -> OxCacheResult<u64> {
self.inner.capacity().await
}
async fn stats(&self) -> OxCacheResult<std::collections::HashMap<String, String>> {
let mut stats = self.inner.stats().await?;
stats.insert(
"stale_ttl_ms".to_string(),
self.stale_ttl.as_millis().to_string(),
);
Ok(stats)
}
async fn keys(&self, pattern: &str) -> OxCacheResult<Vec<String>> {
self.inner.keys(pattern).await
}
}
#[async_trait]
impl CacheWriter for StaleWhileRevalidateBackend {
async fn set(
&self,
key: Arc<str>,
value: Arc<Vec<u8>>,
ttl: Option<Duration>,
) -> OxCacheResult<()> {
let Some(ttl) = ttl else {
return self.inner.set(key, value, None).await;
};
let now = now_epoch_ms();
let expire_at = now.saturating_add(ttl.as_millis() as u64);
let stale_at = expire_at.saturating_add(self.stale_ttl.as_millis() as u64);
let physical = ttl + self.stale_ttl;
let envelope = Arc::new(encode_envelope(expire_at, stale_at, &value));
self.inner.set(key, envelope, Some(physical)).await
}
async fn delete(&self, key: &str) -> OxCacheResult<()> {
self.inner.delete(key).await
}
async fn clear(&self) -> OxCacheResult<()> {
self.inner.clear().await
}
async fn expire(&self, key: &str, ttl: Duration) -> OxCacheResult<bool> {
let Some(raw) = self.inner.get(key).await? else {
return Ok(false);
};
let payload: Vec<u8> = match decode_envelope(&raw) {
Some((_, _, payload)) => payload.to_vec(),
None => raw,
};
let now = now_epoch_ms();
let expire_at = now.saturating_add(ttl.as_millis() as u64);
let stale_at = expire_at.saturating_add(self.stale_ttl.as_millis() as u64);
let envelope = Arc::new(encode_envelope(expire_at, stale_at, &payload));
let physical = ttl + self.stale_ttl;
self.inner
.set(Arc::from(key), envelope, Some(physical))
.await?;
Ok(true)
}
async fn set_many(&self, items: &[CacheSetItem]) -> OxCacheResult<()> {
let mut wrapped: Vec<CacheSetItem> = Vec::with_capacity(items.len());
for (key, value, ttl) in items {
let Some(ttl) = *ttl else {
wrapped.push((key.clone(), value.clone(), None));
continue;
};
let now = now_epoch_ms();
let expire_at = now.saturating_add(ttl.as_millis() as u64);
let stale_at = expire_at.saturating_add(self.stale_ttl.as_millis() as u64);
let physical = ttl + self.stale_ttl;
wrapped.push((
key.clone(),
Arc::new(encode_envelope(expire_at, stale_at, value)),
Some(physical),
));
}
self.inner.set_many(&wrapped).await
}
}
#[async_trait]
impl CacheConnector for StaleWhileRevalidateBackend {
async fn health_check(&self) -> OxCacheResult<()> {
self.inner.health_check().await
}
async fn shutdown(&self) {
self.inner.shutdown().await;
}
fn backend_kind(&self) -> BackendKind {
self.inner.backend_kind()
}
}
#[cfg(all(test, feature = "stale"))]
mod tests {
use super::*;
use crate::backend::MokaMemoryBackend;
use crate::core::events::{CacheEvent, CacheEventType, EventPublisher};
use crate::error::OxCacheError;
use std::sync::atomic::{AtomicUsize, Ordering};
fn backend(stale_ttl: Duration) -> StaleWhileRevalidateBackend {
StaleWhileRevalidateBackend::new(Arc::new(MokaMemoryBackend::new()), stale_ttl)
}
fn k(s: &str) -> Arc<str> {
Arc::from(s)
}
#[derive(Default)]
struct RecordingPublisher {
expire_events: AtomicUsize,
}
#[async_trait]
impl EventPublisher for RecordingPublisher {
async fn publish(&self, event: CacheEvent) -> Result<(), OxCacheError> {
if event.event_type == CacheEventType::Expire {
self.expire_events.fetch_add(1, Ordering::SeqCst);
}
Ok(())
}
}
#[tokio::test]
async fn fresh_read_round_trips() {
let backend = backend(Duration::from_secs(60));
backend
.set(k("f"), Arc::new(vec![1, 2]), Some(Duration::from_secs(5)))
.await
.unwrap();
let (bytes, state) = backend.get_with_state("f").await.unwrap();
assert_eq!(state, StaleState::Fresh);
assert_eq!(bytes, Some(vec![1, 2]));
let ttl = backend.ttl("f").await.unwrap().unwrap();
assert!(ttl <= Duration::from_secs(5));
}
#[tokio::test]
async fn stale_window_serves_old_value_and_emits_event() {
let publisher = Arc::new(RecordingPublisher::default());
let backend = backend(Duration::from_secs(5)).with_event_publisher(publisher.clone());
backend
.set(k("s"), Arc::new(vec![7]), Some(Duration::from_millis(50)))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(80)).await; let (bytes, state) = backend.get_with_state("s").await.unwrap();
assert_eq!(state, StaleState::Stale);
assert_eq!(
bytes,
Some(vec![7]),
"stale window must serve the old value"
);
assert_eq!(
publisher.expire_events.load(Ordering::SeqCst),
1,
"stale hit must publish Expire event with state=stale"
);
assert_eq!(backend.ttl("s").await.unwrap(), None);
assert!(backend.exists("s").await.unwrap());
}
#[tokio::test]
async fn beyond_stale_window_is_expired_and_deleted() {
let backend = backend(Duration::from_millis(30));
backend
.set(k("e"), Arc::new(vec![7]), Some(Duration::from_millis(30)))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(90)).await;
let (bytes, state) = backend.get_with_state("e").await.unwrap();
assert_eq!(state, StaleState::Expired);
assert_eq!(bytes, None);
assert!(!backend.exists("e").await.unwrap());
}
#[tokio::test]
async fn opaque_passthrough_for_legacy_values() {
let inner = Arc::new(MokaMemoryBackend::new());
inner
.set(
k("legacy"),
Arc::new(vec![3, 3]),
Some(Duration::from_secs(60)),
)
.await
.unwrap();
let backend = StaleWhileRevalidateBackend::new(inner.clone(), Duration::from_secs(60));
let (bytes, state) = backend.get_with_state("legacy").await.unwrap();
assert_eq!(state, StaleState::Opaque);
assert_eq!(bytes, Some(vec![3, 3]));
assert!(backend.ttl("legacy").await.unwrap().is_some());
}
#[tokio::test]
async fn no_ttl_passthrough_has_no_stale_semantics() {
let backend = backend(Duration::from_millis(50));
backend
.set(k("forever"), Arc::new(vec![1]), None)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(80)).await;
let (bytes, state) = backend.get_with_state("forever").await.unwrap();
assert_eq!(state, StaleState::Opaque);
assert_eq!(bytes, Some(vec![1]));
}
#[tokio::test]
async fn delete_clear_and_set_many_transparent() {
let backend = backend(Duration::from_secs(60));
let items: Vec<CacheSetItem> = vec![
(k("m1"), Arc::new(vec![1]), Some(Duration::from_secs(60))),
(k("m2"), Arc::new(vec![2]), None),
];
backend.set_many(&items).await.unwrap();
assert_eq!(backend.get("m1").await.unwrap(), Some(vec![1]));
assert_eq!(backend.get("m2").await.unwrap(), Some(vec![2]));
backend.delete("m1").await.unwrap();
assert_eq!(backend.get("m1").await.unwrap(), None);
backend.clear().await.unwrap();
assert_eq!(backend.get("m2").await.unwrap(), None);
}
#[cfg(feature = "compression")]
#[tokio::test]
async fn composes_with_compression_in_both_orders() {
use crate::features::compression::CompressingBackend;
let base = Arc::new(MokaMemoryBackend::new());
let compressed = Arc::new(CompressingBackend::new(base.clone()));
let stale_outer = StaleWhileRevalidateBackend::new(compressed, Duration::from_secs(60));
stale_outer
.set(
k("c1"),
Arc::new(vec![b'x'; 4096]),
Some(Duration::from_secs(60)),
)
.await
.unwrap();
assert_eq!(
stale_outer.get("c1").await.unwrap(),
Some(vec![b'x'; 4096]),
"stale(compress(base)) order must round-trip"
);
let base2 = Arc::new(MokaMemoryBackend::new());
let stale_inner = Arc::new(StaleWhileRevalidateBackend::new(
base2.clone(),
Duration::from_secs(60),
));
let compressed_outer = CompressingBackend::new(stale_inner);
let payload = vec![b'y'; 4096];
compressed_outer
.set(
k("c2"),
Arc::new(payload.clone()),
Some(Duration::from_secs(60)),
)
.await
.unwrap();
assert_eq!(
compressed_outer.get("c2").await.unwrap(),
Some(payload),
"compress(stale(base)) order must round-trip"
);
}
#[test]
fn decode_envelope_rejects_foreign_payloads() {
assert!(decode_envelope(&b"OW"[..]).is_none());
let mut bad = vec![0u8; ENV_HEADER];
bad[0..4].copy_from_slice(&0x4F_57_52_54u32.to_le_bytes());
assert!(decode_envelope(&bad).is_none());
let mut neg = Vec::new();
neg.extend_from_slice(&STALE_MAGIC.to_le_bytes());
neg.extend_from_slice(&(-1i64).to_le_bytes());
neg.extend_from_slice(&1i64.to_le_bytes());
assert!(decode_envelope(&neg).is_none());
let mut neg2 = Vec::new();
neg2.extend_from_slice(&STALE_MAGIC.to_le_bytes());
neg2.extend_from_slice(&1i64.to_le_bytes());
neg2.extend_from_slice(&(-1i64).to_le_bytes());
assert!(decode_envelope(&neg2).is_none());
let ok = encode_envelope(10, 20, b"payload");
let (exp, stale, payload) = decode_envelope(&ok).expect("valid envelope");
assert_eq!((exp, stale, payload), (10, 20, b"payload".as_slice()));
}
}