Skip to main content

nexo_core/session/
manager.rs

1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use chrono::Utc;
5use dashmap::DashMap;
6use uuid::Uuid;
7
8use super::types::Session;
9
10type ExpireCallback = Arc<dyn Fn(Uuid) + Send + Sync>;
11
12/// Soft cap on concurrent live sessions. Prevents a Telegram/WhatsApp
13/// spammer from creating unbounded sessions by rotating `chat_id`s.
14/// At the cap, the oldest-idle session is evicted before a new one
15/// lands — this keeps the policy invisible to legitimate traffic
16/// while bounding memory growth under abuse. The default is large
17/// enough that real deployments rarely hit it.
18pub const DEFAULT_MAX_SESSIONS: usize = 10_000;
19
20#[derive(Clone)]
21pub struct SessionManager {
22    sessions: Arc<DashMap<Uuid, Session>>,
23    ttl: Duration,
24    max_turns: usize,
25    max_sessions: usize,
26    /// Callbacks invoked (via `tokio::spawn`) whenever a session is dropped
27    /// from the map — both explicit `delete()` and TTL sweep drop it.
28    on_expire: Arc<Mutex<Vec<ExpireCallback>>>,
29}
30
31impl SessionManager {
32    /// Creates the manager and spawns the background TTL sweeper.
33    pub fn new(ttl: Duration, max_turns: usize) -> Self {
34        Self::with_cap(ttl, max_turns, DEFAULT_MAX_SESSIONS)
35    }
36
37    /// Creates the manager with a custom concurrent-session cap. Values
38    /// of `0` disable the cap (unbounded).
39    pub fn with_cap(ttl: Duration, max_turns: usize, max_sessions: usize) -> Self {
40        let sessions = Arc::new(DashMap::new());
41        let on_expire: Arc<Mutex<Vec<ExpireCallback>>> = Arc::new(Mutex::new(Vec::new()));
42        let mgr = Self {
43            sessions,
44            ttl,
45            max_turns,
46            max_sessions,
47            on_expire,
48        };
49        mgr.spawn_sweeper();
50        mgr
51    }
52
53    /// Evict the single oldest-idle session once we've hit the cap. The
54    /// scan is O(n) on the DashMap, but only runs when we're at the
55    /// ceiling — under normal load it never fires.
56    fn enforce_cap(&self) {
57        if self.max_sessions == 0 {
58            return;
59        }
60        while self.sessions.len() >= self.max_sessions {
61            let oldest = self
62                .sessions
63                .iter()
64                .min_by_key(|e| e.value().last_access)
65                .map(|e| *e.key());
66            match oldest {
67                Some(id) => {
68                    if self.sessions.remove(&id).is_some() {
69                        tracing::warn!(
70                            session_id = %id,
71                            cap = self.max_sessions,
72                            "session cap reached; evicting oldest-idle"
73                        );
74                        self.fire_expire(id);
75                    }
76                }
77                None => break,
78            }
79        }
80    }
81
82    pub fn create(&self, agent_id: impl Into<String>) -> Session {
83        self.enforce_cap();
84        let session = Session::new(agent_id, self.max_turns);
85        self.sessions.insert(session.id, session.clone());
86        session
87    }
88
89    /// Returns the session, updating last_access. Returns None if not found.
90    pub fn get(&self, id: Uuid) -> Option<Session> {
91        let mut entry = self.sessions.get_mut(&id)?;
92        entry.last_access = Utc::now();
93        Some(entry.clone())
94    }
95
96    /// Returns existing session or creates a new one bound to agent_id.
97    pub fn get_or_create(&self, id: Uuid, agent_id: impl Into<String>) -> Session {
98        // Evict before insert — only fires on the create branch because
99        // `enforce_cap` runs against the pre-insert size. An existing
100        // session hits DashMap's get path, which is unaffected.
101        if !self.sessions.contains_key(&id) {
102            self.enforce_cap();
103        }
104        let agent_id = agent_id.into();
105        let max_turns = self.max_turns;
106        let mut entry = self
107            .sessions
108            .entry(id)
109            .or_insert_with(|| Session::with_id(id, agent_id, max_turns));
110        entry.last_access = Utc::now();
111        entry.clone()
112    }
113
114    /// Replaces session state. Returns false if the session does not exist.
115    ///
116    /// The replacement is atomic via DashMap's `get_mut` entry lock — this
117    /// prevents a race where `delete()` runs between a check and insert
118    /// and the update silently resurrects the removed session.
119    pub fn update(&self, session: Session) -> bool {
120        let Some(mut entry) = self.sessions.get_mut(&session.id) else {
121            return false;
122        };
123        *entry = session;
124        true
125    }
126
127    /// Atomically append an interaction to the session's history while
128    /// holding the DashMap entry lock. The older pattern — `clone →
129    /// mutate clone → update(clone)` — could drop history when two
130    /// handlers raced on the same session (both read the pre-trim
131    /// state, both pushed their entry, the second overwrote the
132    /// first's trim). Returns false if the session no longer exists.
133    pub fn push_message(&self, id: Uuid, interaction: super::types::Interaction) -> bool {
134        let Some(mut entry) = self.sessions.get_mut(&id) else {
135            return false;
136        };
137        entry.push(interaction);
138        true
139    }
140
141    /// Removes a session and fires every registered `on_expire` callback.
142    /// Returns true if it existed.
143    pub fn delete(&self, id: Uuid) -> bool {
144        let existed = self.sessions.remove(&id).is_some();
145        if existed {
146            self.fire_expire(id);
147        }
148        existed
149    }
150
151    pub fn active_count(&self) -> usize {
152        self.sessions.len()
153    }
154
155    /// Register a callback fired when a session is dropped — either by
156    /// explicit `delete()` or by the TTL sweeper. Each invocation runs on
157    /// its own `tokio::spawn`, so callbacks must not assume caller context
158    /// and should capture owned handles (Arc clones) from the closure's
159    /// environment. Safe to call multiple times; callbacks fire in the
160    /// registration order snapshot at the moment of expiry.
161    pub fn on_expire<F>(&self, f: F)
162    where
163        F: Fn(Uuid) + Send + Sync + 'static,
164    {
165        self.on_expire.lock().unwrap().push(Arc::new(f));
166    }
167
168    fn fire_expire(&self, id: Uuid) {
169        let callbacks: Vec<ExpireCallback> = self.on_expire.lock().unwrap().clone();
170        spawn_callbacks(&callbacks, id);
171    }
172
173    fn spawn_sweeper(&self) {
174        let sessions = Arc::clone(&self.sessions);
175        let on_expire = Arc::clone(&self.on_expire);
176        let ttl = self.ttl;
177        // Sweep interval is ttl/4, minimum 10ms (keeps tests fast).
178        let interval = (ttl / 4).max(Duration::from_millis(10));
179
180        tokio::spawn(async move {
181            let mut ticker = tokio::time::interval(interval);
182            ticker.tick().await; // skip first immediate tick
183            loop {
184                ticker.tick().await;
185                let now = Utc::now();
186                let ttl_chrono =
187                    chrono::Duration::from_std(ttl).unwrap_or(chrono::Duration::hours(24));
188
189                // Collect expired ids first so we can drop them + fire
190                // callbacks without holding any map guard across await.
191                let expired: Vec<Uuid> = sessions
192                    .iter()
193                    .filter_map(|entry| {
194                        if now.signed_duration_since(entry.value().last_access) >= ttl_chrono {
195                            Some(*entry.key())
196                        } else {
197                            None
198                        }
199                    })
200                    .collect();
201                if expired.is_empty() {
202                    continue;
203                }
204                for id in &expired {
205                    sessions.remove(id);
206                }
207                let callbacks: Vec<ExpireCallback> = on_expire.lock().unwrap().clone();
208                if callbacks.is_empty() {
209                    continue;
210                }
211                for id in expired {
212                    spawn_callbacks(&callbacks, id);
213                }
214            }
215        });
216    }
217}
218
219fn spawn_callbacks(callbacks: &[ExpireCallback], id: Uuid) {
220    for cb in callbacks {
221        let cb = cb.clone();
222        tokio::spawn(async move {
223            cb(id);
224        });
225    }
226}