Skip to main content

caravan_rpc/resources/
queue.rs

1//! MessageQueue seam + Redis / RabbitMQ / SQS impls.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use thiserror::Error;
6
7use crate::wagon;
8
9/// Errors from the MessageQueue seam. Serde-serializable for over-the-
10/// wire dispatch; `serde_json::Error` is converted to `Json(String)` at
11/// construction time.
12#[derive(Debug, Error, Serialize, Deserialize, Clone)]
13pub enum QueueError {
14    #[error("JSON error: {0}")]
15    Json(String),
16    #[error("Redis error: {0}")]
17    Redis(String),
18    #[error("RabbitMQ error: {0}")]
19    RabbitMQ(String),
20    #[error("SQS error: {0}")]
21    Sqs(String),
22    #[error("invalid URL: {0}")]
23    InvalidUrl(String),
24}
25
26impl From<serde_json::Error> for QueueError {
27    fn from(e: serde_json::Error) -> Self {
28        QueueError::Json(e.to_string())
29    }
30}
31
32/// Caravan-owned seam for at-least-once message queues. Decorated with
33/// `#[wagon]` so callers dispatch via `client::<dyn MessageQueue>()`.
34///
35/// The `topic:` parameter is a portability device: Redis Streams +
36/// RabbitMQ use it as a queue/stream name; SQS ignores it (each SQS
37/// queue URL is its own substrate).
38#[wagon]
39pub trait MessageQueue: Send + Sync {
40    fn publish(&self, topic: &str, message: &Value) -> Result<String, QueueError>;
41    fn consume(
42        &self,
43        topic: &str,
44        count: usize,
45        block_ms: usize,
46    ) -> Result<Vec<(String, Value)>, QueueError>;
47    fn ack(&self, topic: &str, message_id: &str) -> Result<(), QueueError>;
48    fn extend_visibility(
49        &self,
50        topic: &str,
51        message_id: &str,
52        seconds: u64,
53    ) -> Result<(), QueueError>;
54}
55
56// --- Redis Streams impl (gated behind `resources-redis`) -------------------
57
58#[cfg(feature = "resources-redis")]
59pub use redis_impl::RedisStreamQueue;
60
61#[cfg(feature = "resources-redis")]
62mod redis_impl {
63    use redis::streams::{StreamReadOptions, StreamReadReply};
64    use redis::{Commands, RedisError};
65    use serde_json::Value;
66
67    use super::{MessageQueue, QueueError};
68
69    impl From<RedisError> for QueueError {
70        fn from(e: RedisError) -> Self {
71            QueueError::Redis(e.to_string())
72        }
73    }
74
75    pub struct RedisStreamQueue {
76        client: redis::Client,
77        consumer_group: String,
78        consumer_name: String,
79    }
80
81    impl RedisStreamQueue {
82        pub fn new(url: &str, consumer_group: &str) -> Result<Self, QueueError> {
83            let client = redis::Client::open(url).map_err(QueueError::from)?;
84            let consumer_name = format!("worker-{}", &uuid::Uuid::new_v4().to_string()[..8]);
85            Ok(Self {
86                client,
87                consumer_group: consumer_group.to_string(),
88                consumer_name,
89            })
90        }
91
92        fn ensure_group(
93            &self,
94            conn: &mut redis::Connection,
95            topic: &str,
96        ) -> Result<(), QueueError> {
97            let result: Result<(), RedisError> = redis::cmd("XGROUP")
98                .arg("CREATE")
99                .arg(topic)
100                .arg(&self.consumer_group)
101                .arg("0")
102                .arg("MKSTREAM")
103                .query(conn);
104            match result {
105                Ok(()) => Ok(()),
106                Err(e) if e.to_string().contains("BUSYGROUP") => Ok(()),
107                Err(e) => Err(QueueError::Redis(e.to_string())),
108            }
109        }
110    }
111
112    impl MessageQueue for RedisStreamQueue {
113        fn publish(&self, topic: &str, message: &Value) -> Result<String, QueueError> {
114            let mut conn = self.client.get_connection()?;
115            self.ensure_group(&mut conn, topic)?;
116            let payload = serde_json::to_string(message)?;
117            let id: String = conn.xadd(topic, "*", &[("data", &payload)])?;
118            Ok(id)
119        }
120
121        fn consume(
122            &self,
123            topic: &str,
124            count: usize,
125            block_ms: usize,
126        ) -> Result<Vec<(String, Value)>, QueueError> {
127            let mut conn = self.client.get_connection()?;
128            self.ensure_group(&mut conn, topic)?;
129            let opts = StreamReadOptions::default()
130                .group(&self.consumer_group, &self.consumer_name)
131                .count(count)
132                .block(block_ms);
133            let reply: StreamReadReply = conn.xread_options(&[topic], &[">"], &opts)?;
134            let mut messages = Vec::new();
135            for key in reply.keys {
136                for entry in key.ids {
137                    if let Some(redis::Value::BulkString(bytes)) = entry.map.get("data") {
138                        let data_str = String::from_utf8_lossy(bytes);
139                        let value: Value = serde_json::from_str(&data_str)?;
140                        messages.push((entry.id, value));
141                    }
142                }
143            }
144            Ok(messages)
145        }
146
147        fn ack(&self, topic: &str, message_id: &str) -> Result<(), QueueError> {
148            let mut conn = self.client.get_connection()?;
149            let _: () = conn.xack(topic, &self.consumer_group, &[message_id])?;
150            Ok(())
151        }
152
153        fn extend_visibility(
154            &self,
155            _topic: &str,
156            _message_id: &str,
157            _seconds: u64,
158        ) -> Result<(), QueueError> {
159            Ok(()) // No SQS-style visibility timeout in Redis Streams.
160        }
161    }
162}
163
164// --- RabbitMQ impl (gated behind `resources-rabbit`) -----------------------
165
166#[cfg(feature = "resources-rabbit")]
167pub use rabbit_impl::RabbitMQQueue;
168
169#[cfg(feature = "resources-rabbit")]
170mod rabbit_impl {
171    use std::sync::Mutex;
172
173    use serde_json::Value;
174
175    use super::{MessageQueue, QueueError};
176
177    pub struct RabbitMQQueue {
178        url: String,
179        state: Mutex<RabbitMQState>,
180    }
181
182    struct RabbitMQState {
183        connection: Option<lapin::Connection>,
184        channel: Option<lapin::Channel>,
185        declared: std::collections::HashSet<String>,
186    }
187
188    impl RabbitMQQueue {
189        pub fn new(url: &str) -> Result<Self, QueueError> {
190            let scheme = url::Url::parse(url)
191                .map_err(|e| QueueError::InvalidUrl(e.to_string()))?
192                .scheme()
193                .to_string();
194            if scheme != "amqp" && scheme != "amqps" {
195                return Err(QueueError::InvalidUrl(format!(
196                    "RabbitMQQueue expects amqp(s)://; got {scheme}"
197                )));
198            }
199            Ok(Self {
200                url: url.to_string(),
201                state: Mutex::new(RabbitMQState {
202                    connection: None,
203                    channel: None,
204                    declared: std::collections::HashSet::new(),
205                }),
206            })
207        }
208
209        fn ensure_channel(&self) -> Result<lapin::Channel, QueueError> {
210            let mut state = self.state.lock().unwrap();
211            if let Some(ch) = &state.channel {
212                if ch.status().connected() {
213                    return Ok(ch.clone());
214                }
215            }
216            let url = self.url.clone();
217            let (conn, ch) = tokio::task::block_in_place(|| {
218                tokio::runtime::Handle::current().block_on(async {
219                    let conn =
220                        lapin::Connection::connect(&url, lapin::ConnectionProperties::default())
221                            .await
222                            .map_err(|e| QueueError::RabbitMQ(e.to_string()))?;
223                    let ch = conn
224                        .create_channel()
225                        .await
226                        .map_err(|e| QueueError::RabbitMQ(e.to_string()))?;
227                    Ok::<_, QueueError>((conn, ch))
228                })
229            })?;
230            state.connection = Some(conn);
231            state.channel = Some(ch.clone());
232            state.declared.clear();
233            Ok(ch)
234        }
235
236        fn ensure_queue(&self, ch: &lapin::Channel, topic: &str) -> Result<(), QueueError> {
237            {
238                let state = self.state.lock().unwrap();
239                if state.declared.contains(topic) {
240                    return Ok(());
241                }
242            }
243            let topic_owned = topic.to_string();
244            let ch_clone = ch.clone();
245            tokio::task::block_in_place(|| {
246                tokio::runtime::Handle::current().block_on(async {
247                    ch_clone
248                        .queue_declare(
249                            &topic_owned,
250                            lapin::options::QueueDeclareOptions {
251                                durable: true,
252                                ..Default::default()
253                            },
254                            lapin::types::FieldTable::default(),
255                        )
256                        .await
257                        .map_err(|e| QueueError::RabbitMQ(e.to_string()))
258                        .map(|_| ())
259                })
260            })?;
261            let mut state = self.state.lock().unwrap();
262            state.declared.insert(topic.to_string());
263            Ok(())
264        }
265    }
266
267    impl MessageQueue for RabbitMQQueue {
268        fn publish(&self, topic: &str, message: &Value) -> Result<String, QueueError> {
269            let ch = self.ensure_channel()?;
270            self.ensure_queue(&ch, topic)?;
271            let body = serde_json::to_vec(message)?;
272            let msg_id = uuid::Uuid::new_v4().to_string().replace('-', "");
273            let topic_owned = topic.to_string();
274            let msg_id_owned = msg_id.clone();
275            tokio::task::block_in_place(|| {
276                tokio::runtime::Handle::current().block_on(async {
277                    ch.basic_publish(
278                        "",
279                        &topic_owned,
280                        lapin::options::BasicPublishOptions::default(),
281                        &body,
282                        lapin::BasicProperties::default()
283                            .with_message_id(msg_id_owned.into())
284                            .with_delivery_mode(2)
285                            .with_content_type("application/json".into()),
286                    )
287                    .await
288                    .map_err(|e| QueueError::RabbitMQ(e.to_string()))?
289                    .await
290                    .map_err(|e| QueueError::RabbitMQ(e.to_string()))?;
291                    Ok::<_, QueueError>(())
292                })
293            })?;
294            Ok(msg_id)
295        }
296
297        fn consume(
298            &self,
299            topic: &str,
300            count: usize,
301            block_ms: usize,
302        ) -> Result<Vec<(String, Value)>, QueueError> {
303            let ch = self.ensure_channel()?;
304            self.ensure_queue(&ch, topic)?;
305            let deadline =
306                std::time::Instant::now() + std::time::Duration::from_millis(block_ms as u64);
307            let topic_owned = topic.to_string();
308            tokio::task::block_in_place(|| {
309                tokio::runtime::Handle::current().block_on(async {
310                    let mut out: Vec<(String, Value)> = Vec::new();
311                    while out.len() < count {
312                        if std::time::Instant::now() >= deadline {
313                            break;
314                        }
315                        let msg = ch
316                            .basic_get(&topic_owned, lapin::options::BasicGetOptions::default())
317                            .await
318                            .map_err(|e| QueueError::RabbitMQ(e.to_string()))?;
319                        match msg {
320                            Some(delivery) => {
321                                let payload: Value = serde_json::from_slice(&delivery.data)?;
322                                out.push((delivery.delivery_tag.to_string(), payload));
323                            }
324                            None => {
325                                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
326                            }
327                        }
328                    }
329                    Ok::<_, QueueError>(out)
330                })
331            })
332        }
333
334        fn ack(&self, _topic: &str, message_id: &str) -> Result<(), QueueError> {
335            let ch = self.ensure_channel()?;
336            let delivery_tag: u64 = message_id
337                .parse()
338                .map_err(|e: std::num::ParseIntError| QueueError::RabbitMQ(e.to_string()))?;
339            tokio::task::block_in_place(|| {
340                tokio::runtime::Handle::current().block_on(async {
341                    ch.basic_ack(delivery_tag, lapin::options::BasicAckOptions::default())
342                        .await
343                        .map_err(|e| QueueError::RabbitMQ(e.to_string()))
344                })
345            })
346        }
347
348        fn extend_visibility(
349            &self,
350            _topic: &str,
351            _message_id: &str,
352            _seconds: u64,
353        ) -> Result<(), QueueError> {
354            Ok(())
355        }
356    }
357}
358
359// --- SQS impl (gated behind `resources-aws`) -------------------------------
360
361#[cfg(feature = "resources-aws")]
362pub use sqs_impl::SqsQueue;
363
364#[cfg(feature = "resources-aws")]
365mod sqs_impl {
366    use serde_json::Value;
367
368    use super::{MessageQueue, QueueError};
369
370    /// AWS SQS impl via aws-sdk-sqs. Single-queue per URL.
371    pub struct SqsQueue {
372        client: aws_sdk_sqs::Client,
373        queue_url: String,
374    }
375
376    impl SqsQueue {
377        pub fn from_url(url: &str) -> Result<Self, QueueError> {
378            let scheme = url::Url::parse(url)
379                .map_err(|e| QueueError::InvalidUrl(e.to_string()))?
380                .scheme()
381                .to_string();
382            if scheme != "http" && scheme != "https" {
383                return Err(QueueError::InvalidUrl(format!(
384                    "SqsQueue expects https://sqs.<region>.amazonaws.com/...; got {scheme}"
385                )));
386            }
387            let client = tokio::task::block_in_place(|| {
388                tokio::runtime::Handle::current().block_on(async {
389                    let cfg = aws_config::defaults(aws_config::BehaviorVersion::latest())
390                        .load()
391                        .await;
392                    aws_sdk_sqs::Client::new(&cfg)
393                })
394            });
395            Ok(Self {
396                client,
397                queue_url: url.to_string(),
398            })
399        }
400
401        pub fn from_env() -> Result<Self, QueueError> {
402            let url = std::env::var("QUEUE_URL")
403                .map_err(|_| QueueError::Sqs("SqsQueue::from_env requires QUEUE_URL".into()))?;
404            Self::from_url(&url)
405        }
406    }
407
408    impl MessageQueue for SqsQueue {
409        fn publish(&self, _topic: &str, message: &Value) -> Result<String, QueueError> {
410            let body = serde_json::to_string(message)?;
411            let queue_url = self.queue_url.clone();
412            let client = self.client.clone();
413            tokio::task::block_in_place(|| {
414                tokio::runtime::Handle::current().block_on(async {
415                    let resp = client
416                        .send_message()
417                        .queue_url(&queue_url)
418                        .message_body(body)
419                        .send()
420                        .await
421                        .map_err(|e| QueueError::Sqs(e.to_string()))?;
422                    Ok(resp.message_id().unwrap_or_default().to_string())
423                })
424            })
425        }
426
427        fn consume(
428            &self,
429            _topic: &str,
430            count: usize,
431            block_ms: usize,
432        ) -> Result<Vec<(String, Value)>, QueueError> {
433            let wait_seconds = (block_ms / 1000).clamp(0, 20) as i32;
434            let max_msgs = count.min(10) as i32;
435            let queue_url = self.queue_url.clone();
436            let client = self.client.clone();
437            tokio::task::block_in_place(|| {
438                tokio::runtime::Handle::current().block_on(async {
439                    let resp = client
440                        .receive_message()
441                        .queue_url(&queue_url)
442                        .max_number_of_messages(max_msgs)
443                        .wait_time_seconds(wait_seconds)
444                        .send()
445                        .await
446                        .map_err(|e| QueueError::Sqs(e.to_string()))?;
447                    let mut out: Vec<(String, Value)> = Vec::new();
448                    for m in resp.messages.unwrap_or_default() {
449                        let handle = m
450                            .receipt_handle
451                            .ok_or_else(|| QueueError::Sqs("missing ReceiptHandle".into()))?;
452                        let body = m
453                            .body
454                            .ok_or_else(|| QueueError::Sqs("missing Body".into()))?;
455                        let value: Value = serde_json::from_str(&body)?;
456                        out.push((handle, value));
457                    }
458                    Ok(out)
459                })
460            })
461        }
462
463        fn ack(&self, _topic: &str, message_id: &str) -> Result<(), QueueError> {
464            let queue_url = self.queue_url.clone();
465            let handle = message_id.to_string();
466            let client = self.client.clone();
467            tokio::task::block_in_place(|| {
468                tokio::runtime::Handle::current().block_on(async {
469                    client
470                        .delete_message()
471                        .queue_url(&queue_url)
472                        .receipt_handle(handle)
473                        .send()
474                        .await
475                        .map_err(|e| QueueError::Sqs(e.to_string()))
476                        .map(|_| ())
477                })
478            })
479        }
480
481        fn extend_visibility(
482            &self,
483            _topic: &str,
484            message_id: &str,
485            seconds: u64,
486        ) -> Result<(), QueueError> {
487            let queue_url = self.queue_url.clone();
488            let handle = message_id.to_string();
489            let client = self.client.clone();
490            tokio::task::block_in_place(|| {
491                tokio::runtime::Handle::current().block_on(async {
492                    client
493                        .change_message_visibility()
494                        .queue_url(&queue_url)
495                        .receipt_handle(handle)
496                        .visibility_timeout(seconds as i32)
497                        .send()
498                        .await
499                        .map_err(|e| QueueError::Sqs(e.to_string()))
500                        .map(|_| ())
501                })
502            })
503        }
504    }
505}