use crate::infrastructure::RedisClient;
use crate::session::{Session, SessionStore};
use crate::utils::ChainError;
use async_trait::async_trait;
use redis::RedisError;
use serde_json;
use std::sync::Arc;
use tracing::{debug, error, info, instrument};
use uuid::Uuid;
pub struct InRedisSessionStore {
client: Arc<RedisClient>,
key_prefix: String,
session_ttl: u64,
}
const SAVE_CAS_SCRIPT: &str = r#"
local cur = redis.call('GET', KEYS[1])
if not cur then
return -1
end
local ver = redis.call('GET', KEYS[2]) or '0'
if ver ~= ARGV[2] then
return -2
end
redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[4]))
redis.call('SET', KEYS[2], ARGV[3], 'EX', tonumber(ARGV[4]))
return 1
"#;
fn map_cas_result(code: i64, id: Uuid, expected_version: u64) -> Result<(), ChainError> {
match code {
1 => Ok(()),
-1 => Err(ChainError::NotFound(format!(
"Session with id {} not found",
id
))),
-2 => Err(ChainError::Conflict(format!(
"Session {} was modified concurrently (expected version {})",
id, expected_version
))),
other => Err(ChainError::Internal(format!(
"Unexpected CAS result code {} for session {}",
other, id
))),
}
}
impl InRedisSessionStore {
#[instrument(skip(client), level = "debug")]
pub fn new(
client: Arc<RedisClient>,
key_prefix: Option<String>,
session_ttl: Option<u64>,
) -> Self {
let prefix = key_prefix.unwrap_or_else(|| "session:".to_string());
let ttl = session_ttl.unwrap_or(1800);
info!(
key_prefix = %prefix,
session_ttl = ttl,
"Created new Redis session store"
);
Self {
client,
key_prefix: prefix,
session_ttl: ttl,
}
}
fn session_key(&self, id: Uuid) -> String {
format!("{}{}", self.key_prefix, id)
}
fn version_key(&self, id: Uuid) -> String {
format!("{}:ver", self.session_key(id))
}
fn map_redis_error(err: RedisError) -> ChainError {
ChainError::Internal(format!("Redis error: {}", err))
}
}
#[async_trait]
impl SessionStore for InRedisSessionStore {
#[instrument(skip(self), level = "debug")]
async fn get(&self, id: Uuid) -> Result<Session, ChainError> {
let key = self.session_key(id);
debug!(session_id = %id, key = %key, "Getting session from Redis");
match self.client.get::<String>(&key).await {
Ok(Some(json_str)) => {
match serde_json::from_str::<Session>(&json_str) {
Ok(session) => {
debug!(session_id = %id, "Session retrieved successfully");
Ok(session)
}
Err(e) => {
error!(session_id = %id, error = %e, "Failed to deserialize session");
Err(ChainError::Internal(format!(
"Failed to deserialize session: {}",
e
)))
}
}
}
Ok(None) => {
debug!(session_id = %id, "Session not found in Redis");
Err(ChainError::NotFound(format!(
"Session with id {} not found",
id
)))
}
Err(e) => {
error!(session_id = %id, error = %e, "Redis error while getting session");
Err(Self::map_redis_error(e))
}
}
}
#[instrument(skip(self, session), level = "debug")]
async fn create(&self, session: Session) -> Result<(), ChainError> {
let key = self.session_key(session.id);
let version_key = self.version_key(session.id);
debug!(session_id = %session.id, key = %key, "Creating session in Redis");
let json_str = match serde_json::to_string(&session) {
Ok(s) => s,
Err(e) => {
error!(session_id = %session.id, error = %e, "Failed to serialize session");
return Err(ChainError::Internal(format!(
"Failed to serialize session: {}",
e
)));
}
};
match self
.client
.set_nx(&key, json_str, Some(self.session_ttl))
.await
{
Ok(true) => {
self.client
.set(
&version_key,
session.version.to_string(),
Some(self.session_ttl),
)
.await
.map_err(Self::map_redis_error)?;
debug!(session_id = %session.id, "Session created successfully");
Ok(())
}
Ok(false) => {
error!(session_id = %session.id, "Session id already exists in Redis");
Err(ChainError::AlreadyExists(format!(
"Session with id {} already exists",
session.id
)))
}
Err(e) => {
error!(session_id = %session.id, error = %e, "Redis error while creating session");
Err(Self::map_redis_error(e))
}
}
}
#[instrument(skip(self, session), level = "debug")]
async fn save(&self, session: Session) -> Result<(), ChainError> {
let key = self.session_key(session.id);
let version_key = self.version_key(session.id);
debug!(session_id = %session.id, key = %key, "Saving session to Redis");
let json_str = match serde_json::to_string(&session) {
Ok(s) => s,
Err(e) => {
error!(session_id = %session.id, error = %e, "Failed to serialize session");
return Err(ChainError::Internal(format!(
"Failed to serialize session: {}",
e
)));
}
};
self.client
.set(&key, json_str, Some(self.session_ttl))
.await
.map_err(Self::map_redis_error)?;
self.client
.set(
&version_key,
session.version.to_string(),
Some(self.session_ttl),
)
.await
.map_err(Self::map_redis_error)?;
debug!(session_id = %session.id, "Session saved successfully");
Ok(())
}
#[instrument(skip(self, session), level = "debug")]
async fn save_cas(&self, session: Session, expected_version: u64) -> Result<(), ChainError> {
let id = session.id;
let key = self.session_key(id);
let version_key = self.version_key(id);
debug!(session_id = %id, key = %key, expected_version, "CAS-saving session to Redis");
let json_str = match serde_json::to_string(&session) {
Ok(s) => s,
Err(e) => {
error!(session_id = %id, error = %e, "Failed to serialize session");
return Err(ChainError::Internal(format!(
"Failed to serialize session: {}",
e
)));
}
};
let mut conn = self.client.connection_manager();
let code: i64 = redis::Script::new(SAVE_CAS_SCRIPT)
.key(&key)
.key(&version_key)
.arg(json_str)
.arg(expected_version.to_string())
.arg(session.version.to_string())
.arg(self.session_ttl)
.invoke_async(&mut conn)
.await
.map_err(Self::map_redis_error)?;
map_cas_result(code, id, expected_version).inspect_err(|e| {
debug!(session_id = %id, error = %e, "CAS save rejected");
})
}
#[instrument(skip(self), level = "debug")]
async fn delete(&self, id: Uuid) -> Result<bool, ChainError> {
let key = self.session_key(id);
let version_key = self.version_key(id);
debug!(session_id = %id, key = %key, "Deleting session from Redis");
let deleted = self
.client
.delete(&key)
.await
.map_err(Self::map_redis_error)?;
self.client
.delete(&version_key)
.await
.map_err(Self::map_redis_error)?;
debug!(session_id = %id, deleted = deleted, "Session delete result");
Ok(deleted)
}
#[instrument(skip(self), level = "debug")]
async fn cleanup(&self) -> Result<usize, ChainError> {
debug!("Cleaning up expired sessions from Redis");
info!("Redis handles automatic expiration of session keys");
Ok(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::RedisConfig;
use crate::session::{SessionState, SimulationMethod, SimulationParameters};
use optionstratlib::utils::TimeFrame;
use positive::{Positive, pos_or_panic};
use rust_decimal::Decimal;
use std::sync::Mutex;
use std::time::SystemTime;
use uuid::Uuid;
struct TestInRedisSessionStore {
sessions: Mutex<std::collections::HashMap<String, String>>,
key_prefix: String,
session_ttl: u64,
}
impl TestInRedisSessionStore {
fn new(key_prefix: Option<String>, session_ttl: Option<u64>) -> Self {
let prefix = key_prefix.unwrap_or_else(|| "session:".to_string());
let ttl = session_ttl.unwrap_or(1800);
Self {
sessions: Mutex::new(std::collections::HashMap::new()),
key_prefix: prefix,
session_ttl: ttl,
}
}
fn session_key(&self, id: Uuid) -> String {
format!("{}{}", self.key_prefix, id)
}
fn version_key(&self, id: Uuid) -> String {
format!("{}:ver", self.session_key(id))
}
fn get_store_size(&self) -> usize {
self.sessions
.lock()
.unwrap()
.keys()
.filter(|k| !k.ends_with(":ver"))
.count()
}
}
#[async_trait]
impl SessionStore for TestInRedisSessionStore {
async fn get(&self, id: Uuid) -> Result<Session, ChainError> {
let key = self.session_key(id);
let sessions = self.sessions.lock().unwrap();
match sessions.get(&key) {
Some(json_str) => {
match serde_json::from_str::<Session>(json_str) {
Ok(session) => Ok(session),
Err(e) => Err(ChainError::Internal(format!(
"Failed to deserialize session: {}",
e
))),
}
}
None => Err(ChainError::NotFound(format!(
"Session with id {} not found",
id
))),
}
}
async fn create(&self, session: Session) -> Result<(), ChainError> {
let key = self.session_key(session.id);
let version_key = self.version_key(session.id);
let json_str = match serde_json::to_string(&session) {
Ok(s) => s,
Err(e) => {
return Err(ChainError::Internal(format!(
"Failed to serialize session: {}",
e
)));
}
};
let mut sessions = self.sessions.lock().unwrap();
if sessions.contains_key(&key) {
return Err(ChainError::AlreadyExists(format!(
"Session with id {} already exists",
session.id
)));
}
sessions.insert(key, json_str);
sessions.insert(version_key, session.version.to_string());
Ok(())
}
async fn save(&self, session: Session) -> Result<(), ChainError> {
let key = self.session_key(session.id);
let version_key = self.version_key(session.id);
let json_str = match serde_json::to_string(&session) {
Ok(s) => s,
Err(e) => {
return Err(ChainError::Internal(format!(
"Failed to serialize session: {}",
e
)));
}
};
let mut sessions = self.sessions.lock().unwrap();
sessions.insert(key, json_str);
sessions.insert(version_key, session.version.to_string());
Ok(())
}
async fn save_cas(
&self,
session: Session,
expected_version: u64,
) -> Result<(), ChainError> {
let key = self.session_key(session.id);
let version_key = self.version_key(session.id);
let json_str = match serde_json::to_string(&session) {
Ok(s) => s,
Err(e) => {
return Err(ChainError::Internal(format!(
"Failed to serialize session: {}",
e
)));
}
};
let mut sessions = self.sessions.lock().unwrap();
if !sessions.contains_key(&key) {
return Err(ChainError::NotFound(format!(
"Session with id {} not found",
session.id
)));
}
let stored_version: u64 = sessions
.get(&version_key)
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
if stored_version != expected_version {
return Err(ChainError::Conflict(format!(
"Session {} was modified concurrently (expected version {}, found {})",
session.id, expected_version, stored_version
)));
}
sessions.insert(key, json_str);
sessions.insert(version_key, session.version.to_string());
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<bool, ChainError> {
let key = self.session_key(id);
let version_key = self.version_key(id);
let mut sessions = self.sessions.lock().unwrap();
let removed = sessions.remove(&key).is_some();
sessions.remove(&version_key);
Ok(removed)
}
async fn cleanup(&self) -> Result<usize, ChainError> {
Ok(0)
}
}
fn create_test_session() -> Session {
let params = SimulationParameters {
symbol: "TEST".to_string(),
steps: 10,
initial_price: pos_or_panic!(100.0),
days_to_expiration: pos_or_panic!(30.0),
volatility: pos_or_panic!(0.2),
risk_free_rate: Decimal::new(0, 0),
dividend_yield: Positive::ZERO,
method: SimulationMethod::GeometricBrownian {
dt: pos_or_panic!(1.0),
drift: Decimal::new(0, 0),
volatility: pos_or_panic!(0.2),
},
time_frame: TimeFrame::Day,
chain_size: Some(5),
strike_interval: Some(pos_or_panic!(5.0)),
skew_slope: None,
smile_curve: None,
spread: None,
seed: None,
};
Session {
id: Uuid::new_v4(),
created_at: SystemTime::now(),
updated_at: SystemTime::now(),
current_step: 0,
total_steps: 10,
parameters: params,
state: SessionState::Initialized,
version: 0,
}
}
#[test]
fn test_new_with_defaults() {
let store = TestInRedisSessionStore::new(None, None);
assert_eq!(store.key_prefix, "session:");
assert_eq!(store.session_ttl, 1800);
}
#[test]
fn test_new_with_custom_values() {
let store = TestInRedisSessionStore::new(Some("custom_prefix:".to_string()), Some(3600));
assert_eq!(store.key_prefix, "custom_prefix:");
assert_eq!(store.session_ttl, 3600);
}
#[test]
fn test_session_key_format() {
let store = TestInRedisSessionStore::new(Some("test:".to_string()), None);
let id = Uuid::parse_str("f47ac10b-58cc-4372-a567-0e02b2c3d479").unwrap();
let key = store.session_key(id);
assert_eq!(key, "test:f47ac10b-58cc-4372-a567-0e02b2c3d479");
}
#[tokio::test]
async fn test_save_and_get_session() {
let store = TestInRedisSessionStore::new(None, None);
let session = create_test_session();
let session_id = session.id;
let save_result = store.save(session.clone()).await;
assert!(save_result.is_ok());
assert_eq!(store.get_store_size(), 1);
let get_result = store.get(session_id).await;
assert!(get_result.is_ok());
let retrieved_session = get_result.unwrap();
assert_eq!(retrieved_session.id, session_id);
assert_eq!(retrieved_session.state, SessionState::Initialized);
assert_eq!(retrieved_session.current_step, 0);
assert_eq!(retrieved_session.total_steps, 10);
}
#[tokio::test]
async fn test_create_new_session() {
let store = TestInRedisSessionStore::new(None, None);
let session = create_test_session();
let session_id = session.id;
assert!(store.create(session).await.is_ok());
assert_eq!(store.get_store_size(), 1);
assert!(store.get(session_id).await.is_ok());
}
#[tokio::test]
async fn test_create_duplicate_returns_already_exists() {
let store = TestInRedisSessionStore::new(None, None);
let session = create_test_session();
assert!(store.create(session.clone()).await.is_ok());
match store.create(session).await {
Err(ChainError::AlreadyExists(msg)) => {
assert!(msg.contains("already exists"));
}
other => panic!("Expected AlreadyExists error, got {:?}", other),
}
assert_eq!(store.get_store_size(), 1);
}
#[tokio::test]
async fn test_save_still_updates_after_create() {
let store = TestInRedisSessionStore::new(None, None);
let mut session = create_test_session();
let session_id = session.id;
assert!(store.create(session.clone()).await.is_ok());
session.current_step = 3;
session.state = SessionState::InProgress;
assert!(store.save(session).await.is_ok());
let updated = store.get(session_id).await.unwrap();
assert_eq!(updated.current_step, 3);
assert_eq!(updated.state, SessionState::InProgress);
}
#[tokio::test]
async fn test_get_non_existent_session() {
let store = TestInRedisSessionStore::new(None, None);
let non_existent_id = Uuid::new_v4();
let result = store.get(non_existent_id).await;
assert!(result.is_err());
match result {
Err(ChainError::NotFound(msg)) => {
assert!(msg.contains(&non_existent_id.to_string()));
}
_ => panic!("Expected NotFound error"),
}
}
#[tokio::test]
async fn test_delete_existing_session() {
let store = TestInRedisSessionStore::new(None, None);
let session = create_test_session();
let session_id = session.id;
store.save(session).await.unwrap();
assert_eq!(store.get_store_size(), 1);
let delete_result = store.delete(session_id).await;
assert!(delete_result.is_ok());
assert!(delete_result.unwrap());
assert_eq!(store.get_store_size(), 0);
let get_result = store.get(session_id).await;
assert!(get_result.is_err());
}
#[tokio::test]
async fn test_delete_non_existent_session() {
let store = TestInRedisSessionStore::new(None, None);
let non_existent_id = Uuid::new_v4();
let result = store.delete(non_existent_id).await;
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[tokio::test]
async fn test_cleanup() {
let store = TestInRedisSessionStore::new(None, None);
let result = store.cleanup().await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[tokio::test]
async fn test_update_existing_session() {
let store = TestInRedisSessionStore::new(None, None);
let mut session = create_test_session();
let session_id = session.id;
store.save(session.clone()).await.unwrap();
session.current_step = 5;
session.state = SessionState::InProgress;
let update_result = store.save(session.clone()).await;
assert!(update_result.is_ok());
let get_result = store.get(session_id).await;
assert!(get_result.is_ok());
let updated_session = get_result.unwrap();
assert_eq!(updated_session.current_step, 5);
assert_eq!(updated_session.state, SessionState::InProgress);
}
#[test]
fn test_save_cas_script_encodes_the_contract() {
assert!(SAVE_CAS_SCRIPT.contains("redis.call('GET', KEYS[1])")); assert!(SAVE_CAS_SCRIPT.contains("redis.call('GET', KEYS[2])")); assert!(SAVE_CAS_SCRIPT.contains("or '0'")); assert!(SAVE_CAS_SCRIPT.contains("ver ~= ARGV[2]")); assert!(!SAVE_CAS_SCRIPT.contains("cjson")); assert!(SAVE_CAS_SCRIPT.contains("return -1")); assert!(SAVE_CAS_SCRIPT.contains("return -2")); assert!(SAVE_CAS_SCRIPT.contains("return 1")); assert!(SAVE_CAS_SCRIPT.contains("'EX'")); }
#[test]
fn test_map_cas_result_maps_each_code() {
let id = Uuid::new_v4();
assert!(map_cas_result(1, id, 0).is_ok());
match map_cas_result(-1, id, 0) {
Err(ChainError::NotFound(_)) => {}
other => panic!("expected NotFound, got {other:?}"),
}
match map_cas_result(-2, id, 4) {
Err(ChainError::Conflict(msg)) => assert!(msg.contains("version 4")),
other => panic!("expected Conflict, got {other:?}"),
}
match map_cas_result(42, id, 0) {
Err(ChainError::Internal(_)) => {}
other => panic!("expected Internal for an unexpected code, got {other:?}"),
}
}
#[tokio::test]
async fn test_test_double_save_cas_behaves_like_lua() {
let store = TestInRedisSessionStore::new(None, None);
let mut session = create_test_session();
let id = session.id;
match store.save_cas(session.clone(), 0).await {
Err(ChainError::NotFound(_)) => {}
other => panic!("expected NotFound, got {other:?}"),
}
store.create(session.clone()).await.unwrap();
session.current_step = 2;
assert!(session.bump_version().is_ok());
assert!(store.save_cas(session.clone(), 0).await.is_ok());
assert_eq!(store.get(id).await.unwrap().version, 1);
let mut stale = session.clone();
stale.current_step = 77;
assert!(stale.bump_version().is_ok());
match store.save_cas(stale, 0).await {
Err(ChainError::Conflict(_)) => {}
other => panic!("expected Conflict, got {other:?}"),
}
let stored = store.get(id).await.unwrap();
assert_eq!(stored.current_step, 2);
assert_eq!(stored.version, 1);
}
#[tokio::test]
#[ignore = "requires live Redis on localhost:6379; run with -- --ignored"]
async fn test_save_cas_is_atomic_against_live_redis() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: None,
password: None,
database: 0,
timeout: 5,
connect_timeout: 5,
};
let client = Arc::new(
RedisClient::new(config)
.await
.expect("connect to Redis on localhost:6379"),
);
let prefix = format!("test-cas:{}:", Uuid::new_v4());
let store = InRedisSessionStore::new(client, Some(prefix), Some(60));
let base = create_test_session();
let id = base.id;
store
.create(base.clone())
.await
.expect("create session in Redis");
let mut a = base.clone();
a.current_step = 1;
a.bump_version().expect("bump writer a");
let mut b = base.clone();
b.current_step = 2;
b.bump_version().expect("bump writer b");
let (ra, rb) = tokio::join!(store.save_cas(a, 0), store.save_cas(b, 0));
let results = [ra, rb];
let ok_count = results.iter().filter(|r| r.is_ok()).count();
let conflict_count = results
.iter()
.filter(|r| matches!(r, Err(ChainError::Conflict(_))))
.count();
assert_eq!(ok_count, 1, "exactly one concurrent CAS must commit");
assert_eq!(
conflict_count, 1,
"exactly one concurrent CAS must conflict"
);
let stored = store.get(id).await.expect("load stored session");
assert_eq!(stored.version, 1, "one winning advance leaves version at 1");
let mut stale = base.clone();
stale.current_step = 3;
stale.bump_version().expect("bump stale writer");
match store.save_cas(stale, 0).await {
Err(ChainError::Conflict(_)) => {}
other => panic!("expected Conflict for a stale writer, got {other:?}"),
}
assert!(store.delete(id).await.expect("cleanup session"));
}
struct TestErrorInRedisSessionStore {
}
#[async_trait]
impl SessionStore for TestErrorInRedisSessionStore {
async fn get(&self, id: Uuid) -> Result<Session, ChainError> {
Err(ChainError::Internal(format!(
"Simulated error for session {}",
id
)))
}
async fn create(&self, session: Session) -> Result<(), ChainError> {
Err(ChainError::Internal(format!(
"Simulated error creating session {}",
session.id
)))
}
async fn save(&self, session: Session) -> Result<(), ChainError> {
Err(ChainError::Internal(format!(
"Simulated error saving session {}",
session.id
)))
}
async fn save_cas(
&self,
session: Session,
_expected_version: u64,
) -> Result<(), ChainError> {
Err(ChainError::Internal(format!(
"Simulated error CAS-saving session {}",
session.id
)))
}
async fn delete(&self, id: Uuid) -> Result<bool, ChainError> {
Err(ChainError::Internal(format!(
"Simulated error deleting session {}",
id
)))
}
async fn cleanup(&self) -> Result<usize, ChainError> {
Err(ChainError::Internal(
"Simulated error during cleanup".to_string(),
))
}
}
#[tokio::test]
async fn test_error_handling() {
let error_store = TestErrorInRedisSessionStore {};
let session = create_test_session();
let session_id = session.id;
let create_result = error_store.create(session.clone()).await;
assert!(create_result.is_err());
let save_result = error_store.save(session.clone()).await;
assert!(save_result.is_err());
let save_cas_result = error_store.save_cas(session, 0).await;
assert!(save_cas_result.is_err());
let get_result = error_store.get(session_id).await;
assert!(get_result.is_err());
let delete_result = error_store.delete(session_id).await;
assert!(delete_result.is_err());
let cleanup_result = error_store.cleanup().await;
assert!(cleanup_result.is_err());
}
}