use crate::error::{OxCacheError, OxCacheResult};
use redis::Client;
use redis::aio::ConnectionLike;
use redis::aio::ConnectionManager;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use super::error::map_redis_error;
#[derive(Debug, Clone)]
pub(crate) struct SentinelDiscovery {
sentinels: Vec<String>,
master_name: String,
addr_map: Vec<(String, String)>,
}
impl SentinelDiscovery {
pub(crate) fn parse_urls(connection_string: &str) -> OxCacheResult<Vec<String>> {
let urls: Vec<String> = connection_string
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if urls.is_empty() {
return Err(OxCacheError::InvalidInput(
"Sentinel mode requires at least one sentinel URL in the connection string \
(comma-separated list, e.g. redis://host1:26379,redis://host2:26379)"
.to_string(),
));
}
for url in &urls {
if !(url.starts_with("redis://")
|| url.starts_with("rediss://")
|| url.starts_with("redis+unix://")
|| url.starts_with("unix://"))
{
return Err(OxCacheError::InvalidInput(format!(
"Invalid sentinel URL '{url}': expected redis://, rediss://, \
redis+unix:// or unix:// scheme"
)));
}
}
Ok(urls)
}
pub(crate) fn new(
connection_string: &str,
master_name: &str,
addr_map: Vec<(String, String)>,
) -> OxCacheResult<Self> {
Ok(Self {
sentinels: Self::parse_urls(connection_string)?,
master_name: master_name.to_string(),
addr_map,
})
}
fn data_scheme(&self) -> &'static str {
if self
.sentinels
.first()
.is_some_and(|u| u.starts_with("rediss://"))
{
"rediss"
} else {
"redis"
}
}
fn build_master_url(&self, ip: &str, port: u16, database: Option<u16>) -> String {
let key = format!("{ip}:{port}");
let (host, port) = match self.addr_map.iter().find(|(from, _)| *from == key) {
Some((_, to)) => match to.rsplit_once(':') {
Some((h, p)) => (h.to_string(), p.to_string()),
None => (to.clone(), port.to_string()),
},
None => (ip.to_string(), port.to_string()),
};
let host = if host.contains(':') {
format!("[{host}]")
} else {
host
};
let mut url = format!("{}://{}:{}", self.data_scheme(), host, port);
if let Some(db) = database {
url.push('/');
url.push_str(&db.to_string());
}
url
}
pub(crate) async fn resolve_master(
&self,
connect_timeout: Duration,
database: Option<u16>,
) -> OxCacheResult<String> {
let mut last_err = String::from("no sentinel attempted");
for url in &self.sentinels {
let client = match Client::open(url.as_str()) {
Ok(c) => c,
Err(e) => {
last_err = format!("{url}: {}", map_redis_error(e));
continue;
}
};
let mut conn = match tokio::time::timeout(
connect_timeout,
client.get_multiplexed_async_connection(),
)
.await
{
Ok(Ok(c)) => c,
Ok(Err(e)) => {
last_err = format!("{url}: {e}");
continue;
}
Err(_) => {
last_err = format!("{url}: connect timeout");
continue;
}
};
match redis::cmd("SENTINEL")
.arg("get-master-addr-by-name")
.arg(&self.master_name)
.query_async::<Option<(String, u16)>>(&mut conn)
.await
{
Ok(Some((ip, port))) => {
return Ok(self.build_master_url(&ip, port, database));
}
Ok(None) => {
last_err = format!("{url}: master '{}' unknown", self.master_name);
}
Err(e) => {
last_err = format!("{url}: {e}");
}
}
}
Err(OxCacheError::Connection(format!(
"Sentinel could not resolve master '{}': no sentinel answered successfully \
(last error: {last_err})",
self.master_name
)))
}
}
#[derive(Clone)]
pub(crate) struct SentinelConnection {
shared: Arc<RwLock<ConnectionManager>>,
discovery: Arc<SentinelDiscovery>,
connection_timeout: Duration,
database: Option<u16>,
}
impl SentinelConnection {
pub(crate) async fn connect(
discovery: SentinelDiscovery,
connection_timeout: Duration,
database: Option<u16>,
) -> OxCacheResult<(Self, Client)> {
let discovery = Arc::new(discovery);
let master_url = discovery
.resolve_master(connection_timeout, database)
.await?;
let client = Client::open(master_url.as_str()).map_err(map_redis_error)?;
let manager = Self::connect_manager(&client, connection_timeout).await?;
Ok((
Self {
shared: Arc::new(RwLock::new(manager)),
discovery,
connection_timeout,
database,
},
client,
))
}
async fn connect_manager(
client: &Client,
connection_timeout: Duration,
) -> OxCacheResult<ConnectionManager> {
match tokio::time::timeout(connection_timeout, client.get_connection_manager()).await {
Ok(Ok(mgr)) => Ok(mgr),
Ok(Err(e)) => Err(OxCacheError::Connection(format!(
"Failed to connect to the master resolved by sentinel: {e}"
))),
Err(_) => Err(OxCacheError::Connection(
"Connection timeout - Redis master resolved by sentinel unavailable".to_string(),
)),
}
}
pub(crate) fn db(&self) -> i64 {
self.database.map(i64::from).unwrap_or(0)
}
async fn current(&self) -> ConnectionManager {
self.shared.read().await.clone()
}
async fn rediscover(&self, trigger: &str) -> Result<(), redis::RedisError> {
let master_url = self
.discovery
.resolve_master(self.connection_timeout, self.database)
.await;
let manager = match master_url {
Ok(url) => match Client::open(url.as_str()) {
Ok(client) => Self::connect_manager(&client, self.connection_timeout).await,
Err(e) => Err(map_redis_error(e)),
},
Err(e) => Err(e),
};
match manager {
Ok(new_manager) => {
*self.shared.write().await = new_manager;
Ok(())
}
Err(e) => Err(redis::make_extension_error(
"OXCACHE_SENTINEL".to_string(),
Some(format!("master rediscovery after '{trigger}' failed: {e}")),
)),
}
}
pub(crate) async fn exec_cmd(&self, cmd: &redis::Cmd) -> redis::RedisResult<redis::Value> {
let mut conn = self.current().await;
match conn.req_packed_command(cmd).await {
Ok(value) => Ok(value),
Err(e) if Self::is_recoverable(&e) => {
self.rediscover(&e.to_string()).await?;
let mut conn = self.current().await;
conn.req_packed_command(cmd).await
}
Err(e) => Err(e),
}
}
pub(crate) async fn exec_pipeline(
&self,
pipeline: &redis::Pipeline,
offset: usize,
count: usize,
) -> redis::RedisResult<Vec<redis::Value>> {
let mut conn = self.current().await;
match conn.req_packed_commands(pipeline, offset, count).await {
Ok(values) => Ok(values),
Err(e) if Self::is_recoverable(&e) => {
self.rediscover(&e.to_string()).await?;
let mut conn = self.current().await;
conn.req_packed_commands(pipeline, offset, count).await
}
Err(e) => Err(e),
}
}
fn is_recoverable(err: &redis::RedisError) -> bool {
use redis::ErrorKind as K;
use redis::ServerErrorKind as S;
if err.is_connection_dropped() || err.is_io_error() || err.is_timeout() {
return true;
}
matches!(
err.kind(),
K::Server(S::ReadOnly)
| K::Server(S::MasterDown)
| K::Server(S::ClusterDown)
| K::Server(S::TryAgain)
| K::Server(S::BusyLoading)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_urls_splits_and_trims() {
let urls = SentinelDiscovery::parse_urls(
"redis://127.0.0.1:26382 , redis://127.0.0.1:26383,,redis://127.0.0.1:26384",
)
.unwrap();
assert_eq!(urls.len(), 3);
assert_eq!(urls[0], "redis://127.0.0.1:26382");
}
#[test]
fn test_parse_urls_rejects_empty() {
assert!(SentinelDiscovery::parse_urls(" , ").is_err());
}
#[test]
fn test_parse_urls_rejects_invalid() {
assert!(SentinelDiscovery::parse_urls("not-a-url").is_err());
}
#[test]
fn test_build_master_url_applies_addr_map_and_db() {
let discovery = SentinelDiscovery::new(
"redis://127.0.0.1:26382",
"mymaster",
vec![("172.26.0.2:6379".to_string(), "127.0.0.1:16380".to_string())],
)
.unwrap();
assert_eq!(
discovery.build_master_url("172.26.0.2", 6379, Some(3)),
"redis://127.0.0.1:16380/3"
);
assert_eq!(
discovery.build_master_url("172.26.0.3", 6379, None),
"redis://172.26.0.3:6379"
);
}
#[test]
fn test_build_master_url_follows_sentinel_tls_scheme() {
let discovery =
SentinelDiscovery::new("rediss://127.0.0.1:26382", "mymaster", vec![]).unwrap();
assert_eq!(
discovery.build_master_url("172.26.0.2", 6379, None),
"rediss://172.26.0.2:6379"
);
}
#[test]
fn test_build_master_url_brackets_ipv6() {
let discovery =
SentinelDiscovery::new("redis://127.0.0.1:26382", "mymaster", vec![]).unwrap();
assert_eq!(
discovery.build_master_url("::1", 6379, None),
"redis://[::1]:6379"
);
}
#[test]
fn test_is_recoverable_classifies_failover_errors() {
let readonly = redis::RedisError::from((
redis::ErrorKind::Server(redis::ServerErrorKind::ReadOnly),
"READONLY",
));
assert!(SentinelConnection::is_recoverable(&readonly));
let dropped = redis::RedisError::from((redis::ErrorKind::Io, "connection dropped"));
assert!(SentinelConnection::is_recoverable(&dropped));
let noperm = redis::RedisError::from((
redis::ErrorKind::Server(redis::ServerErrorKind::NoPerm),
"NOPERM",
));
assert!(!SentinelConnection::is_recoverable(&noperm));
}
}