use std::time::Duration;
use async_trait::async_trait;
use redis::AsyncCommands;
use super::{Cache, CacheError};
const CONNECT_RETRIES: usize = 2;
const RETRY_MAX_DELAY_MS: u64 = 1_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct RedisTimeouts {
pub connect: Duration,
pub response: Duration,
}
impl RedisTimeouts {
#[must_use]
pub const fn new(connect: Duration, response: Duration) -> Self {
Self { connect, response }
}
}
impl Default for RedisTimeouts {
fn default() -> Self {
Self::new(Duration::from_secs(5), Duration::from_secs(2))
}
}
fn manager_config(timeouts: RedisTimeouts) -> redis::aio::ConnectionManagerConfig {
redis::aio::ConnectionManagerConfig::new()
.set_connection_timeout(timeouts.connect)
.set_response_timeout(timeouts.response)
.set_number_of_retries(CONNECT_RETRIES)
.set_max_delay(RETRY_MAX_DELAY_MS)
}
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> {
Self::with_timeouts(url, default_ttl, RedisTimeouts::default()).await
}
pub async fn with_timeouts(
url: &str,
default_ttl: Option<Duration>,
timeouts: RedisTimeouts,
) -> Result<Self, CacheError> {
let client = redis::Client::open(url).map_err(|e| CacheError::Connection(e.to_string()))?;
let conn = redis::aio::ConnectionManager::new_with_config(client, manager_config(timeouts))
.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 get_bytes(&self, key: &str) -> Result<Option<bytes::Bytes>, CacheError> {
let mut conn = self.conn.clone();
conn.get::<_, Option<Vec<u8>>>(key)
.await
.map(|v| v.map(Into::into))
.map_err(|e| CacheError::Connection(e.to_string()))
}
async fn set(&self, key: &str, value: &str, ttl: Option<Duration>) -> Result<(), CacheError> {
super::check_text(value)?;
self.set_bytes(key, value.as_bytes(), ttl).await
}
async fn set_bytes(
&self,
key: &str,
value: &[u8],
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> {
super::check_text(value)?;
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> {
super::check_text(value)?;
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")
.arg("ASYNC")
.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 r = redis.pcall('INCRBY', KEYS[1], ARGV[1])
if type(r) == 'table' and r.err then
if not string.find(r.err, 'not an integer', 1, true) then
return r
end
local left = redis.call('PTTL', KEYS[1])
redis.call('SET', KEYS[1], ARGV[1])
if left > 0 then
redis.call('PEXPIRE', KEYS[1], left)
end
end
if tonumber(ARGV[2]) > 0 and redis.call('PTTL', KEYS[1]) < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return redis.call('GET', KEYS[1])",
);
let value: String = script
.key(key)
.arg(by)
.arg(self.effective_ttl_ms(ttl).unwrap_or(0))
.invoke_async(&mut conn)
.await
.map_err(|e| {
if e.to_string().contains("would overflow") {
return super::incr_overflow();
}
CacheError::Connection(e.to_string())
})?;
value
.parse()
.map_err(|_| CacheError::Connection(format!("incr: non-integer result {value:?}")))
}
}
#[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);
}
#[test]
fn manager_config_caps_retries_and_backoff() {
let t = Duration::from_millis(100);
let config = format!("{:?}", manager_config(RedisTimeouts::new(t, t)));
assert!(config.contains("number_of_retries: 2"), "{config}");
assert!(config.contains("max_delay: Some(1000)"), "{config}");
}
async fn ok_server() -> (String, std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>) {
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("redis://{}/", listener.local_addr().unwrap());
let seen = std::sync::Arc::<std::sync::Mutex<Vec<Vec<String>>>>::default();
let log = seen.clone();
tokio::spawn(async move {
while let Ok((sock, _)) = listener.accept().await {
let log = log.clone();
tokio::spawn(async move {
let (r, mut w) = sock.into_split();
let mut r = BufReader::new(r);
let mut line = String::new();
loop {
line.clear();
if r.read_line(&mut line).await.unwrap_or(0) == 0 {
return;
}
let n: usize = line.trim()[1..].parse().unwrap();
let mut cmd = Vec::new();
for _ in 0..n {
line.clear();
r.read_line(&mut line).await.unwrap();
let len: usize = line.trim()[1..].parse().unwrap();
let mut buf = vec![0; len + 2];
r.read_exact(&mut buf).await.unwrap();
buf.truncate(len);
cmd.push(String::from_utf8(buf).unwrap());
}
log.lock().unwrap().push(cmd);
w.write_all(b"+OK\r\n").await.unwrap();
}
});
}
});
(url, seen)
}
#[tokio::test]
async fn clear_flushes_asynchronously() {
let (url, seen) = ok_server().await;
let cache = RedisCache::new(&url).await.expect("connect");
cache.clear().await.expect("clear");
let seen = seen.lock().unwrap();
let flush = seen.iter().find(|c| c[0].eq_ignore_ascii_case("FLUSHDB"));
assert_eq!(flush.map(|c| &c[1..]), Some(&["ASYNC".to_owned()][..]));
}
#[tokio::test]
async fn touch_never_reads_then_writes() {
let (url, seen) = ok_server().await;
let cache = RedisCache::new(&url).await.expect("connect");
let _ = cache.touch("k", Some(Duration::from_secs(60))).await;
let _ = cache.touch("k", None).await;
let seen = seen.lock().unwrap();
let sent = |name: &str| seen.iter().any(|c| c[0].eq_ignore_ascii_case(name));
assert!(sent("PEXPIRE") && sent("PERSIST"), "{seen:?}");
assert!(!sent("GET") && !sent("SET"), "{seen:?}");
}
#[tokio::test]
async fn a_silent_server_fails_the_connect() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("redis://{}/", listener.local_addr().unwrap());
let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let count = accepted.clone();
let _accept = tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((sock, _)) = listener.accept().await {
count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
held.push(sock);
}
});
let t = Duration::from_millis(100);
let start = std::time::Instant::now();
let built = tokio::time::timeout(
Duration::from_secs(30),
RedisCache::with_timeouts(&url, None, RedisTimeouts::new(t, t)),
)
.await
.expect("connect hung past its timeout");
assert!(built.is_err(), "connected to a silent server");
assert!(
start.elapsed() < Duration::from_secs(6),
"{:?}",
start.elapsed()
);
tokio::time::sleep(Duration::from_millis(200)).await;
let attempts = accepted.load(std::sync::atomic::Ordering::SeqCst);
assert_eq!(attempts, 3, "one connect and two retries");
}
}