use std::time::Duration;
use async_trait::async_trait;
use redis::AsyncCommands;
use super::{Cache, CacheError};
pub struct RedisCache {
conn: redis::aio::ConnectionManager,
default_ttl: Option<Duration>,
}
impl RedisCache {
pub async fn new(url: &str) -> Result<Self, CacheError> {
Self::with_default_ttl(url, None).await
}
pub async fn with_default_ttl(
url: &str,
default_ttl: Option<Duration>,
) -> Result<Self, CacheError> {
let client = redis::Client::open(url).map_err(|e| CacheError::Connection(e.to_string()))?;
let conn = redis::aio::ConnectionManager::new(client)
.await
.map_err(|e| CacheError::Connection(e.to_string()))?;
Ok(Self { conn, default_ttl })
}
fn effective_ttl_ms(&self, ttl: Option<Duration>) -> Option<u64> {
ttl.or(self.default_ttl).map(ttl_ms)
}
}
const MAX_TTL_MS: u64 = i64::MAX as u64 / 2;
fn ttl_ms(d: Duration) -> u64 {
u64::try_from(d.as_millis())
.unwrap_or(u64::MAX)
.clamp(1, MAX_TTL_MS)
}
#[async_trait]
impl Cache for RedisCache {
async fn get(&self, key: &str) -> Result<Option<String>, CacheError> {
let mut conn = self.conn.clone();
conn.get::<_, Option<String>>(key)
.await
.map_err(|e| CacheError::Connection(e.to_string()))
}
async fn set(&self, key: &str, value: &str, ttl: Option<Duration>) -> Result<(), CacheError> {
let mut conn = self.conn.clone();
match self.effective_ttl_ms(ttl) {
Some(ms) => conn
.pset_ex::<_, _, ()>(key, value, ms)
.await
.map_err(|e| CacheError::Connection(e.to_string())),
None => conn
.set::<_, _, ()>(key, value)
.await
.map_err(|e| CacheError::Connection(e.to_string())),
}
}
async fn set_forever(&self, key: &str, value: &str) -> Result<(), CacheError> {
let mut conn = self.conn.clone();
conn.set::<_, _, ()>(key, value)
.await
.map_err(|e| CacheError::Connection(e.to_string()))
}
async fn add(&self, key: &str, value: &str, ttl: Option<Duration>) -> Result<bool, CacheError> {
let mut conn = self.conn.clone();
let mut cmd = redis::cmd("SET");
cmd.arg(key).arg(value).arg("NX");
if let Some(ms) = self.effective_ttl_ms(ttl) {
cmd.arg("PX").arg(ms);
}
let reply: Option<String> = cmd
.query_async(&mut conn)
.await
.map_err(|e| CacheError::Connection(e.to_string()))?;
Ok(reply.is_some())
}
async fn touch(&self, key: &str, ttl: Option<Duration>) -> Result<bool, CacheError> {
let mut conn = self.conn.clone();
let err = |e: redis::RedisError| CacheError::Connection(e.to_string());
match self.effective_ttl_ms(ttl) {
Some(ms) => redis::cmd("PEXPIRE")
.arg(key)
.arg(ms)
.query_async::<bool>(&mut conn)
.await
.map_err(err),
None => {
redis::cmd("PERSIST")
.arg(key)
.query_async::<()>(&mut conn)
.await
.map_err(err)?;
conn.exists::<_, bool>(key).await.map_err(err)
}
}
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
let mut conn = self.conn.clone();
conn.del::<_, ()>(key)
.await
.map_err(|e| CacheError::Connection(e.to_string()))
}
async fn exists(&self, key: &str) -> Result<bool, CacheError> {
let mut conn = self.conn.clone();
conn.exists::<_, bool>(key)
.await
.map_err(|e| CacheError::Connection(e.to_string()))
}
async fn clear(&self) -> Result<(), CacheError> {
let mut conn = self.conn.clone();
redis::cmd("FLUSHDB")
.query_async::<()>(&mut conn)
.await
.map_err(|e| CacheError::Connection(e.to_string()))
}
async fn delete_prefix(&self, prefix: &str) -> Result<(), CacheError> {
let mut conn = self.conn.clone();
let mut pattern = String::with_capacity(prefix.len() + 1);
for ch in prefix.chars() {
if matches!(ch, '*' | '?' | '[' | ']' | '\\') {
pattern.push('\\');
}
pattern.push(ch);
}
pattern.push('*');
let mut cursor: u64 = 0;
loop {
let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(512)
.query_async(&mut conn)
.await
.map_err(|e| CacheError::Connection(format!("scan: {e}")))?;
if !keys.is_empty() {
redis::cmd("DEL")
.arg(&keys)
.query_async::<()>(&mut conn)
.await
.map_err(|e| CacheError::Connection(format!("del: {e}")))?;
}
if next == 0 {
return Ok(());
}
cursor = next;
}
}
async fn incr(&self, key: &str, by: i64, ttl: Option<Duration>) -> Result<i64, CacheError> {
let mut conn = self.conn.clone();
let script = redis::Script::new(
r"local n = redis.call('INCRBY', KEYS[1], ARGV[1])
if tonumber(ARGV[2]) > 0 and redis.call('PTTL', KEYS[1]) < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return n",
);
script
.key(key)
.arg(by)
.arg(self.effective_ttl_ms(ttl).unwrap_or(0))
.invoke_async(&mut conn)
.await
.map_err(|e| CacheError::Connection(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ttl_ms_is_clamped_to_a_valid_redis_expire() {
assert_eq!(ttl_ms(Duration::MAX), MAX_TTL_MS);
assert!(ttl_ms(Duration::MAX) <= i64::MAX as u64);
assert_eq!(ttl_ms(Duration::from_millis(1500)), 1500);
assert_eq!(ttl_ms(Duration::ZERO), 1);
}
}