1use std::future::Future;
7use std::panic::AssertUnwindSafe;
8use std::sync::Arc;
9use std::time::Duration;
10
11use actix_web::web::ThinData;
12use aws_sdk_sqs::error::{ProvideErrorMetadata, SdkError};
13use aws_sdk_sqs::types::{
14 DeleteMessageBatchRequestEntry, Message, MessageAttributeValue, MessageSystemAttributeName,
15};
16use futures::FutureExt;
17use serde::de::DeserializeOwned;
18use tokio::sync::watch;
19use tokio::task::{JoinHandle, JoinSet};
20use tracing::{debug, error, info, warn};
21
22use crate::queues::{backoff_config_for_queue, retry_delay_secs};
23use crate::{
24 config::ServerConfig,
25 jobs::{
26 notification_handler, relayer_health_check_handler, token_swap_request_handler,
27 transaction_request_handler, transaction_status_handler, transaction_submission_handler,
28 Job, NotificationSend, RelayerHealthCheck, TokenSwapRequest, TransactionRequest,
29 TransactionSend, TransactionStatusCheck,
30 },
31 utils::{aws_error::DisplayErrorContext, classify_sdk_error},
32};
33
34use super::{HandlerError, WorkerContext};
35use super::{QueueBackendError, QueueType, WorkerHandle};
36
37#[derive(Debug)]
38enum ProcessingError {
39 Retryable(String),
40 Permanent(String),
41}
42
43#[derive(Debug)]
46enum MessageOutcome {
47 Delete { receipt_handle: String },
49 Retain,
52}
53
54#[derive(Clone)]
57struct PollLoopConfig {
58 queue_type: QueueType,
59 polling_interval: u64,
60 visibility_timeout: u32,
61 handler_timeout: Duration,
62 max_retries: usize,
63 poller_id: usize,
64 poller_count: usize,
65}
66
67pub async fn spawn_worker_for_queue(
81 sqs_client: aws_sdk_sqs::Client,
82 queue_type: QueueType,
83 queue_url: String,
84 app_state: Arc<ThinData<crate::models::DefaultAppState>>,
85 shutdown_rx: watch::Receiver<bool>,
86) -> Result<WorkerHandle, QueueBackendError> {
87 let concurrency = get_concurrency_for_queue(queue_type);
88 let max_retries = queue_type.max_retries();
89 let polling_interval = get_wait_time_for_queue(queue_type);
90 let poller_count = get_poller_count_for_queue(queue_type);
91 let visibility_timeout = queue_type.visibility_timeout_secs();
92 let handler_timeout_secs = handler_timeout_secs(queue_type);
93 let handler_timeout = Duration::from_secs(handler_timeout_secs);
94
95 info!(
96 queue_type = ?queue_type,
97 queue_url = %queue_url,
98 concurrency = concurrency,
99 max_retries = max_retries,
100 polling_interval_secs = polling_interval,
101 poller_count = poller_count,
102 visibility_timeout_secs = visibility_timeout,
103 handler_timeout_secs = handler_timeout_secs,
104 "Spawning SQS worker"
105 );
106
107 let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
109
110 let handle: JoinHandle<()> = tokio::spawn(async move {
111 let mut poller_handles: JoinSet<()> = JoinSet::new();
112
113 for poller_id in 0..poller_count {
114 let client = sqs_client.clone();
115 let url = queue_url.clone();
116 let state = app_state.clone();
117 let sem = semaphore.clone();
118 let mut rx = shutdown_rx.clone();
119 let config = PollLoopConfig {
120 queue_type,
121 polling_interval,
122 visibility_timeout,
123 handler_timeout,
124 max_retries,
125 poller_id,
126 poller_count,
127 };
128
129 poller_handles.spawn(async move {
130 run_poll_loop(client, url, state, sem, &mut rx, config).await;
131 });
132 }
133
134 while let Some(join_result) = poller_handles.join_next().await {
136 if let Err(err) = join_result {
137 error!(
138 queue_type = ?queue_type,
139 error = %err,
140 "SQS poller task terminated unexpectedly"
141 );
142 }
143 }
144 info!(queue_type = ?queue_type, "SQS worker stopped");
145 });
146
147 Ok(WorkerHandle::Tokio(handle))
148}
149
150async fn run_poll_loop(
153 sqs_client: aws_sdk_sqs::Client,
154 queue_url: String,
155 app_state: Arc<ThinData<crate::models::DefaultAppState>>,
156 semaphore: Arc<tokio::sync::Semaphore>,
157 shutdown_rx: &mut watch::Receiver<bool>,
158 config: PollLoopConfig,
159) {
160 let PollLoopConfig {
161 queue_type,
162 polling_interval,
163 visibility_timeout,
164 handler_timeout,
165 max_retries,
166 poller_id,
167 poller_count,
168 } = config;
169 let mut inflight: JoinSet<Option<String>> = JoinSet::new();
170 let mut consecutive_poll_errors: u32 = 0;
171 let mut pending_deletes: Vec<String> = Vec::new();
172
173 loop {
174 while let Some(result) = inflight.try_join_next() {
176 match result {
177 Ok(Some(receipt_handle)) => pending_deletes.push(receipt_handle),
178 Ok(None) => {} Err(e) => {
180 warn!(
181 queue_type = ?queue_type,
182 poller_id = poller_id,
183 error = %e,
184 "In-flight task failed"
185 );
186 }
187 }
188 }
189
190 if !pending_deletes.is_empty() {
192 flush_delete_batch(&sqs_client, &queue_url, &pending_deletes, queue_type).await;
193 pending_deletes.clear();
194 }
195
196 if *shutdown_rx.borrow() {
198 info!(queue_type = ?queue_type, poller_id = poller_id, "Shutdown signal received, stopping SQS poller");
199 break;
200 }
201
202 let available_permits = semaphore.available_permits();
209 let base_share = available_permits / poller_count;
210 let remainder = available_permits % poller_count;
211 let my_share = base_share + usize::from(poller_id < remainder);
212 if my_share == 0 {
213 tokio::select! {
214 _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
215 _ = shutdown_rx.changed() => {
216 info!(queue_type = ?queue_type, poller_id = poller_id, "Shutdown signal received, stopping SQS poller");
217 break;
218 }
219 }
220 }
221
222 let batch_size = my_share.min(10) as i32;
224
225 let messages_result = tokio::select! {
227 result = sqs_client
228 .receive_message()
229 .queue_url(&queue_url)
230 .max_number_of_messages(batch_size) .wait_time_seconds(polling_interval as i32)
232 .visibility_timeout(visibility_timeout as i32)
233 .message_system_attribute_names(MessageSystemAttributeName::ApproximateReceiveCount)
234 .message_system_attribute_names(MessageSystemAttributeName::MessageGroupId)
235 .message_attribute_names("target_scheduled_on")
236 .message_attribute_names("retry_attempt")
237 .send() => result,
238 _ = shutdown_rx.changed() => {
239 info!(queue_type = ?queue_type, poller_id = poller_id, "Shutdown signal received during SQS poll, stopping poller");
240 break;
241 }
242 };
243
244 match messages_result {
245 Ok(output) => {
246 if consecutive_poll_errors > 0 {
247 info!(
248 queue_type = ?queue_type,
249 poller_id = poller_id,
250 previous_errors = consecutive_poll_errors,
251 "SQS polling recovered after consecutive errors"
252 );
253 }
254 consecutive_poll_errors = 0;
255
256 if let Some(messages) = output.messages {
257 if !messages.is_empty() {
258 debug!(
259 queue_type = ?queue_type,
260 poller_id = poller_id,
261 message_count = messages.len(),
262 "Received messages from SQS"
263 );
264
265 for message in messages {
267 let permit = match semaphore.clone().acquire_owned().await {
268 Ok(permit) => permit,
269 Err(err) => {
270 error!(
271 queue_type = ?queue_type,
272 poller_id = poller_id,
273 error = %err,
274 "Semaphore closed, stopping SQS poller loop"
275 );
276 return;
277 }
278 };
279 let client = sqs_client.clone();
280 let url = queue_url.clone();
281 let state = app_state.clone();
282
283 inflight.spawn(async move {
284 let _permit = permit; let result = tokio::time::timeout(
287 handler_timeout,
288 AssertUnwindSafe(process_message(
289 client.clone(),
290 message,
291 queue_type,
292 &url,
293 state,
294 max_retries,
295 ))
296 .catch_unwind(),
297 )
298 .await;
299
300 match result {
301 Ok(Ok(Ok(MessageOutcome::Delete { receipt_handle }))) => {
302 Some(receipt_handle)
303 }
304 Ok(Ok(Ok(MessageOutcome::Retain))) => None,
305 Ok(Ok(Err(e))) => {
306 error!(
307 queue_type = ?queue_type,
308 error = %e,
309 "Failed to process message"
310 );
311 None
312 }
313 Ok(Err(panic_info)) => {
314 let msg = panic_info
315 .downcast_ref::<String>()
316 .map(|s| s.as_str())
317 .or_else(|| {
318 panic_info.downcast_ref::<&str>().copied()
319 })
320 .unwrap_or("unknown panic");
321 error!(
322 queue_type = ?queue_type,
323 panic = %msg,
324 "Message handler panicked"
325 );
326 None
327 }
328 Err(_) => {
329 error!(
330 queue_type = ?queue_type,
331 timeout_secs = handler_timeout.as_secs(),
332 "Message handler timed out; message will be retried after visibility timeout"
333 );
334 None
335 }
336 }
337 });
338 }
339 }
340 }
341 }
342 Err(e) => {
343 consecutive_poll_errors = consecutive_poll_errors.saturating_add(1);
344 let backoff_secs = poll_error_backoff_secs(consecutive_poll_errors);
345 let (error_code, error_message) = match &e {
346 SdkError::ServiceError(ctx) => (ctx.err().code(), ctx.err().message()),
347 _ => (None, None),
348 };
349 error!(
350 queue_type = ?queue_type,
351 poller_id = poller_id,
352 error.kind = classify_sdk_error(&e),
353 error.detail = %DisplayErrorContext(&e),
354 error_code = error_code.unwrap_or("unknown"),
355 error_message = error_message.unwrap_or("n/a"),
356 consecutive_errors = consecutive_poll_errors,
357 backoff_secs = backoff_secs,
358 "Failed to receive messages from SQS, backing off"
359 );
360 tokio::select! {
361 _ = tokio::time::sleep(Duration::from_secs(backoff_secs)) => {}
362 _ = shutdown_rx.changed() => {
363 info!(queue_type = ?queue_type, poller_id = poller_id, "Shutdown signal received during backoff, stopping poller");
364 break;
365 }
366 }
367 }
368 }
369 }
370
371 if !inflight.is_empty() {
373 info!(
374 queue_type = ?queue_type,
375 poller_id = poller_id,
376 count = inflight.len(),
377 "Draining in-flight tasks before shutdown"
378 );
379 match tokio::time::timeout(Duration::from_secs(30), async {
380 while let Some(result) = inflight.join_next().await {
381 match result {
382 Ok(Some(receipt_handle)) => pending_deletes.push(receipt_handle),
383 Ok(None) => {}
384 Err(e) => {
385 warn!(
386 queue_type = ?queue_type,
387 poller_id = poller_id,
388 error = %e,
389 "In-flight task failed during drain"
390 );
391 }
392 }
393 }
394 })
395 .await
396 {
397 Ok(()) => {
398 info!(queue_type = ?queue_type, poller_id = poller_id, "All in-flight tasks drained")
399 }
400 Err(_) => {
401 warn!(
402 queue_type = ?queue_type,
403 poller_id = poller_id,
404 remaining = inflight.len(),
405 "Drain timeout, abandoning remaining tasks"
406 );
407 inflight.abort_all();
408 }
409 }
410 }
411
412 if !pending_deletes.is_empty() {
414 flush_delete_batch(&sqs_client, &queue_url, &pending_deletes, queue_type).await;
415 }
416}
417
418async fn process_message(
423 sqs_client: aws_sdk_sqs::Client,
424 message: Message,
425 queue_type: QueueType,
426 queue_url: &str,
427 app_state: Arc<ThinData<crate::models::DefaultAppState>>,
428 max_retries: usize,
429) -> Result<MessageOutcome, QueueBackendError> {
430 let body = message
431 .body()
432 .ok_or_else(|| QueueBackendError::QueueError("Empty message body".to_string()))?;
433
434 let receipt_handle = message
435 .receipt_handle()
436 .ok_or_else(|| QueueBackendError::QueueError("Missing receipt handle".to_string()))?;
437
438 if let Some(target_scheduled_on) = parse_target_scheduled_on(&message) {
440 let now = std::time::SystemTime::now()
441 .duration_since(std::time::SystemTime::UNIX_EPOCH)
442 .map_err(|e| QueueBackendError::QueueError(format!("System clock error: {e}")))?
443 .as_secs() as i64;
444 let remaining = target_scheduled_on - now;
445 if remaining > 0 {
446 let should_delete_original = defer_message(
447 &sqs_client,
448 queue_url,
449 body.to_string(),
450 &message,
451 target_scheduled_on,
452 remaining.min(900) as i32,
453 )
454 .await?;
455
456 debug!(
457 queue_type = ?queue_type,
458 remaining_seconds = remaining,
459 "Deferred scheduled SQS message for next delay hop"
460 );
461 return if should_delete_original {
462 Ok(MessageOutcome::Delete {
463 receipt_handle: receipt_handle.to_string(),
464 })
465 } else {
466 Ok(MessageOutcome::Retain)
467 };
468 }
469 }
470
471 let receive_count = message
473 .attributes()
474 .and_then(|attrs| attrs.get(&MessageSystemAttributeName::ApproximateReceiveCount))
475 .and_then(|count| count.parse::<usize>().ok())
476 .unwrap_or(1);
477 let attempt_number = receive_count.saturating_sub(1);
479 let logical_retry_attempt = parse_retry_attempt(&message).unwrap_or(attempt_number);
482
483 let sqs_message_id = message.message_id().unwrap_or("unknown").to_string();
485
486 debug!(
487 queue_type = ?queue_type,
488 message_id = %sqs_message_id,
489 attempt = attempt_number,
490 receive_count = receive_count,
491 max_retries = max_retries,
492 "Processing message"
493 );
494
495 let result = match queue_type {
497 QueueType::TransactionRequest => {
498 process_job::<TransactionRequest, _, _>(
499 body,
500 app_state,
501 attempt_number,
502 sqs_message_id,
503 "TransactionRequest",
504 transaction_request_handler,
505 )
506 .await
507 }
508 QueueType::TransactionSubmission => {
509 process_job::<TransactionSend, _, _>(
510 body,
511 app_state,
512 attempt_number,
513 sqs_message_id,
514 "TransactionSend",
515 transaction_submission_handler,
516 )
517 .await
518 }
519 QueueType::StatusCheck | QueueType::StatusCheckEvm | QueueType::StatusCheckStellar => {
520 process_job::<TransactionStatusCheck, _, _>(
521 body,
522 app_state,
523 attempt_number,
524 sqs_message_id,
525 "TransactionStatusCheck",
526 transaction_status_handler,
527 )
528 .await
529 }
530 QueueType::Notification => {
531 process_job::<NotificationSend, _, _>(
532 body,
533 app_state,
534 attempt_number,
535 sqs_message_id,
536 "NotificationSend",
537 notification_handler,
538 )
539 .await
540 }
541 QueueType::TokenSwapRequest => {
542 process_job::<TokenSwapRequest, _, _>(
543 body,
544 app_state,
545 attempt_number,
546 sqs_message_id,
547 "TokenSwapRequest",
548 token_swap_request_handler,
549 )
550 .await
551 }
552 QueueType::RelayerHealthCheck => {
553 process_job::<RelayerHealthCheck, _, _>(
554 body,
555 app_state,
556 attempt_number,
557 sqs_message_id,
558 "RelayerHealthCheck",
559 relayer_health_check_handler,
560 )
561 .await
562 }
563 };
564
565 match result {
566 Ok(()) => {
567 debug!(
568 queue_type = ?queue_type,
569 attempt = attempt_number,
570 "Message processed successfully"
571 );
572
573 Ok(MessageOutcome::Delete {
574 receipt_handle: receipt_handle.to_string(),
575 })
576 }
577 Err(ProcessingError::Permanent(e)) => {
578 error!(
579 queue_type = ?queue_type,
580 attempt = attempt_number,
581 error = %e,
582 "Permanent handler failure, message will be deleted"
583 );
584
585 Ok(MessageOutcome::Delete {
586 receipt_handle: receipt_handle.to_string(),
587 })
588 }
589 Err(ProcessingError::Retryable(e)) => {
590 if max_retries != usize::MAX && receive_count > max_retries {
592 error!(
593 queue_type = ?queue_type,
594 attempt = attempt_number,
595 receive_count = receive_count,
596 max_retries = max_retries,
597 error = %e,
598 "Max retries exceeded; message will be automatically moved to DLQ by SQS redrive policy"
599 );
600 return Ok(MessageOutcome::Retain);
601 }
602
603 let delay = if queue_type.is_status_check() {
607 compute_status_retry_delay(body, logical_retry_attempt)
608 } else {
609 retry_delay_secs(backoff_config_for_queue(queue_type), logical_retry_attempt)
610 };
611
612 if is_fifo_queue_url(queue_url) {
615 if let Err(err) = sqs_client
616 .change_message_visibility()
617 .queue_url(queue_url)
618 .receipt_handle(receipt_handle)
619 .visibility_timeout(delay.clamp(1, 900))
620 .send()
621 .await
622 {
623 error!(
624 queue_type = ?queue_type,
625 error = %err,
626 "Failed to set visibility timeout for retry; falling back to existing visibility timeout"
627 );
628 return Ok(MessageOutcome::Retain);
629 }
630
631 debug!(
632 queue_type = ?queue_type,
633 attempt = logical_retry_attempt,
634 delay_seconds = delay,
635 error = %e,
636 "Retry scheduled via visibility timeout"
637 );
638
639 return Ok(MessageOutcome::Retain);
640 }
641
642 let next_retry_attempt = logical_retry_attempt.saturating_add(1);
643
644 if let Err(send_err) = sqs_client
648 .send_message()
649 .queue_url(queue_url)
650 .message_body(body.to_string())
651 .delay_seconds(delay)
652 .message_attributes(
653 "retry_attempt",
654 MessageAttributeValue::builder()
655 .data_type("Number")
656 .string_value(next_retry_attempt.to_string())
657 .build()
658 .map_err(|err| {
659 QueueBackendError::SqsError(format!(
660 "Failed to build retry_attempt attribute: {err}"
661 ))
662 })?,
663 )
664 .send()
665 .await
666 {
667 error!(
668 queue_type = ?queue_type,
669 error.kind = classify_sdk_error(&send_err),
670 error.detail = %DisplayErrorContext(&send_err),
671 "Failed to re-enqueue message; leaving original for visibility timeout retry"
672 );
673 return Ok(MessageOutcome::Retain);
675 }
676
677 debug!(
678 queue_type = ?queue_type,
679 attempt = logical_retry_attempt,
680 delay_seconds = delay,
681 error = %e,
682 "Message re-enqueued with backoff delay"
683 );
684
685 Ok(MessageOutcome::Delete {
687 receipt_handle: receipt_handle.to_string(),
688 })
689 }
690 }
691}
692
693async fn process_job<T, F, Fut>(
696 body: &str,
697 app_state: Arc<ThinData<crate::models::DefaultAppState>>,
698 attempt: usize,
699 task_id: String,
700 type_name: &str,
701 handler: F,
702) -> Result<(), ProcessingError>
703where
704 T: DeserializeOwned,
705 F: FnOnce(Job<T>, ThinData<crate::models::DefaultAppState>, WorkerContext) -> Fut,
706 Fut: Future<Output = Result<(), HandlerError>>,
707{
708 let job: Job<T> = serde_json::from_str(body).map_err(|e| {
709 error!(error = %e, "Failed to deserialize {} job", type_name);
710 ProcessingError::Permanent(format!("Failed to deserialize {type_name} job: {e}"))
712 })?;
713
714 let ctx = WorkerContext::new(attempt, task_id);
715 handler(job, (*app_state).clone(), ctx)
716 .await
717 .map_err(map_handler_error)
718}
719
720fn map_handler_error(error: HandlerError) -> ProcessingError {
721 match error {
722 HandlerError::Abort(msg) => ProcessingError::Permanent(msg),
723 HandlerError::Retry(msg) => ProcessingError::Retryable(msg),
724 }
725}
726
727fn parse_target_scheduled_on(message: &Message) -> Option<i64> {
728 message
729 .message_attributes()
730 .and_then(|attrs| attrs.get("target_scheduled_on"))
731 .and_then(|value| value.string_value())
732 .and_then(|value| value.parse::<i64>().ok())
733}
734
735fn parse_retry_attempt(message: &Message) -> Option<usize> {
736 message
737 .message_attributes()
738 .and_then(|attrs| attrs.get("retry_attempt"))
739 .and_then(|value| value.string_value())
740 .and_then(|value| value.parse::<usize>().ok())
741}
742
743fn is_fifo_queue_url(queue_url: &str) -> bool {
744 queue_url.ends_with(".fifo")
745}
746
747async fn defer_message(
748 sqs_client: &aws_sdk_sqs::Client,
749 queue_url: &str,
750 body: String,
751 message: &Message,
752 target_scheduled_on: i64,
753 delay_seconds: i32,
754) -> Result<bool, QueueBackendError> {
755 if is_fifo_queue_url(queue_url) {
756 let receipt_handle = message.receipt_handle().ok_or_else(|| {
757 QueueBackendError::QueueError(
758 "Cannot defer FIFO message: missing receipt handle".to_string(),
759 )
760 })?;
761
762 sqs_client
763 .change_message_visibility()
764 .queue_url(queue_url)
765 .receipt_handle(receipt_handle)
766 .visibility_timeout(delay_seconds.clamp(1, 900))
767 .send()
768 .await
769 .map_err(|e| {
770 error!(
771 error.kind = classify_sdk_error(&e),
772 error.detail = %DisplayErrorContext(&e),
773 queue_url = %queue_url,
774 "Failed to defer FIFO message via visibility timeout"
775 );
776 QueueBackendError::SqsError(format!(
777 "Failed to defer FIFO message via visibility timeout: {}",
778 classify_sdk_error(&e)
779 ))
780 })?;
781
782 return Ok(false);
783 }
784
785 let request = sqs_client
788 .send_message()
789 .queue_url(queue_url)
790 .message_body(body)
791 .delay_seconds(delay_seconds.clamp(1, 900))
792 .message_attributes(
793 "target_scheduled_on",
794 MessageAttributeValue::builder()
795 .data_type("Number")
796 .string_value(target_scheduled_on.to_string())
797 .build()
798 .map_err(|e| {
799 QueueBackendError::SqsError(format!(
800 "Failed to build deferred scheduled attribute: {e}"
801 ))
802 })?,
803 );
804
805 request.send().await.map_err(|e| {
806 error!(
807 error.kind = classify_sdk_error(&e),
808 error.detail = %DisplayErrorContext(&e),
809 queue_url = %queue_url,
810 "Failed to defer scheduled message"
811 );
812 QueueBackendError::SqsError(format!(
813 "Failed to defer scheduled message: {}",
814 classify_sdk_error(&e)
815 ))
816 })?;
817
818 Ok(true)
819}
820
821#[derive(serde::Deserialize)]
826struct StatusCheckData {
827 network_type: Option<crate::models::NetworkType>,
828}
829
830#[derive(serde::Deserialize)]
835struct PartialStatusCheckJob {
836 data: StatusCheckData,
837}
838
839fn compute_status_retry_delay(body: &str, attempt: usize) -> i32 {
846 let network_type = serde_json::from_str::<PartialStatusCheckJob>(body)
847 .ok()
848 .and_then(|j| j.data.network_type);
849
850 crate::queues::retry_config::status_check_retry_delay_secs(network_type, attempt)
851}
852
853fn get_wait_time_for_queue(queue_type: QueueType) -> u64 {
855 ServerConfig::get_sqs_wait_time(
856 queue_type.sqs_env_key(),
857 queue_type.default_wait_time_secs(),
858 )
859}
860
861fn get_poller_count_for_queue(queue_type: QueueType) -> usize {
863 let configured = ServerConfig::get_sqs_poller_count(
864 queue_type.sqs_env_key(),
865 queue_type.default_poller_count(),
866 );
867 if configured == 0 {
868 warn!(
869 queue_type = ?queue_type,
870 "Configured poller count is 0; clamping to 1"
871 );
872 1
873 } else {
874 configured
875 }
876}
877
878fn get_concurrency_for_queue(queue_type: QueueType) -> usize {
880 let configured = ServerConfig::get_worker_concurrency(
881 queue_type.concurrency_env_key(),
882 queue_type.default_concurrency(),
883 );
884 if configured == 0 {
885 warn!(
886 queue_type = ?queue_type,
887 "Configured concurrency is 0; clamping to 1"
888 );
889 1
890 } else {
891 configured
892 }
893}
894
895fn handler_timeout_secs(queue_type: QueueType) -> u64 {
899 u64::from(queue_type.visibility_timeout_secs().max(1))
900}
901
902const MAX_POLL_BACKOFF_SECS: u64 = 60;
904
905const RECOVERY_PROBE_EVERY: u32 = 4;
909
910fn poll_error_backoff_secs(consecutive_errors: u32) -> u64 {
914 let base: u64 = 5;
915
916 if consecutive_errors >= 7 && consecutive_errors.is_multiple_of(RECOVERY_PROBE_EVERY) {
919 return base;
920 }
921
922 let exponent = consecutive_errors.saturating_sub(1).min(16);
923 base.saturating_mul(2_u64.saturating_pow(exponent))
924 .min(MAX_POLL_BACKOFF_SECS)
925}
926
927async fn flush_delete_batch(
933 sqs_client: &aws_sdk_sqs::Client,
934 queue_url: &str,
935 batch: &[String],
936 queue_type: QueueType,
937) -> usize {
938 if batch.is_empty() {
939 return 0;
940 }
941
942 let mut deleted = 0;
943
944 for chunk in batch.chunks(10) {
945 let entries: Vec<DeleteMessageBatchRequestEntry> = chunk
946 .iter()
947 .enumerate()
948 .map(|(i, handle)| {
949 DeleteMessageBatchRequestEntry::builder()
950 .id(i.to_string())
951 .receipt_handle(handle)
952 .build()
953 .expect("id and receipt_handle are always set")
954 })
955 .collect();
956
957 match sqs_client
958 .delete_message_batch()
959 .queue_url(queue_url)
960 .set_entries(Some(entries))
961 .send()
962 .await
963 {
964 Ok(output) => {
965 deleted += output.successful().len();
966
967 for f in output.failed() {
968 warn!(
969 queue_type = ?queue_type,
970 id = %f.id(),
971 code = %f.code(),
972 message = f.message().unwrap_or("unknown"),
973 "Batch delete entry failed (message will be redelivered)"
974 );
975 }
976 }
977 Err(e) => {
978 error!(
979 queue_type = ?queue_type,
980 error.kind = classify_sdk_error(&e),
981 error.detail = %DisplayErrorContext(&e),
982 batch_size = chunk.len(),
983 "Batch delete API call failed (messages will be redelivered)"
984 );
985 }
986 }
987 }
988
989 deleted
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 #[test]
997 fn test_get_concurrency_for_queue() {
998 let concurrency = get_concurrency_for_queue(QueueType::TransactionRequest);
1000 assert!(concurrency > 0);
1001
1002 let concurrency = get_concurrency_for_queue(QueueType::StatusCheck);
1003 assert!(concurrency > 0);
1004 }
1005
1006 #[test]
1007 fn test_handler_timeout_secs_is_positive() {
1008 let all = [
1009 QueueType::TransactionRequest,
1010 QueueType::TransactionSubmission,
1011 QueueType::StatusCheck,
1012 QueueType::StatusCheckEvm,
1013 QueueType::StatusCheckStellar,
1014 QueueType::Notification,
1015 QueueType::TokenSwapRequest,
1016 QueueType::RelayerHealthCheck,
1017 ];
1018 for queue_type in all {
1019 assert!(handler_timeout_secs(queue_type) > 0);
1020 }
1021 }
1022
1023 #[test]
1024 fn test_handler_timeout_secs_uses_visibility_timeout() {
1025 assert_eq!(
1026 handler_timeout_secs(QueueType::StatusCheckEvm),
1027 QueueType::StatusCheckEvm.visibility_timeout_secs() as u64
1028 );
1029 assert_eq!(
1030 handler_timeout_secs(QueueType::Notification),
1031 QueueType::Notification.visibility_timeout_secs() as u64
1032 );
1033 }
1034
1035 #[test]
1036 fn test_parse_target_scheduled_on() {
1037 let message = Message::builder().build();
1039
1040 assert_eq!(parse_target_scheduled_on(&message), None);
1042
1043 let message = Message::builder()
1045 .message_attributes(
1046 "target_scheduled_on",
1047 MessageAttributeValue::builder()
1048 .data_type("Number")
1049 .string_value("1234567890")
1050 .build()
1051 .unwrap(),
1052 )
1053 .build();
1054
1055 assert_eq!(parse_target_scheduled_on(&message), Some(1234567890));
1056 }
1057
1058 #[test]
1059 fn test_parse_retry_attempt() {
1060 let message = Message::builder().build();
1061 assert_eq!(parse_retry_attempt(&message), None);
1062
1063 let message = Message::builder()
1064 .message_attributes(
1065 "retry_attempt",
1066 MessageAttributeValue::builder()
1067 .data_type("Number")
1068 .string_value("7")
1069 .build()
1070 .unwrap(),
1071 )
1072 .build();
1073 assert_eq!(parse_retry_attempt(&message), Some(7));
1074 }
1075
1076 #[test]
1077 fn test_map_handler_error() {
1078 let error = HandlerError::Abort("Validation failed".to_string());
1080 let result = map_handler_error(error);
1081 assert!(matches!(result, ProcessingError::Permanent(_)));
1082
1083 let error = HandlerError::Retry("Network timeout".to_string());
1085 let result = map_handler_error(error);
1086 assert!(matches!(result, ProcessingError::Retryable(_)));
1087 }
1088
1089 #[test]
1090 fn test_is_fifo_queue_url() {
1091 assert!(is_fifo_queue_url(
1092 "https://sqs.us-east-1.amazonaws.com/123/queue.fifo"
1093 ));
1094 assert!(!is_fifo_queue_url(
1095 "https://sqs.us-east-1.amazonaws.com/123/queue"
1096 ));
1097 }
1098
1099 #[test]
1100 fn test_compute_status_retry_delay_evm() {
1101 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1","network_type":"evm"}}"#;
1103 assert_eq!(compute_status_retry_delay(body, 0), 8);
1104 assert_eq!(compute_status_retry_delay(body, 1), 12);
1105 assert_eq!(compute_status_retry_delay(body, 8), 12);
1106 }
1107
1108 #[test]
1109 fn test_compute_status_retry_delay_stellar() {
1110 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1","network_type":"stellar"}}"#;
1111 assert_eq!(compute_status_retry_delay(body, 0), 2);
1112 assert_eq!(compute_status_retry_delay(body, 1), 3);
1113 assert_eq!(compute_status_retry_delay(body, 8), 3);
1114 }
1115
1116 #[test]
1117 fn test_compute_status_retry_delay_solana() {
1118 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1","network_type":"solana"}}"#;
1119 assert_eq!(compute_status_retry_delay(body, 0), 5);
1120 assert_eq!(compute_status_retry_delay(body, 1), 8);
1121 assert_eq!(compute_status_retry_delay(body, 8), 8);
1122 }
1123
1124 #[test]
1125 fn test_compute_status_retry_delay_missing_network() {
1126 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1"}}"#;
1127 assert_eq!(compute_status_retry_delay(body, 0), 5);
1128 assert_eq!(compute_status_retry_delay(body, 1), 8);
1129 assert_eq!(compute_status_retry_delay(body, 8), 8);
1130 }
1131
1132 #[test]
1133 fn test_compute_status_retry_delay_invalid_body() {
1134 assert_eq!(compute_status_retry_delay("not json", 0), 5);
1135 assert_eq!(compute_status_retry_delay("not json", 1), 8);
1136 assert_eq!(compute_status_retry_delay("not json", 8), 8);
1137 }
1138
1139 #[tokio::test]
1140 async fn test_semaphore_released_on_panic() {
1141 let sem = Arc::new(tokio::sync::Semaphore::new(1));
1142 let permit = sem.clone().acquire_owned().await.unwrap();
1143
1144 let handle = tokio::spawn(async move {
1145 let _permit = permit; let _ = AssertUnwindSafe(async { panic!("test panic") })
1147 .catch_unwind()
1148 .await;
1149 });
1150
1151 handle.await.unwrap();
1152 let _p = tokio::time::timeout(Duration::from_millis(100), sem.acquire())
1154 .await
1155 .expect("permit should be available after panic");
1156 }
1157
1158 #[test]
1159 fn test_poll_error_backoff_secs() {
1160 assert_eq!(poll_error_backoff_secs(1), 5);
1162 assert_eq!(poll_error_backoff_secs(2), 10);
1164 assert_eq!(poll_error_backoff_secs(3), 20);
1166 assert_eq!(poll_error_backoff_secs(4), 40);
1168 assert_eq!(poll_error_backoff_secs(5), 60);
1170 assert_eq!(poll_error_backoff_secs(6), 60);
1171 assert_eq!(poll_error_backoff_secs(7), 60);
1172 assert_eq!(poll_error_backoff_secs(8), 5);
1174 assert_eq!(poll_error_backoff_secs(9), 60);
1175 assert_eq!(poll_error_backoff_secs(12), 5); }
1177
1178 #[test]
1179 fn test_poll_error_backoff_zero_errors() {
1180 assert_eq!(poll_error_backoff_secs(0), 5);
1182 }
1183
1184 #[test]
1185 fn test_poll_error_backoff_recovery_probes() {
1186 for i in (8..=100).step_by(RECOVERY_PROBE_EVERY as usize) {
1188 assert_eq!(
1189 poll_error_backoff_secs(i as u32),
1190 5,
1191 "Expected recovery probe at error {i}"
1192 );
1193 }
1194 }
1195
1196 #[test]
1197 fn test_message_outcome_delete_carries_receipt_handle() {
1198 let handle = "test-receipt-handle-123".to_string();
1199 let outcome = MessageOutcome::Delete {
1200 receipt_handle: handle.clone(),
1201 };
1202 match outcome {
1203 MessageOutcome::Delete { receipt_handle } => {
1204 assert_eq!(receipt_handle, handle);
1205 }
1206 MessageOutcome::Retain => panic!("Expected Delete variant"),
1207 }
1208 }
1209
1210 #[test]
1211 fn test_message_outcome_retain() {
1212 let outcome = MessageOutcome::Retain;
1213 assert!(matches!(outcome, MessageOutcome::Retain));
1214 }
1215
1216 #[test]
1217 fn test_batch_delete_entry_builder() {
1218 let handles = vec![
1221 "receipt-0".to_string(),
1222 "receipt-1".to_string(),
1223 "receipt-2".to_string(),
1224 ];
1225 let entries: Vec<DeleteMessageBatchRequestEntry> = handles
1226 .iter()
1227 .enumerate()
1228 .map(|(i, handle)| {
1229 DeleteMessageBatchRequestEntry::builder()
1230 .id(i.to_string())
1231 .receipt_handle(handle)
1232 .build()
1233 .expect("id and receipt_handle are set")
1234 })
1235 .collect();
1236
1237 assert_eq!(entries.len(), 3);
1238 assert_eq!(entries[0].id(), "0");
1239 assert_eq!(entries[0].receipt_handle(), "receipt-0");
1240 assert_eq!(entries[2].id(), "2");
1241 assert_eq!(entries[2].receipt_handle(), "receipt-2");
1242 }
1243
1244 #[test]
1245 fn test_batch_chunking_logic() {
1246 let handles: Vec<String> = (0..25).map(|i| format!("receipt-{i}")).collect();
1249 let chunks: Vec<&[String]> = handles.chunks(10).collect();
1250
1251 assert_eq!(chunks.len(), 3);
1252 assert_eq!(chunks[0].len(), 10);
1253 assert_eq!(chunks[1].len(), 10);
1254 assert_eq!(chunks[2].len(), 5);
1255 }
1256
1257 #[test]
1258 fn test_outcome_collection_pattern() {
1259 let outcomes = vec![
1262 Some("receipt-1".to_string()), None, Some("receipt-2".to_string()), None, Some("receipt-3".to_string()), ];
1268
1269 let pending_deletes: Vec<String> = outcomes.into_iter().flatten().collect();
1270
1271 assert_eq!(pending_deletes.len(), 3);
1272 assert_eq!(pending_deletes[0], "receipt-1");
1273 assert_eq!(pending_deletes[1], "receipt-2");
1274 assert_eq!(pending_deletes[2], "receipt-3");
1275 }
1276
1277 #[test]
1280 fn test_parse_target_scheduled_on_non_numeric_string() {
1281 let message = Message::builder()
1282 .message_attributes(
1283 "target_scheduled_on",
1284 MessageAttributeValue::builder()
1285 .data_type("String")
1286 .string_value("not-a-number")
1287 .build()
1288 .unwrap(),
1289 )
1290 .build();
1291 assert_eq!(parse_target_scheduled_on(&message), None);
1292 }
1293
1294 #[test]
1295 fn test_parse_target_scheduled_on_empty_string() {
1296 let message = Message::builder()
1297 .message_attributes(
1298 "target_scheduled_on",
1299 MessageAttributeValue::builder()
1300 .data_type("Number")
1301 .string_value("")
1302 .build()
1303 .unwrap(),
1304 )
1305 .build();
1306 assert_eq!(parse_target_scheduled_on(&message), None);
1307 }
1308
1309 #[test]
1310 fn test_parse_target_scheduled_on_negative_value() {
1311 let message = Message::builder()
1312 .message_attributes(
1313 "target_scheduled_on",
1314 MessageAttributeValue::builder()
1315 .data_type("Number")
1316 .string_value("-1000")
1317 .build()
1318 .unwrap(),
1319 )
1320 .build();
1321 assert_eq!(parse_target_scheduled_on(&message), Some(-1000));
1323 }
1324
1325 #[test]
1326 fn test_parse_target_scheduled_on_float_string() {
1327 let message = Message::builder()
1328 .message_attributes(
1329 "target_scheduled_on",
1330 MessageAttributeValue::builder()
1331 .data_type("Number")
1332 .string_value("1234567890.5")
1333 .build()
1334 .unwrap(),
1335 )
1336 .build();
1337 assert_eq!(parse_target_scheduled_on(&message), None);
1339 }
1340
1341 #[test]
1342 fn test_parse_target_scheduled_on_zero() {
1343 let message = Message::builder()
1344 .message_attributes(
1345 "target_scheduled_on",
1346 MessageAttributeValue::builder()
1347 .data_type("Number")
1348 .string_value("0")
1349 .build()
1350 .unwrap(),
1351 )
1352 .build();
1353 assert_eq!(parse_target_scheduled_on(&message), Some(0));
1354 }
1355
1356 #[test]
1357 fn test_parse_target_scheduled_on_wrong_attribute_name() {
1358 let message = Message::builder()
1360 .message_attributes(
1361 "wrong_key",
1362 MessageAttributeValue::builder()
1363 .data_type("Number")
1364 .string_value("1234567890")
1365 .build()
1366 .unwrap(),
1367 )
1368 .build();
1369 assert_eq!(parse_target_scheduled_on(&message), None);
1370 }
1371
1372 #[test]
1375 fn test_parse_retry_attempt_non_numeric_string() {
1376 let message = Message::builder()
1377 .message_attributes(
1378 "retry_attempt",
1379 MessageAttributeValue::builder()
1380 .data_type("String")
1381 .string_value("abc")
1382 .build()
1383 .unwrap(),
1384 )
1385 .build();
1386 assert_eq!(parse_retry_attempt(&message), None);
1387 }
1388
1389 #[test]
1390 fn test_parse_retry_attempt_negative_value() {
1391 let message = Message::builder()
1392 .message_attributes(
1393 "retry_attempt",
1394 MessageAttributeValue::builder()
1395 .data_type("Number")
1396 .string_value("-1")
1397 .build()
1398 .unwrap(),
1399 )
1400 .build();
1401 assert_eq!(parse_retry_attempt(&message), None);
1403 }
1404
1405 #[test]
1406 fn test_parse_retry_attempt_zero() {
1407 let message = Message::builder()
1408 .message_attributes(
1409 "retry_attempt",
1410 MessageAttributeValue::builder()
1411 .data_type("Number")
1412 .string_value("0")
1413 .build()
1414 .unwrap(),
1415 )
1416 .build();
1417 assert_eq!(parse_retry_attempt(&message), Some(0));
1418 }
1419
1420 #[test]
1421 fn test_parse_retry_attempt_large_value() {
1422 let message = Message::builder()
1423 .message_attributes(
1424 "retry_attempt",
1425 MessageAttributeValue::builder()
1426 .data_type("Number")
1427 .string_value("999999")
1428 .build()
1429 .unwrap(),
1430 )
1431 .build();
1432 assert_eq!(parse_retry_attempt(&message), Some(999999));
1433 }
1434
1435 #[test]
1438 fn test_is_fifo_queue_url_empty_string() {
1439 assert!(!is_fifo_queue_url(""));
1440 }
1441
1442 #[test]
1443 fn test_is_fifo_queue_url_just_fifo_suffix() {
1444 assert!(is_fifo_queue_url("my-queue.fifo"));
1445 }
1446
1447 #[test]
1448 fn test_is_fifo_queue_url_fifo_in_middle() {
1449 assert!(!is_fifo_queue_url(
1451 "https://sqs.us-east-1.amazonaws.com/123/.fifo/queue"
1452 ));
1453 }
1454
1455 #[test]
1456 fn test_is_fifo_queue_url_case_sensitive() {
1457 assert!(!is_fifo_queue_url(
1458 "https://sqs.us-east-1.amazonaws.com/123/queue.FIFO"
1459 ));
1460 assert!(!is_fifo_queue_url(
1461 "https://sqs.us-east-1.amazonaws.com/123/queue.Fifo"
1462 ));
1463 }
1464
1465 #[test]
1466 fn test_is_fifo_queue_url_standard_queue_variations() {
1467 assert!(!is_fifo_queue_url(
1468 "https://sqs.us-east-1.amazonaws.com/123456789/my-queue"
1469 ));
1470 assert!(!is_fifo_queue_url(
1471 "https://sqs.eu-west-1.amazonaws.com/123456789/relayer-tx-request"
1472 ));
1473 assert!(!is_fifo_queue_url(
1474 "http://localhost:4566/000000000000/test-queue"
1475 ));
1476 }
1477
1478 #[test]
1479 fn test_is_fifo_queue_url_localstack() {
1480 assert!(is_fifo_queue_url(
1482 "http://localhost:4566/000000000000/test-queue.fifo"
1483 ));
1484 }
1485
1486 #[test]
1489 fn test_map_handler_error_preserves_abort_message() {
1490 let msg = "Validation failed: invalid nonce";
1491 let error = HandlerError::Abort(msg.to_string());
1492 match map_handler_error(error) {
1493 ProcessingError::Permanent(s) => assert_eq!(s, msg),
1494 ProcessingError::Retryable(_) => panic!("Expected Permanent"),
1495 }
1496 }
1497
1498 #[test]
1499 fn test_map_handler_error_preserves_retry_message() {
1500 let msg = "RPC timeout after 30s";
1501 let error = HandlerError::Retry(msg.to_string());
1502 match map_handler_error(error) {
1503 ProcessingError::Retryable(s) => assert_eq!(s, msg),
1504 ProcessingError::Permanent(_) => panic!("Expected Retryable"),
1505 }
1506 }
1507
1508 #[test]
1509 fn test_map_handler_error_empty_message() {
1510 let error = HandlerError::Abort(String::new());
1511 match map_handler_error(error) {
1512 ProcessingError::Permanent(s) => assert!(s.is_empty()),
1513 ProcessingError::Retryable(_) => panic!("Expected Permanent"),
1514 }
1515 }
1516
1517 #[test]
1520 fn test_handler_timeout_secs_matches_visibility_timeout_for_all_queues() {
1521 let all = [
1522 QueueType::TransactionRequest,
1523 QueueType::TransactionSubmission,
1524 QueueType::StatusCheck,
1525 QueueType::StatusCheckEvm,
1526 QueueType::StatusCheckStellar,
1527 QueueType::Notification,
1528 QueueType::TokenSwapRequest,
1529 QueueType::RelayerHealthCheck,
1530 ];
1531 for qt in all {
1532 assert_eq!(
1533 handler_timeout_secs(qt),
1534 qt.visibility_timeout_secs().max(1) as u64,
1535 "{qt:?}: handler timeout should equal max(visibility_timeout, 1)"
1536 );
1537 }
1538 }
1539
1540 #[test]
1543 fn test_get_concurrency_for_queue_all_types_positive() {
1544 let all = [
1545 QueueType::TransactionRequest,
1546 QueueType::TransactionSubmission,
1547 QueueType::StatusCheck,
1548 QueueType::StatusCheckEvm,
1549 QueueType::StatusCheckStellar,
1550 QueueType::Notification,
1551 QueueType::TokenSwapRequest,
1552 QueueType::RelayerHealthCheck,
1553 ];
1554 for qt in all {
1555 assert!(
1556 get_concurrency_for_queue(qt) > 0,
1557 "{qt:?}: concurrency must be positive (clamped to at least 1)"
1558 );
1559 }
1560 }
1561
1562 #[test]
1565 fn test_poll_error_backoff_never_exceeds_max() {
1566 for i in 0..200 {
1567 let backoff = poll_error_backoff_secs(i);
1568 assert!(
1569 backoff <= MAX_POLL_BACKOFF_SECS,
1570 "Error count {i}: backoff {backoff}s exceeds MAX {MAX_POLL_BACKOFF_SECS}s"
1571 );
1572 }
1573 }
1574
1575 #[test]
1576 fn test_poll_error_backoff_u32_max_does_not_overflow() {
1577 let backoff = poll_error_backoff_secs(u32::MAX);
1578 assert!(backoff <= MAX_POLL_BACKOFF_SECS);
1579 assert!(backoff > 0);
1580 }
1581
1582 #[test]
1583 fn test_poll_error_backoff_always_positive() {
1584 for i in 0..200 {
1585 assert!(
1586 poll_error_backoff_secs(i) > 0,
1587 "Error count {i}: backoff must be positive"
1588 );
1589 }
1590 }
1591
1592 #[test]
1593 fn test_poll_error_backoff_monotonic_before_cap() {
1594 let mut prev = poll_error_backoff_secs(0);
1596 for i in 1..=4 {
1597 let curr = poll_error_backoff_secs(i);
1598 assert!(
1599 curr >= prev,
1600 "Backoff should be non-decreasing before cap: {prev} -> {curr} at error {i}"
1601 );
1602 prev = curr;
1603 }
1604 }
1605
1606 #[test]
1609 fn test_max_poll_backoff_is_reasonable() {
1610 assert!(
1611 MAX_POLL_BACKOFF_SECS >= 10,
1612 "Max backoff should be at least 10s to avoid tight error loops"
1613 );
1614 assert!(
1615 MAX_POLL_BACKOFF_SECS <= 300,
1616 "Max backoff should be at most 5 minutes to detect recovery promptly"
1617 );
1618 }
1619
1620 #[test]
1621 fn test_recovery_probe_every_is_valid() {
1622 assert!(
1623 RECOVERY_PROBE_EVERY >= 2,
1624 "Recovery probe interval must be at least 2 to avoid probing every attempt"
1625 );
1626 assert!(
1627 RECOVERY_PROBE_EVERY <= 10,
1628 "Recovery probe interval should not be too large or recovery detection is slow"
1629 );
1630 }
1631
1632 #[test]
1635 fn test_compute_status_retry_delay_very_high_attempt() {
1636 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1","network_type":"evm"}}"#;
1637 assert_eq!(compute_status_retry_delay(body, 1000), 12);
1639 assert_eq!(compute_status_retry_delay(body, usize::MAX), 12);
1640 }
1641
1642 #[test]
1643 fn test_compute_status_retry_delay_empty_body() {
1644 assert_eq!(compute_status_retry_delay("", 0), 5);
1646 assert_eq!(compute_status_retry_delay("{}", 0), 5);
1647 }
1648
1649 #[test]
1650 fn test_compute_status_retry_delay_partial_json() {
1651 assert_eq!(compute_status_retry_delay(r#"{"data":{}}"#, 0), 5);
1653 assert_eq!(
1654 compute_status_retry_delay(r#"{"data":{"network_type":"evm"}}"#, 0),
1655 8
1656 );
1657 }
1658
1659 #[test]
1662 fn test_partial_status_check_job_deserializes_network_type() {
1663 let body = r#"{"data":{"network_type":"evm","extra_field":"ignored"}}"#;
1664 let parsed: PartialStatusCheckJob = serde_json::from_str(body).unwrap();
1665 assert_eq!(
1666 parsed.data.network_type,
1667 Some(crate::models::NetworkType::Evm)
1668 );
1669 }
1670
1671 #[test]
1672 fn test_partial_status_check_job_handles_missing_network_type() {
1673 let body = r#"{"data":{"transaction_id":"tx1"}}"#;
1674 let parsed: PartialStatusCheckJob = serde_json::from_str(body).unwrap();
1675 assert_eq!(parsed.data.network_type, None);
1676 }
1677
1678 #[test]
1679 fn test_partial_status_check_job_rejects_missing_data() {
1680 let body = r#"{"not_data":{}}"#;
1681 let result = serde_json::from_str::<PartialStatusCheckJob>(body);
1682 assert!(result.is_err());
1683 }
1684
1685 #[test]
1688 fn test_fifo_detection_consistent_with_defer_and_retry_logic() {
1689 let standard = "https://sqs.us-east-1.amazonaws.com/123/relayer-status-check";
1694 let fifo = "https://sqs.us-east-1.amazonaws.com/123/relayer-status-check.fifo";
1695
1696 assert!(!is_fifo_queue_url(standard));
1697 assert!(is_fifo_queue_url(fifo));
1698 }
1699
1700 #[test]
1703 fn test_get_wait_time_for_queue_returns_positive() {
1704 let all = [
1705 QueueType::TransactionRequest,
1706 QueueType::TransactionSubmission,
1707 QueueType::StatusCheck,
1708 QueueType::StatusCheckEvm,
1709 QueueType::StatusCheckStellar,
1710 QueueType::Notification,
1711 QueueType::TokenSwapRequest,
1712 QueueType::RelayerHealthCheck,
1713 ];
1714 for qt in all {
1715 let wt = get_wait_time_for_queue(qt);
1716 assert!(
1717 wt <= 20,
1718 "{qt:?}: wait time {wt} exceeds SQS maximum of 20s"
1719 );
1720 }
1721 }
1722
1723 #[test]
1724 fn test_get_wait_time_for_queue_matches_defaults() {
1725 assert_eq!(
1727 get_wait_time_for_queue(QueueType::TransactionRequest),
1728 QueueType::TransactionRequest.default_wait_time_secs()
1729 );
1730 assert_eq!(
1731 get_wait_time_for_queue(QueueType::StatusCheck),
1732 QueueType::StatusCheck.default_wait_time_secs()
1733 );
1734 }
1735
1736 #[test]
1737 #[serial_test::serial]
1738 fn test_get_wait_time_for_queue_respects_env_override() {
1739 let env_var = format!(
1741 "SQS_{}_WAIT_TIME_SECONDS",
1742 QueueType::StatusCheck.sqs_env_key()
1743 );
1744 std::env::set_var(&env_var, "12");
1745 assert_eq!(get_wait_time_for_queue(QueueType::StatusCheck), 12);
1746 std::env::remove_var(&env_var);
1747 }
1748
1749 #[test]
1750 #[serial_test::serial]
1751 fn test_get_wait_time_for_queue_env_override_clamped_to_20() {
1752 let env_var = format!(
1753 "SQS_{}_WAIT_TIME_SECONDS",
1754 QueueType::Notification.sqs_env_key()
1755 );
1756 std::env::set_var(&env_var, "99");
1757 assert_eq!(
1758 get_wait_time_for_queue(QueueType::Notification),
1759 20,
1760 "Should clamp to SQS maximum of 20"
1761 );
1762 std::env::remove_var(&env_var);
1763 }
1764
1765 #[test]
1768 fn test_get_poller_count_for_queue_all_types_positive() {
1769 let all = [
1770 QueueType::TransactionRequest,
1771 QueueType::TransactionSubmission,
1772 QueueType::StatusCheck,
1773 QueueType::StatusCheckEvm,
1774 QueueType::StatusCheckStellar,
1775 QueueType::Notification,
1776 QueueType::TokenSwapRequest,
1777 QueueType::RelayerHealthCheck,
1778 ];
1779 for qt in all {
1780 assert!(
1781 get_poller_count_for_queue(qt) >= 1,
1782 "{qt:?}: poller count must be at least 1"
1783 );
1784 }
1785 }
1786
1787 #[test]
1788 fn test_get_poller_count_for_queue_matches_defaults() {
1789 assert_eq!(
1791 get_poller_count_for_queue(QueueType::TransactionRequest),
1792 QueueType::TransactionRequest.default_poller_count().max(1)
1793 );
1794 assert_eq!(
1795 get_poller_count_for_queue(QueueType::Notification),
1796 QueueType::Notification.default_poller_count().max(1)
1797 );
1798 }
1799
1800 #[test]
1801 #[serial_test::serial]
1802 fn test_get_poller_count_for_queue_respects_env_override() {
1803 let env_var = format!("SQS_{}_POLLER_COUNT", QueueType::Notification.sqs_env_key());
1804 std::env::set_var(&env_var, "5");
1805 assert_eq!(get_poller_count_for_queue(QueueType::Notification), 5);
1806 std::env::remove_var(&env_var);
1807 }
1808
1809 #[test]
1810 #[serial_test::serial]
1811 fn test_get_poller_count_for_queue_env_zero_clamped_to_1() {
1812 let env_var = format!("SQS_{}_POLLER_COUNT", QueueType::StatusCheck.sqs_env_key());
1813 std::env::set_var(&env_var, "0");
1814 assert_eq!(
1815 get_poller_count_for_queue(QueueType::StatusCheck),
1816 1,
1817 "Zero poller count from env should be clamped to 1"
1818 );
1819 std::env::remove_var(&env_var);
1820 }
1821
1822 #[test]
1825 fn test_poll_loop_config_clone() {
1826 let config = PollLoopConfig {
1827 queue_type: QueueType::TransactionRequest,
1828 polling_interval: 15,
1829 visibility_timeout: 120,
1830 handler_timeout: Duration::from_secs(120),
1831 max_retries: 3,
1832 poller_id: 0,
1833 poller_count: 2,
1834 };
1835 let cloned = config.clone();
1836 assert_eq!(cloned.polling_interval, 15);
1837 assert_eq!(cloned.poller_id, 0);
1838 assert_eq!(cloned.poller_count, 2);
1839 assert_eq!(cloned.max_retries, 3);
1840 }
1841}