Skip to main content

microclaw_engine/
chat_turn_queue.rs

1//! Per-chat turn serialization and message coalescing.
2//!
3//! `ChatTurnQueue` ensures at most one agent run is active per (channel, chat_id)
4//! at any time. Messages arriving while a run is active are queued and coalesced
5//! after the run completes.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10use tokio::sync::Mutex;
11use tracing::{debug, info, warn};
12
13/// Key identifying a specific chat across channels.
14type ChatKey = (String, i64);
15
16/// A message that arrived while an agent run was active for the same chat.
17#[derive(Debug, Clone)]
18pub struct PendingMessage {
19    pub sender_name: String,
20    pub content: String,
21    pub message_id: String,
22    pub timestamp: String,
23}
24
25/// Internal per-chat slot tracking the turn lock and pending messages.
26struct ChatSlot {
27    /// Async mutex held for the duration of an agent run.
28    turn_lock: Arc<Mutex<()>>,
29    /// Messages queued while a run is active.
30    pending_messages: Vec<PendingMessage>,
31    /// Last time this slot was actively used.
32    last_active: Instant,
33}
34
35impl ChatSlot {
36    fn new() -> Self {
37        Self {
38            turn_lock: Arc::new(Mutex::new(())),
39            pending_messages: Vec::new(),
40            last_active: Instant::now(),
41        }
42    }
43}
44
45/// RAII guard that releases the per-chat turn lock when dropped.
46pub struct TurnGuard {
47    _guard: tokio::sync::OwnedMutexGuard<()>,
48    key: ChatKey,
49}
50
51impl Drop for TurnGuard {
52    fn drop(&mut self) {
53        debug!(
54            channel = %self.key.0,
55            chat_id = self.key.1,
56            "Chat turn released"
57        );
58    }
59}
60
61/// Per-chat turn serialization queue.
62///
63/// Ensures at most one agent run per (channel, chat_id). Messages arriving
64/// during an active run are coalesced into `pending_messages` and can be
65/// drained after the run completes.
66pub struct ChatTurnQueue {
67    slots: Mutex<HashMap<ChatKey, Arc<Mutex<ChatSlot>>>>,
68    max_pending: usize,
69    idle_ttl: Duration,
70}
71
72impl ChatTurnQueue {
73    pub fn new(max_pending: usize) -> Self {
74        Self {
75            slots: Mutex::new(HashMap::new()),
76            max_pending,
77            idle_ttl: Duration::from_secs(600),
78        }
79    }
80
81    /// Get or create the slot for a given chat, performing opportunistic
82    /// cleanup of idle slots.
83    async fn get_slot(&self, key: &ChatKey) -> Arc<Mutex<ChatSlot>> {
84        let mut slots = self.slots.lock().await;
85        // Opportunistic cleanup: remove idle slots (no pending messages, not locked)
86        if slots.len() > 100 {
87            let now = Instant::now();
88            let ttl = self.idle_ttl;
89            slots.retain(|_, slot_arc| {
90                // Only remove if we can try-lock it (not actively held)
91                if let Ok(slot) = slot_arc.try_lock() {
92                    if slot.pending_messages.is_empty()
93                        && now.duration_since(slot.last_active) > ttl
94                    {
95                        return false; // remove
96                    }
97                }
98                true
99            });
100        }
101        slots
102            .entry(key.clone())
103            .or_insert_with(|| Arc::new(Mutex::new(ChatSlot::new())))
104            .clone()
105    }
106
107    /// Acquire the turn for a chat. Blocks until the previous run completes.
108    /// Returns a [`TurnGuard`] that releases the turn when dropped.
109    pub async fn acquire(self: &Arc<Self>, channel: &str, chat_id: i64) -> Option<TurnGuard> {
110        let key: ChatKey = (channel.to_string(), chat_id);
111        let slot_arc = self.get_slot(&key).await;
112
113        let turn_lock = {
114            let slot = slot_arc.lock().await;
115            slot.turn_lock.clone()
116        };
117
118        // Acquire with timeout to prevent deadlock (e.g., recursive same-chat calls).
119        let guard = match tokio::time::timeout(Duration::from_secs(60), turn_lock.lock_owned())
120            .await
121        {
122            Ok(guard) => guard,
123            Err(_) => {
124                warn!(
125                    channel = %key.0,
126                    chat_id = key.1,
127                    "ChatTurnQueue: timeout waiting for turn lock (60s); proceeding without lock"
128                );
129                return None;
130            }
131        };
132
133        {
134            let mut slot = slot_arc.lock().await;
135            slot.last_active = Instant::now();
136        }
137
138        debug!(channel, chat_id, "Chat turn acquired");
139
140        Some(TurnGuard { _guard: guard, key })
141    }
142
143    /// Enqueue a message for a chat that currently has an active run.
144    ///
145    /// Returns `true` if the chat has an active run (message was queued).
146    /// Returns `false` if no run is active (caller should start a new run).
147    pub async fn enqueue_if_busy(&self, channel: &str, chat_id: i64, msg: PendingMessage) -> bool {
148        let key: ChatKey = (channel.to_string(), chat_id);
149        let slot_arc = {
150            let slots = self.slots.lock().await;
151            match slots.get(&key) {
152                Some(arc) => arc.clone(),
153                None => return false, // no slot means no active run
154            }
155        };
156
157        let mut slot = slot_arc.lock().await;
158        // Check if turn_lock is currently held (i.e., a run is active)
159        if slot.turn_lock.try_lock().is_ok() {
160            // Lock was not held -> no active run
161            return false;
162        }
163
164        // Run is active; queue the message
165        if slot.pending_messages.len() >= self.max_pending {
166            // Drop oldest to make room
167            slot.pending_messages.remove(0);
168            warn!(
169                channel,
170                chat_id,
171                max_pending = self.max_pending,
172                "ChatTurnQueue: pending messages at capacity; dropped oldest"
173            );
174        }
175
176        info!(
177            channel,
178            chat_id,
179            sender = %msg.sender_name,
180            pending_count = slot.pending_messages.len() + 1,
181            "Message queued while chat turn is active"
182        );
183        slot.pending_messages.push(msg);
184        true
185    }
186
187    /// Atomically try to start a new turn or enqueue the message.
188    ///
189    /// If no turn is active, acquires the lock and returns `Some(TurnGuard)`.
190    /// If a turn is active, queues the message and returns `None`.
191    ///
192    /// This avoids the race window between a separate `enqueue_if_busy` check
193    /// and a later `acquire()` call.
194    pub async fn try_start_or_enqueue(
195        self: &Arc<Self>,
196        channel: &str,
197        chat_id: i64,
198        msg: PendingMessage,
199    ) -> Option<TurnGuard> {
200        let key: ChatKey = (channel.to_string(), chat_id);
201        let slot_arc = self.get_slot(&key).await;
202
203        let turn_lock = {
204            let slot = slot_arc.lock().await;
205            slot.turn_lock.clone()
206        };
207
208        match turn_lock.try_lock_owned() {
209            Ok(guard) => {
210                // No active run — we start a new turn.
211                let mut slot = slot_arc.lock().await;
212                slot.last_active = Instant::now();
213                debug!(channel, chat_id, "Chat turn acquired");
214                Some(TurnGuard { _guard: guard, key })
215            }
216            Err(_) => {
217                // A run is active — queue the message.
218                let mut slot = slot_arc.lock().await;
219                if slot.pending_messages.len() >= self.max_pending {
220                    slot.pending_messages.remove(0);
221                    warn!(
222                        channel,
223                        chat_id,
224                        max_pending = self.max_pending,
225                        "ChatTurnQueue: pending messages at capacity; dropped oldest"
226                    );
227                }
228                info!(
229                    channel,
230                    chat_id,
231                    sender = %msg.sender_name,
232                    pending_count = slot.pending_messages.len() + 1,
233                    "Message queued while chat turn is active"
234                );
235                slot.pending_messages.push(msg);
236                None
237            }
238        }
239    }
240
241    /// Drain all pending messages accumulated during the current turn.
242    /// Returns them in arrival order.
243    pub async fn drain_pending(&self, channel: &str, chat_id: i64) -> Vec<PendingMessage> {
244        let key: ChatKey = (channel.to_string(), chat_id);
245        let slot_arc = {
246            let slots = self.slots.lock().await;
247            match slots.get(&key) {
248                Some(arc) => arc.clone(),
249                None => return Vec::new(),
250            }
251        };
252
253        let mut slot = slot_arc.lock().await;
254        std::mem::take(&mut slot.pending_messages)
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use std::sync::atomic::{AtomicUsize, Ordering};
262
263    fn make_queue() -> Arc<ChatTurnQueue> {
264        Arc::new(ChatTurnQueue::new(20))
265    }
266
267    fn make_msg(content: &str) -> PendingMessage {
268        PendingMessage {
269            sender_name: "user".to_string(),
270            content: content.to_string(),
271            message_id: format!("msg_{content}"),
272            timestamp: "2026-04-01T00:00:00Z".to_string(),
273        }
274    }
275
276    #[tokio::test]
277    async fn test_acquire_release_basic() {
278        let q = make_queue();
279        let guard = q.acquire("telegram", 1).await;
280        assert!(guard.is_some());
281        drop(guard);
282        // Can acquire again
283        let guard2 = q.acquire("telegram", 1).await;
284        assert!(guard2.is_some());
285    }
286
287    #[tokio::test]
288    async fn test_acquire_blocks_concurrent() {
289        let q = make_queue();
290        let counter = Arc::new(AtomicUsize::new(0));
291
292        let guard = q.acquire("tg", 1).await.unwrap();
293        let q2 = q.clone();
294        let c2 = counter.clone();
295
296        // Spawn a task that tries to acquire the same chat
297        let handle = tokio::spawn(async move {
298            let _g = q2.acquire("tg", 1).await;
299            c2.fetch_add(1, Ordering::SeqCst);
300        });
301
302        // Give the spawned task time to attempt acquire
303        tokio::time::sleep(Duration::from_millis(50)).await;
304        // It should still be blocked
305        assert_eq!(counter.load(Ordering::SeqCst), 0);
306
307        // Release the first guard
308        drop(guard);
309        // Now the spawned task should complete
310        handle.await.unwrap();
311        assert_eq!(counter.load(Ordering::SeqCst), 1);
312    }
313
314    #[tokio::test]
315    async fn test_enqueue_if_busy_returns_true_when_active() {
316        let q = make_queue();
317        let _guard = q.acquire("tg", 1).await.unwrap();
318
319        let queued = q.enqueue_if_busy("tg", 1, make_msg("hello")).await;
320        assert!(queued);
321
322        let queued2 = q.enqueue_if_busy("tg", 1, make_msg("world")).await;
323        assert!(queued2);
324    }
325
326    #[tokio::test]
327    async fn test_enqueue_if_busy_returns_false_when_idle() {
328        let q = make_queue();
329        // No active run
330        let queued = q.enqueue_if_busy("tg", 1, make_msg("hello")).await;
331        assert!(!queued);
332    }
333
334    #[tokio::test]
335    async fn test_drain_pending_returns_all() {
336        let q = make_queue();
337        let _guard = q.acquire("tg", 1).await.unwrap();
338
339        q.enqueue_if_busy("tg", 1, make_msg("a")).await;
340        q.enqueue_if_busy("tg", 1, make_msg("b")).await;
341        q.enqueue_if_busy("tg", 1, make_msg("c")).await;
342
343        let pending = q.drain_pending("tg", 1).await;
344        assert_eq!(pending.len(), 3);
345        assert_eq!(pending[0].content, "a");
346        assert_eq!(pending[1].content, "b");
347        assert_eq!(pending[2].content, "c");
348    }
349
350    #[tokio::test]
351    async fn test_drain_clears_queue() {
352        let q = make_queue();
353        let _guard = q.acquire("tg", 1).await.unwrap();
354
355        q.enqueue_if_busy("tg", 1, make_msg("a")).await;
356        let _ = q.drain_pending("tg", 1).await;
357
358        let pending = q.drain_pending("tg", 1).await;
359        assert!(pending.is_empty());
360    }
361
362    #[tokio::test]
363    async fn test_different_chats_independent() {
364        let q = make_queue();
365        let counter = Arc::new(AtomicUsize::new(0));
366
367        let _guard_chat1 = q.acquire("tg", 1).await.unwrap();
368        let q2 = q.clone();
369        let c2 = counter.clone();
370
371        // Different chat should not block
372        let handle = tokio::spawn(async move {
373            let _g = q2.acquire("tg", 2).await;
374            c2.fetch_add(1, Ordering::SeqCst);
375        });
376
377        handle.await.unwrap();
378        assert_eq!(counter.load(Ordering::SeqCst), 1);
379    }
380
381    #[tokio::test]
382    async fn test_max_pending_drops_oldest() {
383        let q = Arc::new(ChatTurnQueue::new(3));
384        let _guard = q.acquire("tg", 1).await.unwrap();
385
386        q.enqueue_if_busy("tg", 1, make_msg("a")).await;
387        q.enqueue_if_busy("tg", 1, make_msg("b")).await;
388        q.enqueue_if_busy("tg", 1, make_msg("c")).await;
389        // This should drop "a"
390        q.enqueue_if_busy("tg", 1, make_msg("d")).await;
391
392        let pending = q.drain_pending("tg", 1).await;
393        assert_eq!(pending.len(), 3);
394        assert_eq!(pending[0].content, "b");
395        assert_eq!(pending[1].content, "c");
396        assert_eq!(pending[2].content, "d");
397    }
398}