use std::time::Duration;
use rainier_support::{Error, Result};
use redis::aio::MultiplexedConnection;
use redis::{Cmd, FromRedisValue};
#[derive(Clone)]
enum Backend {
Single(redis::Client),
#[cfg(feature = "redis-cluster")]
Cluster(redis::cluster::ClusterClient),
}
#[derive(Clone)]
pub enum RedisConnection {
Single(MultiplexedConnection),
#[cfg(feature = "redis-cluster")]
Cluster(redis::cluster_async::ClusterConnection),
}
impl RedisConnection {
pub async fn query<T: FromRedisValue>(&mut self, command: &Cmd) -> Result<T> {
let outcome = match self {
RedisConnection::Single(connection) => command.query_async(connection).await,
#[cfg(feature = "redis-cluster")]
RedisConnection::Cluster(connection) => command.query_async(connection).await,
};
outcome.map_err(redis_error)
}
pub async fn run(&mut self, command: &Cmd) -> Result<()> {
self.query::<()>(command).await
}
pub fn is_cluster(&self) -> bool {
match self {
RedisConnection::Single(_) => false,
#[cfg(feature = "redis-cluster")]
RedisConnection::Cluster(_) => true,
}
}
}
impl std::fmt::Debug for RedisConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(if self.is_cluster() {
"RedisConnection::Cluster"
} else {
"RedisConnection::Single"
})
}
}
#[derive(Clone)]
pub struct RedisConnector {
backend: Backend,
description: String,
}
impl RedisConnector {
pub fn open(url: &str) -> Result<Self> {
let client = redis::Client::open(url).map_err(|e| {
Error::internal(format!("could not open a Redis client: {e}"))
})?;
Ok(Self { backend: Backend::Single(client), description: "redis".to_string() })
}
#[cfg(feature = "redis-cluster")]
pub fn open_cluster(urls: impl IntoIterator<Item = impl Into<String>>) -> Result<Self> {
let seeds: Vec<String> = urls.into_iter().map(Into::into).collect();
if seeds.is_empty() {
return Err(Error::internal("a Redis cluster needs at least one seed node"));
}
let count = seeds.len();
let client = redis::cluster::ClusterClient::new(seeds)
.map_err(|e| Error::internal(format!("could not open a Redis cluster client: {e}")))?;
Ok(Self {
backend: Backend::Cluster(client),
description: format!("redis-cluster({count} seeds)"),
})
}
pub fn description(&self) -> &str {
&self.description
}
pub fn is_cluster(&self) -> bool {
match self.backend {
Backend::Single(_) => false,
#[cfg(feature = "redis-cluster")]
Backend::Cluster(_) => true,
}
}
pub async fn connect(&self) -> Result<RedisConnection> {
match &self.backend {
Backend::Single(client) => client
.get_multiplexed_async_connection()
.await
.map(RedisConnection::Single)
.map_err(redis_error),
#[cfg(feature = "redis-cluster")]
Backend::Cluster(client) => client
.get_async_connection()
.await
.map(RedisConnection::Cluster)
.map_err(redis_error),
}
}
pub async fn ping(&self) -> Result<bool> {
let mut connection = self.connect().await?;
let reply: String = connection.query(&redis::cmd("PING")).await?;
Ok(reply.eq_ignore_ascii_case("pong"))
}
}
impl std::fmt::Debug for RedisConnector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedisConnector").field("backend", &self.description).finish()
}
}
fn redis_error(error: redis::RedisError) -> Error {
Error::new(
rainier_support::ErrorKind::ServiceUnavailable,
format!("Redis: {}", error.category_message()),
)
.with_source(anyhow_from(error))
}
fn anyhow_from(error: redis::RedisError) -> anyhow::Error {
anyhow::Error::new(error)
}
trait Category {
fn category_message(&self) -> String;
}
impl Category for redis::RedisError {
fn category_message(&self) -> String {
match self.kind() {
redis::ErrorKind::IoError => "the server could not be reached".to_string(),
redis::ErrorKind::AuthenticationFailed => "authentication failed".to_string(),
redis::ErrorKind::TypeError => "the reply was not the expected type".to_string(),
redis::ErrorKind::ResponseError => {
self.detail().unwrap_or("the server rejected the command").to_string()
}
_ => self.detail().unwrap_or("the command failed").to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_single_url_opens() {
let connector = RedisConnector::open("redis://127.0.0.1:6379/").unwrap();
assert!(!connector.is_cluster());
assert_eq!(connector.description(), "redis");
}
#[test]
fn a_rubbish_url_is_refused_without_echoing_it() {
let err = RedisConnector::open("postgres://user:hunter2@host/db").unwrap_err();
assert!(!err.message().contains("hunter2"), "{}", err.message());
}
#[test]
#[cfg(feature = "redis-cluster")]
fn a_cluster_needs_at_least_one_seed() {
let err = RedisConnector::open_cluster(Vec::<String>::new()).unwrap_err();
assert!(err.message().contains("at least one seed"), "{}", err.message());
}
#[test]
#[cfg(feature = "redis-cluster")]
fn a_cluster_reports_its_seed_count() {
let connector =
RedisConnector::open_cluster(["redis://10.0.0.1:6379", "redis://10.0.0.2:6379"])
.unwrap();
assert!(connector.is_cluster());
assert!(connector.description().contains('2'), "{}", connector.description());
}
#[tokio::test]
async fn connecting_to_nothing_is_a_503_not_a_500() {
let connector = RedisConnector::open("redis://127.0.0.1:1/").unwrap();
let err = connector.connect().await.unwrap_err();
assert_eq!(err.status(), 503, "{}", err.message());
}
}
#[derive(Clone)]
pub struct RedisClient {
connection: RedisConnection,
}
impl RedisClient {
pub async fn connect(connector: &RedisConnector) -> Result<Self> {
Ok(Self { connection: connector.connect().await? })
}
pub fn new(connection: RedisConnection) -> Self {
Self { connection }
}
pub fn is_cluster(&self) -> bool {
self.connection.is_cluster()
}
pub fn describe(&self) -> &'static str {
if self.is_cluster() {
"redis-cluster"
} else {
"redis"
}
}
pub fn connection(&self) -> RedisConnection {
self.connection.clone()
}
pub async fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
self.connection.clone().query(redis::cmd("GET").arg(key)).await
}
pub async fn set(&self, key: &str, value: &[u8], ttl: Option<Duration>) -> Result<()> {
let mut command = redis::cmd("SET");
command.arg(key).arg(value);
if let Some(ttl) = ttl {
command.arg("PX").arg((ttl.as_millis() as u64).max(1));
}
self.connection.clone().run(&command).await
}
pub async fn set_nx(&self, key: &str, value: &[u8], ttl: Option<Duration>) -> Result<bool> {
let mut command = redis::cmd("SET");
command.arg(key).arg(value).arg("NX");
if let Some(ttl) = ttl {
command.arg("PX").arg((ttl.as_millis() as u64).max(1));
}
let reply: Option<String> = self.connection.clone().query(&command).await?;
Ok(reply.is_some())
}
pub async fn del_if(&self, key: &str, expected: &[u8]) -> Result<bool> {
const SCRIPT: &str = r#"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"#;
let mut command = redis::cmd("EVAL");
command.arg(SCRIPT).arg(1).arg(key).arg(expected);
let removed: i64 = self.connection.clone().query(&command).await?;
Ok(removed == 1)
}
pub async fn pexpire_if(&self, key: &str, expected: &[u8], ttl: Duration) -> Result<bool> {
const SCRIPT: &str = r#"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end
"#;
let mut command = redis::cmd("EVAL");
command.arg(SCRIPT).arg(1).arg(key).arg(expected).arg((ttl.as_millis() as u64).max(1));
let extended: i64 = self.connection.clone().query(&command).await?;
Ok(extended == 1)
}
pub async fn del(&self, key: &str) -> Result<bool> {
let removed: i64 = self.connection.clone().query(redis::cmd("DEL").arg(key)).await?;
Ok(removed > 0)
}
pub async fn publish(&self, channel: &str, payload: &[u8]) -> Result<u64> {
self.connection.clone().query(redis::cmd("PUBLISH").arg(channel).arg(payload)).await
}
pub async fn exists(&self, key: &str) -> Result<bool> {
let count: i64 = self.connection.clone().query(redis::cmd("EXISTS").arg(key)).await?;
Ok(count > 0)
}
pub async fn incr_by(&self, key: &str, delta: i64) -> Result<i64> {
self.connection.clone().query(redis::cmd("INCRBY").arg(key).arg(delta)).await
}
pub async fn flushdb(&self) -> Result<()> {
self.connection.clone().run(&redis::cmd("FLUSHDB")).await
}
pub async fn ping(&self) -> Result<bool> {
let reply: String = self.connection.clone().query(&redis::cmd("PING")).await?;
Ok(reply.eq_ignore_ascii_case("pong"))
}
}
impl std::fmt::Debug for RedisClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedisClient").field("backend", &self.describe()).finish()
}
}
#[cfg(test)]
mod client_tests {
use super::*;
async fn client() -> RedisClient {
RedisClient::connect(&RedisConnector::open("redis://127.0.0.1:6379/15").unwrap())
.await
.unwrap()
}
#[tokio::test]
#[ignore = "needs a live Redis"]
async fn a_value_round_trips() {
let client = client().await;
client.flushdb().await.unwrap();
client.set("k", b"v", None).await.unwrap();
assert_eq!(client.get("k").await.unwrap(), Some(b"v".to_vec()));
assert!(client.exists("k").await.unwrap());
assert!(client.del("k").await.unwrap());
assert!(!client.del("k").await.unwrap());
}
#[tokio::test]
#[ignore = "needs a live Redis"]
async fn a_sub_second_ttl_expires_rather_than_never() {
let client = client().await;
client.set("brief", b"v", Some(Duration::from_millis(1))).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!client.exists("brief").await.unwrap());
}
#[tokio::test]
#[ignore = "needs a live Redis"]
async fn set_nx_lets_exactly_one_caller_win() {
let client = client().await;
client.del("lock").await.unwrap();
assert!(client.set_nx("lock", b"mine", Some(Duration::from_secs(5))).await.unwrap());
assert!(!client.set_nx("lock", b"theirs", Some(Duration::from_secs(5))).await.unwrap());
assert_eq!(client.get("lock").await.unwrap(), Some(b"mine".to_vec()));
}
#[tokio::test]
#[ignore = "needs a live Redis"]
async fn incr_by_starts_at_zero_and_accumulates() {
let client = client().await;
client.del("hits").await.unwrap();
assert_eq!(client.incr_by("hits", 1).await.unwrap(), 1);
assert_eq!(client.incr_by("hits", 4).await.unwrap(), 5);
assert_eq!(client.incr_by("hits", -2).await.unwrap(), 3);
}
}