use crate::config::CacheConfig;
use crate::error::{CacheError, CacheResult};
use crate::traits::CacheStore;
use async_trait::async_trait;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::Mutex;
const MEMCACHED_RELATIVE_TTL_MAX_SECS: u64 = 2_592_000;
#[derive(Clone)]
pub struct MemcachedCache {
client: Arc<Mutex<memcache::Client>>,
config: CacheConfig,
}
impl MemcachedCache {
pub async fn new(config: CacheConfig) -> CacheResult<Self> {
let url = config.url.clone();
let server_url = Self::parse_memcached_url(&url)?;
if config.max_connections > 1 {
armature_log::warn!(
"MemcachedCache ignores CacheConfig::max_connections (configured: {}); \
this backend holds a single memcache::Client behind one mutex, so all \
operations serialize onto one connection. connection_timeout and \
operation_timeout are ignored by this backend as well.",
config.max_connections
);
}
let client = tokio::task::spawn_blocking(move || memcache::connect(server_url.as_str()))
.await
.map_err(|e| CacheError::Connection(format!("Failed to spawn task: {}", e)))?
.map_err(|e| CacheError::Connection(format!("Failed to connect: {}", e)))?;
Ok(Self {
client: Arc::new(Mutex::new(client)),
config,
})
}
fn parse_memcached_url(url: &str) -> CacheResult<String> {
if url.starts_with("memcache://") {
Ok(url.to_string())
} else if url.contains(':') {
Ok(format!("memcache://{}", url))
} else {
Err(CacheError::InvalidUrl(format!(
"Invalid Memcached URL: {}. Expected format: 'memcache://host:port' or 'host:port'",
url
)))
}
}
fn build_key(&self, key: &str) -> String {
self.config.build_key(key)
}
fn duration_to_expiration(ttl: Option<Duration>) -> u32 {
match ttl {
Some(d) => Self::expiration_from_secs(d.as_secs(), Self::unix_now()),
None => 0,
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn expiration_from_secs(secs: u64, now: u64) -> u32 {
if secs <= MEMCACHED_RELATIVE_TTL_MAX_SECS {
secs as u32
} else {
now.saturating_add(secs).min(u32::MAX as u64) as u32
}
}
}
#[async_trait]
impl CacheStore for MemcachedCache {
async fn get_json(&self, key: &str) -> CacheResult<Option<String>> {
let key = self.build_key(key);
let client = self.client.clone();
let result = tokio::task::spawn_blocking(move || {
let client = client.blocking_lock();
client.get::<String>(&key)
})
.await
.map_err(|e| CacheError::Other(format!("Task join error: {}", e)))?;
result.map_err(CacheError::from)
}
async fn mget(&self, keys: &[&str]) -> CacheResult<Vec<Option<String>>> {
if keys.is_empty() {
return Ok(Vec::new());
}
let prefixed: Vec<String> = keys.iter().map(|k| self.build_key(k)).collect();
let client = self.client.clone();
let found: std::collections::HashMap<String, String> =
tokio::task::spawn_blocking(move || {
let refs: Vec<&str> = prefixed.iter().map(|s| s.as_str()).collect();
let client = client.blocking_lock();
client.gets::<String>(&refs)
})
.await
.map_err(|e| CacheError::Other(format!("Task join error: {}", e)))?
.map_err(|e| CacheError::Other(format!("memcached mget failed: {}", e)))?;
Ok(keys
.iter()
.map(|k| found.get(&self.build_key(k)).cloned())
.collect())
}
async fn set_json(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()> {
let key = self.build_key(key);
let client = self.client.clone();
let ttl = ttl.or(self.config.default_ttl);
let expiration = Self::duration_to_expiration(ttl);
tokio::task::spawn_blocking(move || {
let client = client.blocking_lock();
client.set(&key, value, expiration)
})
.await
.map_err(|e| CacheError::Other(format!("Task join error: {}", e)))??;
Ok(())
}
async fn set_json_forever(&self, key: &str, value: String) -> CacheResult<()> {
let key = self.build_key(key);
let client = self.client.clone();
tokio::task::spawn_blocking(move || {
let client = client.blocking_lock();
client.set(&key, value, 0)
})
.await
.map_err(|e| CacheError::Other(format!("Task join error: {}", e)))??;
Ok(())
}
async fn delete(&self, key: &str) -> CacheResult<()> {
let key = self.build_key(key);
let client = self.client.clone();
tokio::task::spawn_blocking(move || {
let client = client.blocking_lock();
client.delete(&key)
})
.await
.map_err(|e| CacheError::Other(format!("Task join error: {}", e)))??;
Ok(())
}
async fn exists(&self, key: &str) -> CacheResult<bool> {
let result = self.get_json(key).await?;
Ok(result.is_some())
}
async fn clear(&self) -> CacheResult<()> {
let client = self.client.clone();
tokio::task::spawn_blocking(move || {
let client = client.blocking_lock();
client.flush()
})
.await
.map_err(|e| CacheError::Other(format!("Task join error: {}", e)))??;
Ok(())
}
async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>> {
let _ = key;
Ok(None)
}
async fn expire(&self, key: &str, ttl: Duration) -> CacheResult<()> {
let full_key = self.build_key(key);
let client = self.client.clone();
let expiration = Self::duration_to_expiration(Some(ttl));
let touched = tokio::task::spawn_blocking(move || {
let client = client.blocking_lock();
client.touch(&full_key, expiration)
})
.await
.map_err(|e| CacheError::Other(format!("Task join error: {}", e)))??;
if touched {
Ok(())
} else {
Err(CacheError::NotFound(key.to_string()))
}
}
async fn increment(&self, key: &str, delta: i64) -> CacheResult<i64> {
let key = self.build_key(key);
let client = self.client.clone();
let expiration = Self::duration_to_expiration(self.config.default_ttl);
let magnitude = delta.unsigned_abs();
let is_increment = delta >= 0;
let new_value =
tokio::task::spawn_blocking(move || -> Result<u64, memcache::MemcacheError> {
let client = client.blocking_lock();
let apply = |client: &memcache::Client| -> Result<u64, memcache::MemcacheError> {
if is_increment {
client.increment(&key, magnitude)
} else {
client.decrement(&key, magnitude)
}
};
match apply(&client) {
Ok(value) => Ok(value),
Err(memcache::MemcacheError::CommandError(
memcache::CommandError::KeyNotFound,
)) => {
match client.add(&key, 0u64, expiration) {
Ok(()) => Ok(0),
Err(memcache::MemcacheError::CommandError(
memcache::CommandError::KeyExists,
)) => apply(&client),
Err(e) => Err(e),
}
}
Err(e) => Err(e),
}
})
.await
.map_err(|e| CacheError::Other(format!("Task join error: {}", e)))??;
Ok(new_value as i64)
}
async fn decrement(&self, key: &str, delta: i64) -> CacheResult<i64> {
self.increment(key, -delta).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_memcached_url() {
assert_eq!(
MemcachedCache::parse_memcached_url("memcache://localhost:11211").unwrap(),
"memcache://localhost:11211"
);
assert_eq!(
MemcachedCache::parse_memcached_url("localhost:11211").unwrap(),
"memcache://localhost:11211"
);
assert!(MemcachedCache::parse_memcached_url("invalid").is_err());
}
#[test]
fn test_duration_to_expiration() {
assert_eq!(MemcachedCache::duration_to_expiration(None), 0);
assert_eq!(
MemcachedCache::duration_to_expiration(Some(Duration::from_secs(60))),
60
);
}
#[test]
fn test_expiration_under_threshold_passes_through() {
let now = 1_700_000_000;
assert_eq!(MemcachedCache::expiration_from_secs(0, now), 0);
assert_eq!(MemcachedCache::expiration_from_secs(60, now), 60);
assert_eq!(
MemcachedCache::expiration_from_secs(MEMCACHED_RELATIVE_TTL_MAX_SECS - 1, now),
(MEMCACHED_RELATIVE_TTL_MAX_SECS - 1) as u32
);
}
#[test]
fn test_expiration_at_threshold_passes_through() {
let now = 1_700_000_000;
assert_eq!(
MemcachedCache::expiration_from_secs(MEMCACHED_RELATIVE_TTL_MAX_SECS, now),
MEMCACHED_RELATIVE_TTL_MAX_SECS as u32
);
}
#[test]
fn test_expiration_over_threshold_becomes_future_absolute_timestamp() {
let now = 1_700_000_000;
let forty_days = 40 * 24 * 60 * 60; let expiration = MemcachedCache::expiration_from_secs(forty_days, now);
assert_eq!(expiration as u64, now + forty_days);
assert!(
(expiration as u64) > now,
"an over-threshold TTL must land in the future, not 1970"
);
}
#[test]
fn test_expiration_overflow_saturates() {
let now = 1_700_000_000;
assert_eq!(
MemcachedCache::expiration_from_secs(u64::MAX, now),
u32::MAX
);
assert_eq!(
MemcachedCache::expiration_from_secs(u32::MAX as u64, now),
u32::MAX
);
assert_eq!(
MemcachedCache::duration_to_expiration(Some(Duration::from_secs(u64::MAX))),
u32::MAX
);
}
}