Skip to main content

soothe_client/appkit/
pool.rs

1//! Connection pool mapping sessions to daemon loops.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use serde_json::Map;
8use tokio::sync::Mutex;
9
10use crate::client::Client;
11use crate::errors::{Error, Result};
12use crate::session::{bootstrap_loop_session, connect_with_retries, BootstrapOptions};
13
14use super::session_store::{SessionRecord, SessionStore};
15
16/// Pool configuration.
17#[derive(Debug, Clone)]
18pub struct PoolConfig {
19    /// Max concurrent pooled connections.
20    pub pool_size: usize,
21    /// Query timeout default.
22    pub query_timeout: Duration,
23    /// Connect timeout.
24    pub connection_timeout: Duration,
25    /// Max idle time before recycle on acquire.
26    pub max_idle_time: Duration,
27}
28
29impl Default for PoolConfig {
30    fn default() -> Self {
31        Self {
32            pool_size: 1000,
33            query_timeout: Duration::from_secs(30 * 60),
34            connection_timeout: Duration::from_secs(30),
35            max_idle_time: Duration::from_secs(10 * 60),
36        }
37    }
38}
39
40/// Pool stats snapshot.
41#[derive(Debug, Clone, Default)]
42pub struct PoolStats {
43    /// Active checked-out connections.
44    pub active: usize,
45    /// Idle connections in pool.
46    pub idle: usize,
47}
48
49/// A pooled connection bound to a session.
50pub struct PooledConn {
51    /// Slot id.
52    pub slot_id: String,
53    /// Session id.
54    pub session_id: String,
55    /// Loop id.
56    pub loop_id: String,
57    /// Underlying client.
58    pub client: Client,
59    last_used: Instant,
60}
61
62impl PooledConn {
63    /// Whether connected.
64    pub fn is_connected(&self) -> bool {
65        self.client.is_connected()
66    }
67
68    /// Loop id.
69    pub fn get_loop_id(&self) -> &str {
70        &self.loop_id
71    }
72}
73
74struct PoolInner {
75    idle: HashMap<String, PooledConn>,
76    active: HashMap<String, PooledConn>,
77}
78
79/// Maps chat sessions to daemon loops.
80pub struct ConnectionPool<S: SessionStore> {
81    daemon_url: String,
82    store: Arc<S>,
83    cfg: PoolConfig,
84    inner: Mutex<PoolInner>,
85}
86
87impl<S: SessionStore + 'static> ConnectionPool<S> {
88    /// Create a pool.
89    pub fn new(daemon_url: impl Into<String>, store: Arc<S>, cfg: Option<PoolConfig>) -> Self {
90        Self {
91            daemon_url: daemon_url.into(),
92            store,
93            cfg: cfg.unwrap_or_default(),
94            inner: Mutex::new(PoolInner {
95                idle: HashMap::new(),
96                active: HashMap::new(),
97            }),
98        }
99    }
100
101    /// Snapshot stats.
102    pub async fn stats(&self) -> PoolStats {
103        let inner = self.inner.lock().await;
104        PoolStats {
105            active: inner.active.len(),
106            idle: inner.idle.len(),
107        }
108    }
109
110    /// Acquire (or bootstrap) a connection for a session.
111    pub async fn acquire(
112        &self,
113        session_id: &str,
114        workspace_id: &str,
115        user_id: &str,
116    ) -> Result<PooledConn> {
117        {
118            let mut inner = self.inner.lock().await;
119            if let Some(mut conn) = inner.idle.remove(session_id) {
120                if conn.last_used.elapsed() > self.cfg.max_idle_time
121                    || !conn.client.is_connection_alive()
122                {
123                    let _ = conn.client.close().await;
124                } else {
125                    conn.last_used = Instant::now();
126                    let out = PooledConn {
127                        slot_id: conn.slot_id.clone(),
128                        session_id: conn.session_id.clone(),
129                        loop_id: conn.loop_id.clone(),
130                        client: conn.client.clone(),
131                        last_used: conn.last_used,
132                    };
133                    inner.active.insert(session_id.to_string(), conn);
134                    return Ok(out);
135                }
136            }
137            if inner.active.len() + inner.idle.len() >= self.cfg.pool_size {
138                return Err(Error::msg("pool exhausted"));
139            }
140        }
141
142        // Ensure session record exists.
143        if self.store.get_session(session_id).await.is_none() {
144            self.store
145                .create_session(SessionRecord {
146                    session_id: session_id.to_string(),
147                    workspace_id: workspace_id.to_string(),
148                    user_id: user_id.to_string(),
149                    ..Default::default()
150                })
151                .await;
152        }
153
154        let client = Client::new(&self.daemon_url);
155        connect_with_retries(&client, 40, Duration::from_millis(250)).await?;
156
157        let loop_id = if let Some(existing) = self.store.get_loop_id_for_session(session_id).await {
158            match client.reattach_and_probe(&existing).await {
159                Ok(()) => existing,
160                Err(_) => {
161                    let mut boot = BootstrapOptions::new();
162                    boot.workspace = Some(workspace_id.to_string());
163                    boot.user_id = Some(user_id.to_string());
164                    let ready = bootstrap_loop_session(&client, boot, None).await?;
165                    ready
166                        .get("loop_id")
167                        .and_then(|v| v.as_str())
168                        .unwrap_or("")
169                        .to_string()
170                }
171            }
172        } else {
173            let mut boot = BootstrapOptions::new();
174            boot.workspace = Some(workspace_id.to_string());
175            boot.user_id = Some(user_id.to_string());
176            let ready = bootstrap_loop_session(&client, boot, None).await?;
177            ready
178                .get("loop_id")
179                .and_then(|v| v.as_str())
180                .unwrap_or("")
181                .to_string()
182        };
183
184        if loop_id.is_empty() {
185            return Err(Error::msg("failed to bootstrap pooled loop"));
186        }
187        self.store.set_loop_id(session_id, &loop_id).await;
188        self.store.update_last_used(session_id).await;
189
190        let conn = PooledConn {
191            slot_id: format!("{session_id}:{loop_id}"),
192            session_id: session_id.to_string(),
193            loop_id,
194            client,
195            last_used: Instant::now(),
196        };
197        let out = PooledConn {
198            slot_id: conn.slot_id.clone(),
199            session_id: conn.session_id.clone(),
200            loop_id: conn.loop_id.clone(),
201            client: conn.client.clone(),
202            last_used: conn.last_used,
203        };
204        self.inner
205            .lock()
206            .await
207            .active
208            .insert(session_id.to_string(), conn);
209        Ok(out)
210    }
211
212    /// Release session connection back to idle.
213    pub async fn release(&self, session_id: &str) {
214        let mut inner = self.inner.lock().await;
215        if let Some(mut conn) = inner.active.remove(session_id) {
216            conn.last_used = Instant::now();
217            inner.idle.insert(session_id.to_string(), conn);
218        }
219    }
220
221    /// Reset session (close + clear loop binding).
222    pub async fn reset_session(&self, session_id: &str) -> Result<()> {
223        {
224            let mut inner = self.inner.lock().await;
225            if let Some(conn) = inner.active.remove(session_id) {
226                let _ = conn.client.close().await;
227            }
228            if let Some(conn) = inner.idle.remove(session_id) {
229                let _ = conn.client.close().await;
230            }
231        }
232        self.store.increment_reset_count(session_id).await;
233        self.store.set_loop_id(session_id, "").await;
234        Ok(())
235    }
236
237    /// Stop pool and close all connections.
238    pub async fn stop(&self) {
239        let mut inner = self.inner.lock().await;
240        let mut conns: Vec<PooledConn> = inner.active.drain().map(|(_, c)| c).collect();
241        conns.extend(inner.idle.drain().map(|(_, c)| c));
242        drop(inner);
243        for conn in conns {
244            let _ = conn.client.close().await;
245        }
246    }
247}
248
249/// Helper to build flat loop_input dict (coerced by callers to notification).
250pub fn input_message_for_loop(
251    text: &str,
252    loop_id: &str,
253    attachments: Option<serde_json::Value>,
254    opts: Option<&super::turn_runner::InputOpts>,
255) -> Map<String, serde_json::Value> {
256    use serde_json::json;
257    let mut m = Map::new();
258    m.insert("type".into(), json!("loop_input"));
259    m.insert("content".into(), json!(text));
260    m.insert("loop_id".into(), json!(loop_id));
261    if let Some(a) = attachments {
262        m.insert("attachments".into(), a);
263    }
264    if let Some(o) = opts {
265        if let Some(h) = &o.intent_hint {
266            m.insert("intent_hint".into(), json!(h));
267        }
268        if let Some(s) = &o.preferred_subagent {
269            m.insert("preferred_subagent".into(), json!(s));
270        }
271        if let Some(schema) = &o.response_schema {
272            m.insert("response_schema".into(), schema.clone());
273        }
274    }
275    m
276}