1use async_trait::async_trait;
25use std::sync::Arc;
26
27use crate::{
28 config::ServerConfig,
29 jobs::{
30 Job, NotificationSend, RelayerHealthCheck, TokenSwapRequest, TransactionRequest,
31 TransactionSend, TransactionStatusCheck,
32 },
33 models::DefaultAppState,
34 utils::RedisConnections,
35};
36use actix_web::web::ThinData;
37
38pub mod cron;
39pub mod errors;
40pub mod pubsub;
41pub mod queue_type;
42pub mod redis;
43pub mod retry_config;
44pub mod sqs;
45pub mod swap_filter;
46pub mod worker_types;
47
48pub use errors::QueueBackendError;
49pub use queue_type::QueueType;
50pub use redis::queue::Queue;
51pub use retry_config::{backoff_config_for_queue, retry_delay_secs, status_check_retry_delay_secs};
52pub use swap_filter::filter_relayers_for_swap;
53pub use worker_types::{HandlerError, QueueHealth, WorkerContext, WorkerHandle};
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum QueueBackendType {
58 Redis,
59 Sqs,
60 PubSub,
61}
62
63impl QueueBackendType {
64 pub const fn as_str(self) -> &'static str {
65 match self {
66 Self::Redis => "redis",
67 Self::Sqs => "sqs",
68 Self::PubSub => "pubsub",
69 }
70 }
71}
72
73impl std::fmt::Display for QueueBackendType {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.write_str(self.as_str())
76 }
77}
78
79#[async_trait]
87pub trait QueueBackend: Send + Sync {
88 async fn produce_transaction_request(
97 &self,
98 job: Job<TransactionRequest>,
99 scheduled_on: Option<i64>,
100 ) -> Result<String, QueueBackendError>;
101
102 async fn produce_transaction_submission(
104 &self,
105 job: Job<TransactionSend>,
106 scheduled_on: Option<i64>,
107 ) -> Result<String, QueueBackendError>;
108
109 async fn produce_transaction_status_check(
111 &self,
112 job: Job<TransactionStatusCheck>,
113 scheduled_on: Option<i64>,
114 ) -> Result<String, QueueBackendError>;
115
116 async fn produce_notification(
118 &self,
119 job: Job<NotificationSend>,
120 scheduled_on: Option<i64>,
121 ) -> Result<String, QueueBackendError>;
122
123 async fn produce_token_swap_request(
125 &self,
126 job: Job<TokenSwapRequest>,
127 scheduled_on: Option<i64>,
128 ) -> Result<String, QueueBackendError>;
129
130 async fn produce_relayer_health_check(
132 &self,
133 job: Job<RelayerHealthCheck>,
134 scheduled_on: Option<i64>,
135 ) -> Result<String, QueueBackendError>;
136
137 async fn initialize_workers(
152 &self,
153 app_state: Arc<ThinData<DefaultAppState>>,
154 handle: tokio::runtime::Handle,
155 ) -> Result<Vec<WorkerHandle>, QueueBackendError>;
156
157 async fn health_check(&self) -> Result<Vec<QueueHealth>, QueueBackendError>;
162
163 fn backend_type(&self) -> QueueBackendType;
165
166 fn shutdown(&self) {}
173}
174
175#[derive(Clone)]
181pub enum QueueBackendStorage {
182 Redis(Box<redis::backend::RedisBackend>),
183 Sqs(sqs::backend::SqsBackend),
184 PubSub(Box<pubsub::backend::PubSubBackend>),
185}
186
187impl std::fmt::Debug for QueueBackendStorage {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 match self {
190 Self::Redis(b) => std::fmt::Debug::fmt(b, f),
191 Self::Sqs(b) => std::fmt::Debug::fmt(b, f),
192 Self::PubSub(b) => std::fmt::Debug::fmt(b, f),
193 }
194 }
195}
196
197impl QueueBackendStorage {
198 pub fn queue(&self) -> Option<&Queue> {
203 match self {
204 Self::Redis(b) => Some(b.queue()),
205 Self::Sqs(_) | Self::PubSub(_) => None,
206 }
207 }
208
209 pub fn redis_connections(&self) -> Option<Arc<RedisConnections>> {
213 self.queue().map(|q| q.redis_connections())
214 }
215}
216
217#[async_trait]
218impl QueueBackend for QueueBackendStorage {
219 async fn produce_transaction_request(
220 &self,
221 job: Job<TransactionRequest>,
222 scheduled_on: Option<i64>,
223 ) -> Result<String, QueueBackendError> {
224 match self {
225 Self::Redis(b) => b.produce_transaction_request(job, scheduled_on).await,
226 Self::Sqs(b) => b.produce_transaction_request(job, scheduled_on).await,
227 Self::PubSub(b) => b.produce_transaction_request(job, scheduled_on).await,
228 }
229 }
230
231 async fn produce_transaction_submission(
232 &self,
233 job: Job<TransactionSend>,
234 scheduled_on: Option<i64>,
235 ) -> Result<String, QueueBackendError> {
236 match self {
237 Self::Redis(b) => b.produce_transaction_submission(job, scheduled_on).await,
238 Self::Sqs(b) => b.produce_transaction_submission(job, scheduled_on).await,
239 Self::PubSub(b) => b.produce_transaction_submission(job, scheduled_on).await,
240 }
241 }
242
243 async fn produce_transaction_status_check(
244 &self,
245 job: Job<TransactionStatusCheck>,
246 scheduled_on: Option<i64>,
247 ) -> Result<String, QueueBackendError> {
248 match self {
249 Self::Redis(b) => b.produce_transaction_status_check(job, scheduled_on).await,
250 Self::Sqs(b) => b.produce_transaction_status_check(job, scheduled_on).await,
251 Self::PubSub(b) => b.produce_transaction_status_check(job, scheduled_on).await,
252 }
253 }
254
255 async fn produce_notification(
256 &self,
257 job: Job<NotificationSend>,
258 scheduled_on: Option<i64>,
259 ) -> Result<String, QueueBackendError> {
260 match self {
261 Self::Redis(b) => b.produce_notification(job, scheduled_on).await,
262 Self::Sqs(b) => b.produce_notification(job, scheduled_on).await,
263 Self::PubSub(b) => b.produce_notification(job, scheduled_on).await,
264 }
265 }
266
267 async fn produce_token_swap_request(
268 &self,
269 job: Job<TokenSwapRequest>,
270 scheduled_on: Option<i64>,
271 ) -> Result<String, QueueBackendError> {
272 match self {
273 Self::Redis(b) => b.produce_token_swap_request(job, scheduled_on).await,
274 Self::Sqs(b) => b.produce_token_swap_request(job, scheduled_on).await,
275 Self::PubSub(b) => b.produce_token_swap_request(job, scheduled_on).await,
276 }
277 }
278
279 async fn produce_relayer_health_check(
280 &self,
281 job: Job<RelayerHealthCheck>,
282 scheduled_on: Option<i64>,
283 ) -> Result<String, QueueBackendError> {
284 match self {
285 Self::Redis(b) => b.produce_relayer_health_check(job, scheduled_on).await,
286 Self::Sqs(b) => b.produce_relayer_health_check(job, scheduled_on).await,
287 Self::PubSub(b) => b.produce_relayer_health_check(job, scheduled_on).await,
288 }
289 }
290
291 async fn initialize_workers(
292 &self,
293 app_state: Arc<ThinData<DefaultAppState>>,
294 handle: tokio::runtime::Handle,
295 ) -> Result<Vec<WorkerHandle>, QueueBackendError> {
296 match self {
297 Self::Redis(b) => b.initialize_workers(app_state, handle).await,
298 Self::Sqs(b) => b.initialize_workers(app_state, handle).await,
299 Self::PubSub(b) => b.initialize_workers(app_state, handle).await,
300 }
301 }
302
303 async fn health_check(&self) -> Result<Vec<QueueHealth>, QueueBackendError> {
304 match self {
305 Self::Redis(b) => b.health_check().await,
306 Self::Sqs(b) => b.health_check().await,
307 Self::PubSub(b) => b.health_check().await,
308 }
309 }
310
311 fn backend_type(&self) -> QueueBackendType {
312 match self {
313 Self::Redis(b) => b.backend_type(),
314 Self::Sqs(b) => b.backend_type(),
315 Self::PubSub(b) => b.backend_type(),
316 }
317 }
318
319 fn shutdown(&self) {
320 match self {
321 Self::Redis(b) => b.shutdown(),
322 Self::Sqs(b) => b.shutdown(),
323 Self::PubSub(b) => b.shutdown(),
324 }
325 }
326}
327
328pub async fn create_queue_backend(
344 redis_connections: Arc<RedisConnections>,
345) -> Result<Arc<QueueBackendStorage>, QueueBackendError> {
346 let backend_type = ServerConfig::get_queue_backend();
347
348 let storage = match backend_type.to_lowercase().as_str() {
349 "redis" => {
350 let backend = redis::backend::RedisBackend::new(redis_connections).await?;
351 QueueBackendStorage::Redis(Box::new(backend))
352 }
353 "sqs" => {
354 let backend = sqs::backend::SqsBackend::new().await?;
355 QueueBackendStorage::Sqs(backend)
356 }
357 "pubsub" | "gcp-pubsub" => {
358 let backend = pubsub::backend::PubSubBackend::new(redis_connections).await?;
359 QueueBackendStorage::PubSub(Box::new(backend))
360 }
361 other => {
362 return Err(QueueBackendError::ConfigError(format!(
363 "Unsupported QUEUE_BACKEND value: {other}. Must be 'redis', 'sqs', or 'pubsub' (alias 'gcp-pubsub')"
364 )));
365 }
366 };
367
368 Ok(Arc::new(storage))
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn test_queue_type_enum_values() {
377 let types = vec![
379 QueueType::TransactionRequest,
380 QueueType::TransactionSubmission,
381 QueueType::StatusCheck,
382 QueueType::StatusCheckEvm,
383 QueueType::StatusCheckStellar,
384 QueueType::Notification,
385 QueueType::TokenSwapRequest,
386 QueueType::RelayerHealthCheck,
387 ];
388
389 for queue_type in types {
390 assert!(!queue_type.queue_name().is_empty());
391 assert!(!queue_type.redis_namespace().is_empty());
392 }
393 }
394
395 #[test]
396 fn test_queue_type_visibility_timeouts_in_range() {
397 let all_types = [
399 QueueType::TransactionRequest,
400 QueueType::TransactionSubmission,
401 QueueType::StatusCheck,
402 QueueType::StatusCheckEvm,
403 QueueType::StatusCheckStellar,
404 QueueType::Notification,
405 QueueType::TokenSwapRequest,
406 QueueType::RelayerHealthCheck,
407 ];
408 for qt in all_types {
409 let vt = qt.visibility_timeout_secs();
410 assert!(vt > 0, "{qt}: visibility timeout must be > 0");
411 assert!(
412 vt <= 43200,
413 "{qt}: visibility timeout {vt}s exceeds SQS max (43200s)"
414 );
415 }
416 }
417
418 #[test]
419 fn test_queue_type_polling_intervals_appropriate() {
420 assert_eq!(QueueType::StatusCheck.default_wait_time_secs(), 5);
422
423 assert!(QueueType::TransactionRequest.default_wait_time_secs() >= 5);
425 assert!(QueueType::TransactionSubmission.default_wait_time_secs() >= 5);
426 assert!(QueueType::Notification.default_wait_time_secs() >= 10);
427 }
428
429 #[test]
430 fn test_queue_backend_error_variants() {
431 let errors = vec![
432 QueueBackendError::RedisError("test".to_string()),
433 QueueBackendError::SqsError("test".to_string()),
434 QueueBackendError::SerializationError("test".to_string()),
435 QueueBackendError::ConfigError("test".to_string()),
436 QueueBackendError::QueueNotFound("test".to_string()),
437 QueueBackendError::WorkerInitError("test".to_string()),
438 QueueBackendError::QueueError("test".to_string()),
439 ];
440
441 for error in errors {
442 let error_str = error.to_string();
443 assert!(!error_str.is_empty());
444 }
445 }
446
447 #[test]
448 fn test_queue_backend_type_string_representations() {
449 assert_eq!(QueueBackendType::Redis.as_str(), "redis");
450 assert_eq!(QueueBackendType::Sqs.as_str(), "sqs");
451 assert_eq!(QueueBackendType::PubSub.as_str(), "pubsub");
452 assert_eq!(QueueBackendType::Redis.to_string(), "redis");
453 assert_eq!(QueueBackendType::Sqs.to_string(), "sqs");
454 assert_eq!(QueueBackendType::PubSub.to_string(), "pubsub");
455 }
456
457 use std::sync::Mutex as StdMutex;
463 static BACKEND_ENV_LOCK: StdMutex<()> = StdMutex::new(());
464
465 fn dummy_redis_connections() -> Arc<RedisConnections> {
466 let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:6379")
468 .builder()
469 .expect("pool builder")
470 .max_size(1)
471 .runtime(deadpool_redis::Runtime::Tokio1)
472 .build()
473 .expect("pool build");
474 Arc::new(RedisConnections::new_single_pool(Arc::new(pool)))
475 }
476
477 #[tokio::test]
478 async fn test_create_queue_backend_rejects_unsupported_value() {
479 let _lock = BACKEND_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
480 let prev = std::env::var("QUEUE_BACKEND").ok();
481
482 std::env::set_var("QUEUE_BACKEND", "kafka-not-real");
483 let result = create_queue_backend(dummy_redis_connections()).await;
484
485 match prev {
486 Some(v) => std::env::set_var("QUEUE_BACKEND", v),
487 None => std::env::remove_var("QUEUE_BACKEND"),
488 }
489
490 let err = result
491 .expect_err("unsupported backend must error")
492 .to_string();
493 assert!(
494 err.contains("Unsupported QUEUE_BACKEND"),
495 "expected unsupported error, got: {err}"
496 );
497 assert!(err.contains("pubsub"), "error should list pubsub: {err}");
499 }
500
501 #[tokio::test]
504 async fn test_redis_and_sqs_backends_still_recognized() {
505 let _lock = BACKEND_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
506 let prev = std::env::var("QUEUE_BACKEND").ok();
507
508 std::env::set_var("QUEUE_BACKEND", "definitely-not-a-backend");
509 let err = create_queue_backend(dummy_redis_connections())
510 .await
511 .expect_err("unsupported value must error")
512 .to_string();
513
514 match prev {
515 Some(v) => std::env::set_var("QUEUE_BACKEND", v),
516 None => std::env::remove_var("QUEUE_BACKEND"),
517 }
518
519 assert!(err.contains("redis"), "redis must remain selectable: {err}");
522 assert!(err.contains("sqs"), "sqs must remain selectable: {err}");
523 assert!(err.contains("pubsub"), "pubsub must be selectable: {err}");
524 }
525
526 #[tokio::test]
527 async fn test_create_queue_backend_routes_pubsub_aliases() {
528 let _lock = BACKEND_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
529 let prev_backend = std::env::var("QUEUE_BACKEND").ok();
530 let prev_project = std::env::var("PUBSUB_PROJECT_ID").ok();
531
532 std::env::remove_var("PUBSUB_PROJECT_ID");
534
535 for alias in ["pubsub", "gcp-pubsub"] {
536 std::env::set_var("QUEUE_BACKEND", alias);
537 let err = create_queue_backend(dummy_redis_connections())
538 .await
539 .expect_err("missing PUBSUB_PROJECT_ID must error")
540 .to_string();
541 assert!(
543 !err.contains("Unsupported QUEUE_BACKEND"),
544 "alias '{alias}' should route to the Pub/Sub backend, got: {err}"
545 );
546 assert!(
547 err.contains("PUBSUB_PROJECT_ID"),
548 "alias '{alias}' should fail fast on missing project id, got: {err}"
549 );
550 }
551
552 match prev_backend {
553 Some(v) => std::env::set_var("QUEUE_BACKEND", v),
554 None => std::env::remove_var("QUEUE_BACKEND"),
555 }
556 if let Some(v) = prev_project {
557 std::env::set_var("PUBSUB_PROJECT_ID", v);
558 }
559 }
560}