use std::time::Duration;
use async_trait::async_trait;
use deadpool_redis::sentinel::{Config, SentinelNodeConnectionInfo, SentinelServerType, TlsMode};
use deadpool_redis::{Runtime, Timeouts};
use crate::CacheError;
use crate::redis_cache::{RedisOptions, RedisPoolStatus, conn_err, to_pool_status};
use crate::redis_ha::{HaCacheBackend, impl_cache_for_ha};
pub struct RedisSentinelCache {
backend: SentinelBackend,
}
struct SentinelBackend {
pool: deadpool_redis::sentinel::Pool,
response_timeout: Duration,
}
#[async_trait]
impl HaCacheBackend for SentinelBackend {
type Conn = deadpool_redis::sentinel::Connection;
async fn checkout(&self) -> Result<Self::Conn, CacheError> {
let mut conn = self.pool.get().await.map_err(conn_err)?;
conn.set_response_timeout(self.response_timeout);
Ok(conn)
}
fn status(&self) -> deadpool_redis::Status {
self.pool.status()
}
}
impl RedisSentinelCache {
pub async fn new(
master_name: &str,
sentinels: &[&str],
max_size: usize,
) -> Result<Self, CacheError> {
Self::with_options(
master_name,
sentinels,
RedisOptions { max_size, ..RedisOptions::default() },
)
.await
}
pub async fn with_options(
master_name: &str,
sentinels: &[&str],
options: RedisOptions,
) -> Result<Self, CacheError> {
let pool = connect_pool(master_name, sentinels, options.max_size, options.response_timeout)
.await?;
Ok(Self { backend: SentinelBackend { pool, response_timeout: options.response_timeout } })
}
pub fn status(&self) -> deadpool_redis::Status {
self.backend.status()
}
pub fn pool_status(&self) -> RedisPoolStatus {
to_pool_status(self.status())
}
}
async fn connect_pool(
master_name: &str,
sentinels: &[&str],
max_size: usize,
response_timeout: Duration,
) -> Result<deadpool_redis::sentinel::Pool, CacheError> {
let urls = sentinels.iter().map(|url| url.to_string()).collect::<Vec<_>>();
let pool = Config::from_urls(urls, master_name.to_string(), SentinelServerType::Master)
.with_node_connection_info(master_node_info(sentinels))
.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)
}
fn master_node_info(sentinels: &[&str]) -> Option<SentinelNodeConnectionInfo> {
let info = sentinels.first()?.parse::<redis::ConnectionInfo>().ok()?;
let tls_mode = match info.addr() {
redis::ConnectionAddr::TcpTls { insecure, .. } => {
Some(if *insecure { TlsMode::Insecure } else { TlsMode::Secure })
}
_ => None,
};
Some(SentinelNodeConnectionInfo {
tls_mode,
redis_connection_info: Some(info.redis_settings().clone().into()),
})
}
impl_cache_for_ha!(RedisSentinelCache);
#[cfg(test)]
mod tests {
use super::*;
fn config() -> Config {
Config::from_urls(
vec!["redis://127.0.0.1:26379".to_string()],
"mymaster".to_string(),
SentinelServerType::Master,
)
}
#[test]
fn config_keeps_the_master_name_and_sentinel_urls() {
let config = config();
assert_eq!(config.master_name, "mymaster");
assert_eq!(config.urls.as_deref(), Some(&["redis://127.0.0.1:26379".to_string()][..]));
}
#[test]
fn the_first_sentinel_url_supplies_the_master_settings() {
let info = master_node_info(&["rediss://user:pass@127.0.0.1:26379/2"])
.expect("a Sentinel URL yields master connection info");
assert!(matches!(info.tls_mode, Some(TlsMode::Secure)));
let redis = info.redis_connection_info.expect("redis settings");
assert_eq!(redis.username.as_deref(), Some("user"));
assert_eq!(redis.password.as_deref(), Some("pass"));
assert_eq!(redis.db, 2);
}
#[test]
fn a_plain_sentinel_url_keeps_the_master_plaintext() {
let info = master_node_info(&["redis://127.0.0.1:26379"]).expect("connection info");
assert!(info.tls_mode.is_none());
assert!(master_node_info(&[]).is_none());
}
#[test]
fn pool_status_reports_the_configured_size_before_any_connection() {
let pool =
config().builder().unwrap().max_size(3).runtime(Runtime::Tokio1).build().unwrap();
let cache = RedisSentinelCache {
backend: SentinelBackend { 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);
}
#[tokio::test]
async fn an_empty_sentinel_list_is_a_connection_error() {
match RedisSentinelCache::new("mymaster", &[], 1).await {
Err(CacheError::ConnectionError(_)) => {}
Err(other) => panic!("expected a connection error, got {other:?}"),
Ok(_) => panic!("an empty sentinel list must not build"),
}
}
}