Skip to main content

ilink_hub/hub/
queue.rs

1//! Per-client message queue — trait-based abstraction with in-memory default.
2//!
3//! The [`MessageQueue`] trait defines the contract for all queue backends.
4//! [`InMemoryQueue`] is the default implementation backed by a `DashMap` with per-slot synchronous `std::sync::Mutex`.
5
6use async_trait::async_trait;
7use dashmap::DashMap;
8use std::collections::{HashMap, VecDeque};
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::Notify;
12use tracing::warn;
13
14use crate::error::HubError;
15use crate::ilink::types::WeixinMessage;
16
17/// Default maximum number of messages buffered per client.
18pub const DEFAULT_MAX_QUEUE_SIZE: usize = 200;
19
20// ─── MessageQueue trait ───────────────────────────────────────────────────────
21
22/// Abstraction over a message queue backend for iLink Hub.
23///
24/// # Object Safety
25///
26/// This trait is object-safe and intended to be used as `Arc<dyn MessageQueue>`.
27///
28/// # Downstream Crate Integration
29///
30/// Downstream crates can implement this trait for custom backends (e.g. Redis)
31/// and inject them into [`crate::hub::HubState`]:
32///
33/// ```ignore
34/// use ilink_hub::MessageQueue;
35/// use ilink_hub::hub::HubState;
36/// use ilink_hub::error::HubError;
37/// use ilink_hub::ilink::types::WeixinMessage;
38/// use async_trait::async_trait;
39/// use std::collections::HashMap;
40/// use std::sync::Arc;
41///
42/// struct CustomQueue;
43///
44/// #[async_trait]
45/// impl MessageQueue for CustomQueue {
46///     async fn push(&self, _vtoken: &str, _msg: WeixinMessage) -> Result<bool, HubError> {
47///         Ok(false)
48///     }
49///     async fn drain(&self, _vtoken: &str) -> Result<Vec<WeixinMessage>, HubError> {
50///         Ok(vec![])
51///     }
52///     async fn wait_notify(&self, _vtoken: &str, _timeout_secs: u64) -> Result<bool, HubError> {
53///         Ok(false)
54///     }
55///     async fn remove_client(&self, _vtoken: &str) -> Result<(), HubError> {
56///         Ok(())
57///     }
58///     async fn queue_sizes(&self) -> Result<HashMap<String, usize>, HubError> {
59///         Ok(HashMap::new())
60///     }
61/// }
62/// ```
63#[async_trait]
64pub trait MessageQueue: Send + Sync {
65    async fn push(&self, vtoken: &str, msg: WeixinMessage) -> Result<bool, HubError>;
66    /// Optimised push for the broadcast path: the base message is shared via
67    /// `Arc<WeixinMessage>` and only the per-recipient `context_token` and
68    /// `ilink_hub_ext` are supplied separately. The base clone cost drops from
69    /// O(N × msg_size) to O(msg_size) + N × cheap field clone, which matters
70    /// when many backends are online and a message carries images / files.
71    ///
72    /// The default implementation clones the base and overlays the overrides,
73    /// so implementations that don't care about the optimisation still work.
74    async fn push_shared(
75        &self,
76        vtoken: &str,
77        base: Arc<WeixinMessage>,
78        context_token: Option<String>,
79        hub_ext: Option<crate::ilink::types::HubExt>,
80    ) -> Result<bool, HubError> {
81        let mut msg = (*base).clone();
82        msg.context_token = context_token;
83        msg.ilink_hub_ext = hub_ext;
84        self.push(vtoken, msg).await
85    }
86    async fn drain(&self, vtoken: &str) -> Result<Vec<WeixinMessage>, HubError>;
87    async fn wait_notify(&self, vtoken: &str, timeout_secs: u64) -> Result<bool, HubError>;
88    async fn remove_client(&self, vtoken: &str) -> Result<(), HubError>;
89    async fn queue_sizes(&self) -> Result<HashMap<String, usize>, HubError>;
90}
91
92// ─── InMemoryQueue ────────────────────────────────────────────────────────────
93//
94// Design: DashMap for lock-free per-client slot lookup, std::sync::Mutex per slot
95// for the message buffer. N concurrent long-polls for different clients never
96// block each other — only same-client operations briefly contend.
97//
98// `wait_notify` clones Arc<Notify> and releases all locks before awaiting, so
99// N simultaneous long-polls hold zero shared locks while waiting.
100
101struct PerClientSlot {
102    messages: std::sync::Mutex<VecDeque<WeixinMessage>>,
103    notify: Arc<Notify>,
104    max_queue_size: usize,
105}
106
107impl PerClientSlot {
108    fn new(max_queue_size: usize) -> Arc<Self> {
109        Arc::new(Self {
110            messages: std::sync::Mutex::new(VecDeque::new()),
111            notify: Arc::new(Notify::new()),
112            max_queue_size,
113        })
114    }
115
116    fn push(&self, msg: WeixinMessage) -> bool {
117        let mut q = self.messages.lock().unwrap_or_else(|e| e.into_inner());
118        let dropped = if q.len() >= self.max_queue_size {
119            q.pop_front();
120            warn!(
121                max = self.max_queue_size,
122                "client queue full, dropping oldest message"
123            );
124            true
125        } else {
126            false
127        };
128        q.push_back(msg);
129        self.notify.notify_one();
130        dropped
131    }
132
133    fn drain(&self) -> Vec<WeixinMessage> {
134        self.messages
135            .lock()
136            .unwrap_or_else(|e| e.into_inner())
137            .drain(..)
138            .collect()
139    }
140
141    fn len(&self) -> usize {
142        self.messages
143            .lock()
144            .unwrap_or_else(|e| e.into_inner())
145            .len()
146    }
147}
148
149pub struct InMemoryQueue {
150    slots: DashMap<String, Arc<PerClientSlot>>,
151    max_queue_size: usize,
152}
153
154impl InMemoryQueue {
155    pub fn new() -> Self {
156        Self::with_limit(DEFAULT_MAX_QUEUE_SIZE)
157    }
158
159    pub fn with_limit(max_queue_size: usize) -> Self {
160        Self {
161            slots: DashMap::new(),
162            max_queue_size,
163        }
164    }
165
166    fn get_or_create(&self, vtoken: &str) -> Arc<PerClientSlot> {
167        self.slots
168            .entry(vtoken.to_string())
169            .or_insert_with(|| PerClientSlot::new(self.max_queue_size))
170            .clone()
171    }
172}
173
174impl Default for InMemoryQueue {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180#[async_trait]
181impl MessageQueue for InMemoryQueue {
182    async fn push(&self, vtoken: &str, msg: WeixinMessage) -> Result<bool, HubError> {
183        Ok(self.get_or_create(vtoken).push(msg))
184    }
185
186    async fn push_shared(
187        &self,
188        vtoken: &str,
189        base: Arc<WeixinMessage>,
190        context_token: Option<String>,
191        hub_ext: Option<crate::ilink::types::HubExt>,
192    ) -> Result<bool, HubError> {
193        // Specialised path: clone the base, overlay only the two per-recipient
194        // fields, then push. `WeixinMessage::item_list` is `Arc<Vec<…>>` so
195        // its clone cost is shared with the broadcast source; the
196        // `context_token` and `ilink_hub_ext` are the only per-recipient
197        // allocations.
198        let mut msg = (*base).clone();
199        msg.context_token = context_token;
200        msg.ilink_hub_ext = hub_ext;
201        Ok(self.get_or_create(vtoken).push(msg))
202    }
203
204    async fn drain(&self, vtoken: &str) -> Result<Vec<WeixinMessage>, HubError> {
205        Ok(self
206            .slots
207            .get(vtoken)
208            .map(|s| s.drain())
209            .unwrap_or_default())
210    }
211
212    async fn wait_notify(&self, vtoken: &str, timeout_secs: u64) -> Result<bool, HubError> {
213        // Clone Arc<Notify> and release the DashMap shard lock before awaiting.
214        let notify = self.get_or_create(vtoken).notify.clone();
215        let result =
216            tokio::time::timeout(Duration::from_secs(timeout_secs), notify.notified()).await;
217        Ok(result.is_ok())
218    }
219
220    async fn remove_client(&self, vtoken: &str) -> Result<(), HubError> {
221        self.slots.remove(vtoken);
222        Ok(())
223    }
224
225    async fn queue_sizes(&self) -> Result<HashMap<String, usize>, HubError> {
226        Ok(self
227            .slots
228            .iter()
229            .map(|e| (e.key().clone(), e.value().len()))
230            .collect())
231    }
232}
233
234#[cfg(test)]
235mod queue_config_tests {
236    use super::*;
237    use std::sync::Arc;
238
239    #[tokio::test]
240    async fn test_in_memory_queue_with_limit() {
241        let q = InMemoryQueue::with_limit(10);
242        let vtoken = "v1";
243
244        // Push 10 messages, no drops
245        for i in 0..10 {
246            let msg = WeixinMessage {
247                message_id: Some(i),
248                ..Default::default()
249            };
250            let dropped = q.push(vtoken, msg).await.unwrap();
251            assert!(!dropped);
252        }
253
254        // Push 11th message, should drop the first one
255        let msg = WeixinMessage {
256            message_id: Some(10),
257            ..Default::default()
258        };
259        let dropped = q.push(vtoken, msg).await.unwrap();
260        assert!(dropped);
261
262        let drained = q.drain(vtoken).await.unwrap();
263        assert_eq!(drained.len(), 10);
264        assert_eq!(drained[0].message_id, Some(1));
265        assert_eq!(drained[9].message_id, Some(10));
266    }
267
268    #[tokio::test]
269    async fn test_push_shared_overrides_context_and_hub_ext_per_recipient() {
270        use crate::ilink::types::HubExt;
271        let q = InMemoryQueue::new();
272        let base = Arc::new(WeixinMessage {
273            from_user_id: Some("user-1".into()),
274            context_token: Some("shared".into()),
275            ..Default::default()
276        });
277
278        // Two recipients should see the shared fields preserved, but their
279        // own context_token and hub_ext applied.
280        q.push_shared(
281            "v1",
282            Arc::clone(&base),
283            Some("vctx-v1".into()),
284            Some(HubExt {
285                session_id: Some("sid-v1".into()),
286                ..Default::default()
287            }),
288        )
289        .await
290        .unwrap();
291        q.push_shared(
292            "v2",
293            Arc::clone(&base),
294            Some("vctx-v2".into()),
295            Some(HubExt {
296                session_id: Some("sid-v2".into()),
297                ..Default::default()
298            }),
299        )
300        .await
301        .unwrap();
302
303        let v1 = q.drain("v1").await.unwrap();
304        let v2 = q.drain("v2").await.unwrap();
305        assert_eq!(v1.len(), 1);
306        assert_eq!(v2.len(), 1);
307        assert_eq!(v1[0].context_token.as_deref(), Some("vctx-v1"));
308        assert_eq!(v2[0].context_token.as_deref(), Some("vctx-v2"));
309        // Shared field is preserved across recipients.
310        assert_eq!(v1[0].from_user_id.as_deref(), Some("user-1"));
311        assert_eq!(v2[0].from_user_id.as_deref(), Some("user-1"));
312        // Per-recipient hub_ext is preserved.
313        assert_eq!(
314            v1[0]
315                .ilink_hub_ext
316                .as_ref()
317                .and_then(|e| e.session_id.as_deref()),
318            Some("sid-v1")
319        );
320        assert_eq!(
321            v2[0]
322                .ilink_hub_ext
323                .as_ref()
324                .and_then(|e| e.session_id.as_deref()),
325            Some("sid-v2")
326        );
327    }
328
329    #[test]
330    fn test_mutex_poison_safe() {
331        use std::thread;
332
333        // Test InMemoryQueue (PerClientSlot) poison safety
334        let slot = Arc::new(PerClientSlot::new(10));
335        let slot_clone = slot.clone();
336        let handle3 = thread::spawn(move || {
337            let _lock = slot_clone.messages.lock().unwrap();
338            panic!("force panic to poison PerClientSlot Mutex");
339        });
340        let _ = handle3.join();
341
342        // Now test push/drain/len on the poisoned slot should not panic and should behave correctly
343        assert!(!slot.push(WeixinMessage::default()));
344        assert_eq!(slot.len(), 1);
345        assert_eq!(slot.drain().len(), 1);
346        assert_eq!(slot.len(), 0);
347
348        // Push multiple messages into the poisoned slot
349        for i in 0..5 {
350            let msg = WeixinMessage {
351                message_id: Some(i),
352                ..Default::default()
353            };
354            slot.push(msg);
355        }
356        assert_eq!(slot.len(), 5);
357        let drained = slot.drain();
358        assert_eq!(drained.len(), 5);
359        assert_eq!(drained[0].message_id, Some(0));
360        assert_eq!(slot.len(), 0);
361
362        // Concurrent adversarial test on poisoned PerClientSlot
363        let mut slot_handles = vec![];
364        for thread_idx in 0..10 {
365            let slot_thread = slot.clone();
366            slot_handles.push(thread::spawn(move || {
367                for i in 0..50 {
368                    let msg = WeixinMessage {
369                        message_id: Some(thread_idx * 100 + i),
370                        ..Default::default()
371                    };
372                    slot_thread.push(msg);
373                    let drained = slot_thread.drain();
374                    for m in drained {
375                        assert!(m.message_id.is_some());
376                    }
377                }
378            }));
379        }
380        for h in slot_handles {
381            h.join().unwrap();
382        }
383    }
384
385    struct AlwaysFalseQueue;
386
387    #[async_trait::async_trait]
388    impl crate::MessageQueue for AlwaysFalseQueue {
389        async fn push(
390            &self,
391            _vtoken: &str,
392            _msg: crate::ilink::types::WeixinMessage,
393        ) -> Result<bool, crate::error::HubError> {
394            Ok(false)
395        }
396        async fn drain(
397            &self,
398            _vtoken: &str,
399        ) -> Result<Vec<crate::ilink::types::WeixinMessage>, crate::error::HubError> {
400            Ok(vec![])
401        }
402        async fn wait_notify(
403            &self,
404            _vtoken: &str,
405            _timeout_secs: u64,
406        ) -> Result<bool, crate::error::HubError> {
407            Ok(false)
408        }
409        async fn remove_client(&self, _vtoken: &str) -> Result<(), crate::error::HubError> {
410            Ok(())
411        }
412        async fn queue_sizes(
413            &self,
414        ) -> Result<std::collections::HashMap<String, usize>, crate::error::HubError> {
415            Ok(std::collections::HashMap::new())
416        }
417    }
418
419    struct AlwaysTrueQueue;
420
421    #[async_trait::async_trait]
422    impl crate::MessageQueue for AlwaysTrueQueue {
423        async fn push(
424            &self,
425            _vtoken: &str,
426            _msg: crate::ilink::types::WeixinMessage,
427        ) -> Result<bool, crate::error::HubError> {
428            Ok(true)
429        }
430        async fn drain(
431            &self,
432            _vtoken: &str,
433        ) -> Result<Vec<crate::ilink::types::WeixinMessage>, crate::error::HubError> {
434            Ok(vec![])
435        }
436        async fn wait_notify(
437            &self,
438            _vtoken: &str,
439            _timeout_secs: u64,
440        ) -> Result<bool, crate::error::HubError> {
441            Ok(false)
442        }
443        async fn remove_client(&self, _vtoken: &str) -> Result<(), crate::error::HubError> {
444            Ok(())
445        }
446        async fn queue_sizes(
447            &self,
448        ) -> Result<std::collections::HashMap<String, usize>, crate::error::HubError> {
449            Ok(std::collections::HashMap::new())
450        }
451    }
452
453    #[tokio::test]
454    async fn push_shared_default_propagates_false_from_push() {
455        let queue = AlwaysFalseQueue;
456        let base = Arc::new(WeixinMessage::default());
457        let result = queue.push_shared("v1", base, None, None).await.unwrap();
458        assert!(
459            !result,
460            "push_shared default impl must propagate Ok(false) from push()"
461        );
462    }
463
464    #[tokio::test]
465    async fn push_shared_default_propagates_true_from_push() {
466        let queue = AlwaysTrueQueue;
467        let base = Arc::new(WeixinMessage::default());
468        let result = queue.push_shared("v1", base, None, None).await.unwrap();
469        assert!(
470            result,
471            "push_shared default impl must propagate Ok(true) from push()"
472        );
473    }
474}