Skip to main content

cherenkov/server/
sessions.rs

1//! Bounded conversation history, committed at the final-response publication boundary.
2
3use super::{
4    UsageStats, failure::Failure, registry, request::SessionInput, tool_call::WireToolCall,
5};
6use crate::units::BYTES_PER_MIB;
7use crate::{config::Config, sampling::Sampling};
8use anyhow::{Context, Result, ensure};
9use rand::rngs::StdRng;
10use serde::Serialize;
11use serde_json::{Map, Value, json};
12use std::{
13    collections::HashMap,
14    sync::{Arc, Mutex},
15    time::{Duration, Instant},
16};
17
18struct Session {
19    history: Box<str>,
20    sampling: Sampling,
21    context: usize,
22    rng: Option<StdRng>,
23    busy: Option<String>,
24    touched: Instant,
25    reserved: usize,
26    committed_turns: u64,
27    usage: UsageStats,
28}
29
30#[derive(Serialize)]
31struct SessionStats<'a> {
32    committed_turns: u64,
33    history_bytes: usize,
34    reserved_history_bytes: usize,
35    usage: &'a UsageStats,
36}
37
38impl Session {
39    fn stats(&self) -> SessionStats<'_> {
40        SessionStats {
41            committed_turns: self.committed_turns,
42            history_bytes: self.history.len(),
43            reserved_history_bytes: self.reserved,
44            usage: &self.usage,
45        }
46    }
47}
48
49pub(super) struct Store {
50    entries: HashMap<String, Session>,
51    limit: usize,
52    capacity: usize,
53    idle: Duration,
54    pub evictions: u64,
55}
56
57impl Store {
58    pub(super) fn new(config: &Config) -> Self {
59        Self {
60            entries: HashMap::new(),
61            limit: config.limits.max_sessions,
62            capacity: config.limits.session_history_mib * BYTES_PER_MIB,
63            idle: Duration::from_secs(config.limits.session_idle_seconds),
64            evictions: 0,
65        }
66    }
67
68    pub(super) fn bytes(&self) -> usize {
69        self.entries
70            .iter()
71            .map(|(id, session)| {
72                id.capacity() + session.history.len() + session.reserved + size_of::<Session>()
73            })
74            .sum()
75    }
76
77    pub(super) fn count(&self) -> usize {
78        self.entries.len()
79    }
80
81    pub(super) fn stats(&self) -> crate::control::state::SessionStats {
82        crate::control::state::SessionStats {
83            entries: self.count(),
84            bytes: self.bytes(),
85            evictions: self.evictions,
86        }
87    }
88
89    pub(super) fn expire(&mut self) {
90        if self.idle.is_zero() {
91            return;
92        }
93
94        self.entries
95            .retain(|_, session| session.busy.is_some() || session.touched.elapsed() < self.idle);
96    }
97
98    fn make_room(&mut self, extra: usize, create: bool, protected: Option<&str>) -> Result<()> {
99        ensure!(
100            extra <= self.capacity,
101            "session history exceeds its memory budget"
102        );
103
104        while self.bytes() + extra > self.capacity || (create && self.entries.len() >= self.limit) {
105            let oldest = self
106                .entries
107                .iter()
108                .filter(|(id, session)| session.busy.is_none() && Some(id.as_str()) != protected)
109                .min_by_key(|(_, session)| session.touched)
110                .map(|(id, _)| id.clone())
111                .ok_or(Failure(503, "all session storage is in use"))?;
112
113            self.entries.remove(&oldest);
114
115            self.evictions += 1;
116        }
117
118        Ok(())
119    }
120
121    pub(super) fn create(&mut self, body: &Value, config: &Config) -> Result<String> {
122        ensure!(self.limit > 0, "retained sessions are disabled");
123        ensure!(body.is_object(), "session must be an object");
124
125        let sampling = Sampling::from_request(body, &config.defaults.sampling)?;
126        let context = body
127            .get("context_tokens")
128            .map(|v| v.as_u64().context("context_tokens must be an integer"))
129            .transpose()?
130            .unwrap_or(config.limits.context_tokens as u64) as usize;
131
132        ensure!(
133            context > 0 && context <= config.limits.context_tokens,
134            "context_tokens exceeds server capacity"
135        );
136        self.expire();
137
138        let id = registry::id("session");
139
140        self.make_room(size_of::<Session>() + id.capacity() + 2, true, None)?;
141        self.entries.insert(
142            id.clone(),
143            Session {
144                history: "[]".into(),
145                sampling,
146                context,
147                rng: None,
148                busy: None,
149                touched: Instant::now(),
150                reserved: 0,
151                committed_turns: 0,
152                usage: UsageStats::default(),
153            },
154        );
155
156        Ok(id)
157    }
158
159    pub(super) fn show(&mut self, id: &str) -> Result<Value> {
160        self.expire();
161
162        let session = self
163            .entries
164            .get(id)
165            .ok_or(Failure(404, "unknown or expired session"))?;
166
167        Ok(json!({
168            "id": id,
169            "object": "session",
170            "sampling": session.sampling,
171            "context_tokens": session.context,
172            "active_request_id": session.busy,
173            "messages": serde_json::from_str::<Value>(&session.history)?,
174            "stats": session.stats(),
175        }))
176    }
177
178    pub(super) fn delete(&mut self, id: &str) -> Result<()> {
179        self.expire();
180
181        let session = self
182            .entries
183            .get(id)
184            .ok_or(Failure(404, "unknown or expired session"))?;
185
186        ensure!(
187            session.busy.is_none(),
188            Failure(409, "cancel the active request before deleting its session")
189        );
190        self.entries.remove(id);
191
192        Ok(())
193    }
194}
195
196/// Pin a session through admission, prefill and decode. Drop rolls back cancelled turns.
197pub(super) struct Turn {
198    pub id: String,
199    store: Arc<Mutex<Store>>,
200    pub input: SessionInput,
201    pub rng: Option<StdRng>,
202}
203
204impl Turn {
205    pub(super) fn begin(
206        store: &Arc<Mutex<Store>>,
207        body: &Value,
208        request_id: &str,
209        request_bytes: usize,
210    ) -> Result<Option<Self>> {
211        let Some(id) = body.get("session_id").filter(|v| !v.is_null()) else {
212            return Ok(None);
213        };
214        let id = id
215            .as_str()
216            .context("session_id must be a string")?
217            .to_owned();
218        let incoming = body["messages"]
219            .as_array()
220            .context("sessions require chat messages")?
221            .clone();
222
223        ensure!(!incoming.is_empty(), "messages must not be empty");
224
225        let mut guard = store.lock().unwrap();
226
227        guard.expire();
228
229        let session = guard
230            .entries
231            .get_mut(&id)
232            .ok_or(Failure(404, "unknown or expired session"))?;
233
234        ensure!(
235            session.busy.is_none(),
236            Failure(409, "session already has an active request")
237        );
238
239        let sampling = Sampling::from_request(body, &session.sampling)?;
240        let restart_rng = body.get("seed").is_some_and(|v| !v.is_null());
241        let mut messages: Vec<Value> = serde_json::from_str(&session.history)?;
242
243        messages.extend(incoming);
244        ensure!(
245            serde_json::to_vec(&messages)?.len() <= request_bytes,
246            "session history and new messages exceed request_bytes"
247        );
248
249        let context = body
250            .get("context_tokens")
251            .map(|v| {
252                v.as_u64()
253                    .and_then(|n| usize::try_from(n).ok())
254                    .context("context_tokens must be a positive integer")
255            })
256            .transpose()?
257            .unwrap_or(session.context);
258
259        ensure!(
260            context > 0 && context <= session.context,
261            "context_tokens exceeds session capacity"
262        );
263
264        // Explicit seeds restart the stream. Otherwise an existing session continues it.
265        let rng = if restart_rng {
266            None
267        } else {
268            session.rng.clone()
269        };
270        session.busy = Some(request_id.to_owned());
271
272        Ok(Some(Self {
273            id,
274            store: store.clone(),
275            input: SessionInput {
276                messages,
277                sampling,
278                context,
279            },
280            rng,
281        }))
282    }
283
284    pub(super) fn prepare(
285        mut self,
286        text: &str,
287        rng: Option<StdRng>,
288        usage: UsageStats,
289        tool_calls: Option<&[WireToolCall]>,
290    ) -> Result<Commit> {
291        let mut message = json!({"role":"assistant", "content":text});
292
293        if let Some(calls) = tool_calls.filter(|calls| !calls.is_empty()) {
294            // The template iterates `tool_call.arguments|items`, so retained
295            // history keeps the decoded object; the wire carries a string.
296            if text.is_empty() {
297                message["content"] = Value::Null;
298            }
299
300            message["tool_calls"] = Value::Array(
301                calls
302                    .iter()
303                    .map(|call| {
304                        json!({
305                            "id": call.id,
306                            "type": "function",
307                            "function": {
308                                "name": call.name,
309                                "arguments": serde_json::from_str::<Value>(
310                                    &call.arguments,
311                                )
312                                .unwrap_or_else(|_| Value::Object(Map::new())),
313                            },
314                        })
315                    })
316                    .collect::<Vec<Value>>(),
317            );
318        }
319
320        self.input.messages.push(message);
321
322        let history = serde_json::to_string(&self.input.messages)?.into_boxed_str();
323        let mut store = self.store.lock().unwrap();
324        let previous_bytes = store
325            .entries
326            .get(&self.id)
327            .context("session disappeared")?
328            .history
329            .len();
330        let growth = history.len().saturating_sub(previous_bytes);
331
332        store.make_room(growth, false, Some(&self.id))?;
333
334        store
335            .entries
336            .get_mut(&self.id)
337            .expect("pinned session")
338            .reserved = growth;
339
340        drop(store);
341
342        // Greedy turns consume no draws; retain the previous random stream.
343        let rng = rng.or_else(|| self.rng.clone());
344
345        Ok(Commit {
346            turn: self,
347            history,
348            rng,
349            usage,
350        })
351    }
352}
353
354impl Drop for Turn {
355    fn drop(&mut self) {
356        if let Some(session) = self.store.lock().unwrap().entries.get_mut(&self.id) {
357            session.busy = None;
358            session.reserved = 0;
359        }
360    }
361}
362
363pub(super) struct Commit {
364    turn: Turn,
365    history: Box<str>,
366    rng: Option<StdRng>,
367    usage: UsageStats,
368}
369
370impl Commit {
371    pub(super) fn publish(self) {
372        let mut store = self.turn.store.lock().unwrap();
373        let session = store
374            .entries
375            .get_mut(&self.turn.id)
376            .expect("pinned session");
377        session.history = self.history;
378        session.sampling = self.turn.input.sampling.clone();
379        session.rng = self.rng;
380        session.committed_turns = session.committed_turns.saturating_add(1);
381
382        session.usage.add(self.usage);
383
384        session.reserved = 0;
385        session.touched = Instant::now();
386    }
387}
388
389#[cfg(test)]
390#[path = "../../tests/unit/server/sessions.rs"]
391mod tests;
392
393#[cfg(test)]
394#[path = "../../tests/unit/server/session_stats.rs"]
395mod stats_tests;