1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use thiserror::Error;
6
7use crate::wagon;
8
9#[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#[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#[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(()) }
161 }
162}
163
164#[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#[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 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}