use std::time::Duration;
use async_trait::async_trait;
use deadpool_redis::{Runtime, Timeouts};
use redis::{ErrorKind, ServerErrorKind};
use crate::{Cache, CacheError};
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy)]
pub struct RedisOptions {
pub max_size: usize,
pub response_timeout: Duration,
}
impl Default for RedisOptions {
fn default() -> Self {
Self { max_size: 16, response_timeout: RESPONSE_TIMEOUT }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RedisPoolStatus {
pub max_size: usize,
pub size: usize,
pub available: usize,
}
pub struct RedisCache {
pool: deadpool_redis::Pool,
response_timeout: Duration,
}
impl RedisCache {
pub async fn new(addr: &str) -> Result<Self, CacheError> {
Self::with_options(addr, RedisOptions::default()).await
}
pub async fn with_pool_size(addr: &str, max_size: usize) -> Result<Self, CacheError> {
Self::with_options(addr, RedisOptions { max_size, ..RedisOptions::default() }).await
}
pub async fn with_options(addr: &str, options: RedisOptions) -> Result<Self, CacheError> {
let pool = connect_pool(addr, options.max_size, options.response_timeout).await?;
Ok(Self { pool, response_timeout: options.response_timeout })
}
pub fn status(&self) -> deadpool_redis::Status {
self.pool.status()
}
pub fn pool_status(&self) -> RedisPoolStatus {
to_pool_status(self.pool.status())
}
async fn checkout(&self) -> Result<deadpool_redis::Connection, CacheError> {
let mut conn = self.pool.get().await.map_err(conn_err)?;
conn.set_response_timeout(self.response_timeout);
Ok(conn)
}
async fn delete_count(&self, key: &str) -> Result<i64, CacheError> {
let mut conn = self.checkout().await?;
redis::cmd("DEL").arg(key).query_async(&mut conn).await.map_err(map_err)
}
}
async fn connect_pool(
addr: &str,
max_size: usize,
response_timeout: Duration,
) -> Result<deadpool_redis::Pool, CacheError> {
let pool = deadpool_redis::Config::from_url(addr)
.builder()
.map_err(conn_err)?
.max_size(max_size.max(1))
.timeouts(Timeouts {
wait: Some(Duration::from_secs(30)),
create: Some(Duration::from_secs(5)),
recycle: Some(Duration::from_secs(5)),
})
.runtime(Runtime::Tokio1)
.build()
.map_err(conn_err)?;
let mut conn = pool.get().await.map_err(conn_err)?;
conn.set_response_timeout(response_timeout);
redis::cmd("PING").query_async::<String>(&mut conn).await.map_err(conn_err)?;
Ok(pool)
}
#[async_trait]
impl Cache for RedisCache {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
let mut conn = self.checkout().await?;
redis::cmd("GET").arg(key).query_async::<Option<Vec<u8>>>(&mut conn).await.map_err(map_err)
}
async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<u64>) -> Result<(), CacheError> {
if ttl == Some(0) {
self.delete_count(key).await?;
return Ok(());
}
let mut conn = self.checkout().await?;
set_cmd(key, &value, ttl).query_async(&mut conn).await.map_err(map_err)
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
match self.delete_count(key).await? {
0 => Err(CacheError::NotFound),
_ => Ok(()),
}
}
async fn incr(&self, key: &str) -> Result<i64, CacheError> {
let mut conn = self.checkout().await?;
redis::cmd("INCR").arg(key).query_async(&mut conn).await.map_err(map_err)
}
}
pub(crate) fn set_cmd(key: &str, value: &[u8], ttl: Option<u64>) -> redis::Cmd {
let mut cmd = redis::cmd("SET");
cmd.arg(key).arg(value);
if let Some(ttl) = ttl {
cmd.arg("EX").arg(ttl);
}
cmd
}
pub(crate) fn conn_err(e: impl std::fmt::Display) -> CacheError {
CacheError::ConnectionError(e.to_string())
}
pub(crate) fn map_err(e: redis::RedisError) -> CacheError {
let message = e.to_string();
match e.kind() {
ErrorKind::Server(ServerErrorKind::ResponseError) => CacheError::SerializeError(message),
_ => CacheError::ConnectionError(message),
}
}
pub(crate) fn to_pool_status(status: deadpool_redis::Status) -> RedisPoolStatus {
RedisPoolStatus { max_size: status.max_size, size: status.size, available: status.available }
}
#[cfg(test)]
mod tests {
use super::*;
fn packed(cmd: &redis::Cmd) -> Vec<u8> {
cmd.get_packed_command()
}
#[test]
fn set_without_ttl_sends_only_key_and_value() {
let bytes = packed(&set_cmd("k", b"v", None));
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("$3\r\nSET\r\n"), "{text}");
assert!(text.contains("$1\r\nk\r\n"), "{text}");
assert!(text.contains("$1\r\nv\r\n"), "{text}");
assert!(!text.contains("EX"), "{text}");
}
#[test]
fn set_with_ttl_appends_ex_seconds() {
let text = String::from_utf8_lossy(&packed(&set_cmd("k", b"v", Some(60)))).into_owned();
assert!(text.contains("$2\r\nEX\r\n"), "{text}");
assert!(text.contains("$2\r\n60\r\n"), "{text}");
}
#[test]
fn set_is_binary_safe() {
let value = [0xff, 0x00, 0x10];
let bytes = packed(&set_cmd("k", &value, None));
assert!(bytes.windows(value.len()).any(|w| w == value), "{bytes:?}");
}
#[test]
fn default_options_are_sixteen_connections_and_thirty_seconds() {
let options = RedisOptions::default();
assert_eq!(options.max_size, 16);
assert_eq!(options.response_timeout, RESPONSE_TIMEOUT);
}
#[test]
fn pool_status_reports_the_configured_size_before_any_connection() {
let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1")
.builder()
.unwrap()
.max_size(3)
.runtime(Runtime::Tokio1)
.build()
.unwrap();
let cache = RedisCache { pool, response_timeout: Duration::from_secs(1) };
let status = cache.pool_status();
assert_eq!(status.max_size, 3);
assert_eq!(status.size, 0);
assert_eq!(status.available, 0);
}
}