use std::fmt::Write as _;
use std::time::Duration;
use apl_cpex::{SessionStore, SessionStoreError};
use async_trait::async_trait;
use deadpool_redis::{Connection, Pool};
use redis::AsyncCommands;
use sha2::{Digest, Sha256};
use crate::config::ValkeyConfig;
use crate::connection::build_pool;
use crate::error::BuildError;
pub struct ValkeySessionStore {
pool: Pool,
key_prefix: String,
ttl_seconds: Option<u64>,
connect_timeout: Duration,
command_timeout: Duration,
}
impl ValkeySessionStore {
pub fn from_config(cfg: &ValkeyConfig) -> Result<Self, BuildError> {
Ok(Self {
pool: build_pool(cfg)?,
key_prefix: cfg.key_prefix.clone(),
ttl_seconds: cfg.ttl_seconds,
connect_timeout: Duration::from_millis(cfg.connect_timeout_ms),
command_timeout: Duration::from_millis(cfg.command_timeout_ms),
})
}
fn key(&self, session_id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(session_id.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ = write!(hex, "{byte:02x}");
}
format!("{}:{}", self.key_prefix, hex)
}
async fn conn(&self) -> Result<Connection, SessionStoreError> {
match tokio::time::timeout(self.connect_timeout, self.pool.get()).await {
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => Err(backend(e)),
Err(_) => Err(SessionStoreError::Backend(
"valkey connection acquire timed out".to_string(),
)),
}
}
}
fn backend(e: impl std::fmt::Display) -> SessionStoreError {
SessionStoreError::Backend(e.to_string())
}
#[async_trait]
impl SessionStore for ValkeySessionStore {
async fn load_labels(&self, session_id: &str) -> Result<Vec<String>, SessionStoreError> {
let key = self.key(session_id);
let mut conn = self.conn().await?;
let labels: Vec<String> =
match tokio::time::timeout(self.command_timeout, conn.smembers(&key)).await {
Ok(res) => res.map_err(backend)?,
Err(_) => {
return Err(SessionStoreError::Backend(
"valkey SMEMBERS timed out".to_string(),
))
},
};
if let Some(ttl) = self.ttl_seconds {
let refresh: Result<bool, _> =
match tokio::time::timeout(self.command_timeout, conn.expire(&key, ttl as i64))
.await
{
Ok(res) => res,
Err(_) => Ok(false), };
if let Err(e) = refresh {
tracing::warn!(
alarm = "session_store_ttl_refresh_failed",
error = %e,
"valkey TTL refresh on load failed; returning read labels (fail-open)"
);
}
}
Ok(labels)
}
async fn append_labels(
&self,
session_id: &str,
labels: &[String],
) -> Result<(), SessionStoreError> {
if labels.is_empty() {
return Ok(());
}
let key = self.key(session_id);
let mut conn = self.conn().await?;
let mut pipe = redis::pipe();
pipe.atomic();
pipe.sadd(&key, labels).ignore();
if let Some(ttl) = self.ttl_seconds {
pipe.expire(&key, ttl as i64).ignore();
}
match tokio::time::timeout(self.command_timeout, pipe.query_async::<()>(&mut conn)).await {
Ok(res) => res.map_err(backend)?,
Err(_) => {
return Err(SessionStoreError::Backend(
"valkey append (SADD+EXPIRE) timed out".to_string(),
))
},
}
Ok(())
}
}