use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::sync::{Arc, Once, RwLock, Weak};
use std::time::{Duration, Instant};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct ReplSession {
pub id: String,
pub db_name: String,
pub owner: String,
pub variables: HashMap<String, JsonValue>,
pub history: Vec<String>,
pub created_at: Instant,
pub last_accessed: Instant,
}
impl ReplSession {
pub fn new(db_name: String) -> Self {
Self::new_for(db_name, String::new())
}
pub fn new_for(db_name: String, owner: String) -> Self {
let now = Instant::now();
Self {
id: Uuid::now_v7().to_string(),
db_name,
owner,
variables: HashMap::new(),
history: Vec::new(),
created_at: now,
last_accessed: now,
}
}
pub fn touch(&mut self) {
self.last_accessed = Instant::now();
}
pub fn add_to_history(&mut self, code: String) {
self.history.push(code);
if self.history.len() > 100 {
self.history.remove(0);
}
let mut total: usize = self.history.iter().map(|c| c.len()).sum();
while total > MAX_HISTORY_BYTES && self.history.len() > 1 {
total -= self.history.remove(0).len();
}
}
pub fn is_expired(&self, timeout: Duration) -> bool {
self.last_accessed.elapsed() > timeout
}
}
const MAX_HISTORY_BYTES: usize = 1024 * 1024;
const CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|v| *v > 0)
.unwrap_or(default)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplSessionError {
TooManySessions(usize),
}
impl std::fmt::Display for ReplSessionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReplSessionError::TooManySessions(max) => write!(
f,
"Too many open REPL sessions (limit {}); retry later or reuse a session_id",
max
),
}
}
}
#[derive(Clone)]
pub struct ReplSessionStore {
sessions: Arc<RwLock<HashMap<String, ReplSession>>>,
timeout: Duration,
max_per_user: usize,
max_total: usize,
cleanup_started: Arc<Once>,
}
impl Default for ReplSessionStore {
fn default() -> Self {
Self::new()
}
}
impl ReplSessionStore {
pub fn new() -> Self {
Self::with_timeout(30 * 60) }
pub fn with_timeout(timeout_secs: u64) -> Self {
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
timeout: Duration::from_secs(timeout_secs),
max_per_user: env_usize("SOLIDB_REPL_MAX_SESSIONS_PER_USER", 8),
max_total: env_usize("SOLIDB_REPL_MAX_SESSIONS", 1000),
cleanup_started: Arc::new(Once::new()),
}
}
pub fn with_limits(mut self, max_per_user: usize, max_total: usize) -> Self {
self.max_per_user = max_per_user.max(1);
self.max_total = max_total.max(1);
self
}
pub fn get_or_create(&self, session_id: Option<&str>, db_name: &str) -> ReplSession {
match self.get_or_create_for(session_id, db_name, "") {
Ok(session) => session,
Err(_) => ReplSession::new(db_name.to_string()),
}
}
pub fn get_or_create_for(
&self,
session_id: Option<&str>,
db_name: &str,
owner: &str,
) -> Result<ReplSession, ReplSessionError> {
self.ensure_cleanup_task();
let mut sessions = self.sessions.write().unwrap();
if let Some(id) = session_id {
if let Some(session) = sessions.get_mut(id) {
if session.owner == owner
&& session.db_name == db_name
&& !session.is_expired(self.timeout)
{
session.touch();
return Ok(session.clone());
} else if session.owner == owner || session.is_expired(self.timeout) {
sessions.remove(id);
}
}
}
let mut own: Vec<(String, Instant)> = sessions
.values()
.filter(|s| s.owner == owner)
.map(|s| (s.id.clone(), s.last_accessed))
.collect();
if own.len() >= self.max_per_user {
own.sort_by_key(|(_, at)| *at);
let excess = own.len() + 1 - self.max_per_user;
for (id, _) in own.into_iter().take(excess) {
sessions.remove(&id);
}
}
if sessions.len() >= self.max_total {
let timeout = self.timeout;
sessions.retain(|_, s| !s.is_expired(timeout));
if sessions.len() >= self.max_total {
return Err(ReplSessionError::TooManySessions(self.max_total));
}
}
let session = ReplSession::new_for(db_name.to_string(), owner.to_string());
sessions.insert(session.id.clone(), session.clone());
Ok(session)
}
fn ensure_cleanup_task(&self) {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return;
};
let weak: Weak<RwLock<HashMap<String, ReplSession>>> = Arc::downgrade(&self.sessions);
let timeout = self.timeout;
self.cleanup_started.call_once(|| {
handle.spawn(async move {
let mut interval = tokio::time::interval(CLEANUP_INTERVAL);
interval.tick().await;
loop {
interval.tick().await;
let Some(sessions) = weak.upgrade() else {
break;
};
if let Ok(mut sessions) = sessions.write() {
sessions.retain(|_, s| !s.is_expired(timeout));
};
}
});
});
}
pub fn get(&self, session_id: &str) -> Option<ReplSession> {
let sessions = self.sessions.read().unwrap();
sessions.get(session_id).and_then(|s| {
if s.is_expired(self.timeout) {
None
} else {
Some(s.clone())
}
})
}
pub fn update(&self, session: ReplSession) {
let mut sessions = self.sessions.write().unwrap();
sessions.insert(session.id.clone(), session);
}
pub fn update_variables(&self, session_id: &str, variables: HashMap<String, JsonValue>) {
let mut sessions = self.sessions.write().unwrap();
if let Some(session) = sessions.get_mut(session_id) {
session.variables = variables;
session.touch();
}
}
pub fn cleanup_expired(&self) -> usize {
let mut sessions = self.sessions.write().unwrap();
let before_count = sessions.len();
sessions.retain(|_, session| !session.is_expired(self.timeout));
before_count - sessions.len()
}
pub fn active_count(&self) -> usize {
let sessions = self.sessions.read().unwrap();
sessions
.values()
.filter(|s| !s.is_expired(self.timeout))
.count()
}
pub fn delete(&self, session_id: &str) -> bool {
let mut sessions = self.sessions.write().unwrap();
sessions.remove(session_id).is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_creation() {
let session = ReplSession::new("test_db".to_string());
assert!(!session.id.is_empty());
assert_eq!(session.db_name, "test_db");
assert!(session.variables.is_empty());
assert!(session.history.is_empty());
}
#[test]
fn test_session_store_get_or_create() {
let store = ReplSessionStore::new();
let session1 = store.get_or_create(None, "db1");
assert_eq!(session1.db_name, "db1");
let session2 = store.get_or_create(Some(&session1.id), "db1");
assert_eq!(session1.id, session2.id);
let session3 = store.get_or_create(Some(&session1.id), "db2");
assert_ne!(session1.id, session3.id);
}
#[test]
fn test_session_expiration() {
let store = ReplSessionStore::with_timeout(0);
let session = store.get_or_create(None, "test_db");
std::thread::sleep(Duration::from_millis(10));
assert!(store.get(&session.id).is_none());
}
#[test]
fn test_cleanup_expired() {
let store = ReplSessionStore::with_timeout(0);
store.get_or_create(None, "db1");
store.get_or_create(None, "db2");
std::thread::sleep(Duration::from_millis(10));
let cleaned = store.cleanup_expired();
assert_eq!(cleaned, 2);
assert_eq!(store.active_count(), 0);
}
#[test]
fn sessions_are_owned_and_capped_per_user() {
let store = ReplSessionStore::new().with_limits(2, 100);
let a1 = store.get_or_create_for(None, "db", "alice").unwrap();
let b = store.get_or_create_for(Some(&a1.id), "db", "bob").unwrap();
assert_ne!(a1.id, b.id);
assert!(store.get(&a1.id).is_some(), "alice's session survives");
let _a2 = store.get_or_create_for(None, "db", "alice").unwrap();
let _a3 = store.get_or_create_for(None, "db", "alice").unwrap();
assert!(store.get(&a1.id).is_none());
assert_eq!(store.active_count(), 3); }
#[test]
fn global_cap_refuses_new_sessions() {
let store = ReplSessionStore::new().with_limits(10, 2);
store.get_or_create_for(None, "db", "a").unwrap();
store.get_or_create_for(None, "db", "b").unwrap();
assert_eq!(
store.get_or_create_for(None, "db", "c").unwrap_err(),
ReplSessionError::TooManySessions(2)
);
}
#[test]
fn history_is_bounded_by_bytes() {
let mut session = ReplSession::new("test".to_string());
for _ in 0..10 {
session.add_to_history("x".repeat(300 * 1024));
}
let total: usize = session.history.iter().map(|c| c.len()).sum();
assert!(total <= MAX_HISTORY_BYTES);
}
#[test]
fn test_history_limit() {
let mut session = ReplSession::new("test".to_string());
for i in 0..150 {
session.add_to_history(format!("command {}", i));
}
assert_eq!(session.history.len(), 100);
assert_eq!(session.history[0], "command 50");
}
}