use std::sync::Arc;
use futures::stream::StreamExt;
use tokio::task::JoinHandle;
use crate::error::{OxCacheError, OxCacheResult};
use crate::i18n::messages::{
MSG_DETAIL_PUBSUB_CLIENT_OPEN_FAILED, MSG_DETAIL_PUBSUB_CONNECT_FAILED,
MSG_DETAIL_PUBSUB_CONNECTION_FAILED, MSG_DETAIL_PUBSUB_PUBLISH_FAILED,
MSG_DETAIL_PUBSUB_SUBSCRIBE_FAILED, MSG_DETAIL_PUBSUB_SUBSCRIBE_TASK_ENDED,
MSG_LOG_PUBSUB_HANDLER_PANICKED, MSG_LOG_PUBSUB_MAX_RECONNECT_ATTEMPTS_REACHED,
MSG_LOG_PUBSUB_PAYLOAD_PARSE_FAILED, MSG_LOG_PUBSUB_RECONNECT_FAILED,
MSG_LOG_PUBSUB_RECONNECTING, MSG_LOG_PUBSUB_RESUBSCRIBE_FAILED, t,
};
const DEFAULT_RECONNECT_ATTEMPTS: usize = 5;
const RECONNECT_BACKOFF_BASE_MS: u64 = 500;
const RECONNECT_BACKOFF_MAX_MS: u64 = 5000;
pub struct RedisPubSub {
client: redis::Client,
publish_conn: redis::aio::ConnectionManager,
tasks: std::sync::Mutex<Vec<JoinHandle<()>>>,
max_reconnect_attempts: usize,
}
impl RedisPubSub {
pub async fn new(url: &str) -> OxCacheResult<Self> {
let client = redis::Client::open(url).map_err(|e| {
OxCacheError::BackendError(t(
MSG_DETAIL_PUBSUB_CLIENT_OPEN_FAILED,
&[("err", e.to_string())],
))
})?;
let publish_conn = redis::aio::ConnectionManager::new(client.clone())
.await
.map_err(|e| {
OxCacheError::BackendError(t(
MSG_DETAIL_PUBSUB_CONNECTION_FAILED,
&[("err", e.to_string())],
))
})?;
Ok(Self {
client,
publish_conn,
tasks: std::sync::Mutex::new(Vec::new()),
max_reconnect_attempts: DEFAULT_RECONNECT_ATTEMPTS,
})
}
#[must_use]
pub fn with_reconnect_attempts(mut self, attempts: usize) -> Self {
self.max_reconnect_attempts = attempts;
self
}
pub async fn publish(&self, channel: &str, message: &str) -> OxCacheResult<i64> {
let mut conn = self.publish_conn.clone();
redis::cmd("PUBLISH")
.arg(channel)
.arg(message)
.query_async::<i64>(&mut conn)
.await
.map_err(|e| {
OxCacheError::BackendError(t(
MSG_DETAIL_PUBSUB_PUBLISH_FAILED,
&[("err", e.to_string())],
))
})
}
pub async fn subscribe(
&self,
channel: &str,
handler: Arc<dyn Fn(String) + Send + Sync>,
) -> OxCacheResult<()> {
let channel = channel.to_string();
let client = self.client.clone();
let max_attempts = self.max_reconnect_attempts;
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<OxCacheResult<()>>();
let handle = tokio::spawn(async move {
let mut pubsub = match client.get_async_pubsub().await {
Ok(p) => p,
Err(e) => {
let _ = ready_tx.send(Err(OxCacheError::BackendError(t(
MSG_DETAIL_PUBSUB_CONNECT_FAILED,
&[("err", e.to_string())],
))));
return;
}
};
if let Err(e) = pubsub.subscribe(&channel).await {
let _ = ready_tx.send(Err(OxCacheError::BackendError(t(
MSG_DETAIL_PUBSUB_SUBSCRIBE_FAILED,
&[("err", e.to_string())],
))));
return;
}
let _ = ready_tx.send(Ok(()));
let mut attempt = 0usize;
loop {
{
let mut msg_stream = pubsub.on_message();
while let Some(msg) = msg_stream.next().await {
let payload: Result<String, _> = msg.get_payload();
match payload {
Ok(payload_str) => {
let handler_clone = handler.clone();
let result = std::panic::catch_unwind(
std::panic::AssertUnwindSafe(move || {
handler_clone(payload_str);
}),
);
if result.is_err() {
tracing::warn!(
channel = %channel,
"{}",
t(MSG_LOG_PUBSUB_HANDLER_PANICKED, &[])
);
}
}
Err(e) => {
tracing::warn!(
channel = %channel,
err = %e,
"{}",
t(MSG_LOG_PUBSUB_PAYLOAD_PARSE_FAILED, &[])
);
}
}
}
}
if attempt >= max_attempts {
tracing::error!(
channel = %channel,
attempts = max_attempts,
"{}",
t(MSG_LOG_PUBSUB_MAX_RECONNECT_ATTEMPTS_REACHED, &[])
);
return;
}
attempt += 1;
let backoff = std::time::Duration::from_millis(
(RECONNECT_BACKOFF_BASE_MS * attempt as u64).min(RECONNECT_BACKOFF_MAX_MS),
);
tracing::warn!(
channel = %channel,
attempt,
max_attempts,
backoff = ?backoff,
"{}",
t(MSG_LOG_PUBSUB_RECONNECTING, &[])
);
tokio::time::sleep(backoff).await;
match client.get_async_pubsub().await {
Ok(mut p) => {
if let Err(e) = p.subscribe(&channel).await {
tracing::error!(
channel = %channel,
err = %e,
"{}",
t(MSG_LOG_PUBSUB_RESUBSCRIBE_FAILED, &[])
);
return;
}
pubsub = p;
attempt = 0;
}
Err(e) => {
tracing::error!(
channel = %channel,
err = %e,
"{}",
t(MSG_LOG_PUBSUB_RECONNECT_FAILED, &[])
);
return;
}
}
}
});
let result = match ready_rx.await {
Ok(r) => r,
Err(_) => Err(OxCacheError::BackendError(t(
MSG_DETAIL_PUBSUB_SUBSCRIBE_TASK_ENDED,
&[],
))),
};
if result.is_ok() {
let mut tasks = self.tasks.lock().unwrap();
tasks.retain(|h| !h.is_finished());
tasks.push(handle);
}
result
}
pub fn shutdown(&self) -> usize {
let mut tasks = self.tasks.lock().unwrap();
let stopped = tasks.len();
for handle in tasks.drain(..) {
handle.abort();
}
stopped
}
}
impl Drop for RedisPubSub {
fn drop(&mut self) {
for handle in self.tasks.lock().unwrap().drain(..) {
handle.abort();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn new_with_unreachable_port_fails_fast() {
let result = RedisPubSub::new("redis://127.0.0.1:1").await;
assert!(
result.is_err(),
"unreachable port construction must fail-fast with Err"
);
}
#[tokio::test]
async fn new_rejects_malformed_url() {
assert!(RedisPubSub::new("not-a-redis-url").await.is_err());
}
}