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::metrics::observe_queue_pickup_latency;
23use crate::queues::{backoff_config_for_queue, retry_delay_secs};
24use crate::{
25 config::ServerConfig,
26 jobs::{
27 notification_handler, relayer_health_check_handler, token_swap_request_handler,
28 transaction_request_handler, transaction_status_handler, transaction_submission_handler,
29 Job, NotificationSend, RelayerHealthCheck, TokenSwapRequest, TransactionRequest,
30 TransactionSend, TransactionStatusCheck,
31 },
32 utils::{aws_error::DisplayErrorContext, classify_sdk_error},
33};
34
35use super::{HandlerError, WorkerContext};
36use super::{QueueBackendError, QueueType, WorkerHandle};
37
38#[derive(Debug)]
39enum ProcessingError {
40 Retryable(String),
41 Permanent(String),
42}
43
44#[derive(Debug)]
47enum MessageOutcome {
48 Delete { receipt_handle: String },
50 Retain,
53}
54
55#[derive(Clone)]
58struct PollLoopConfig {
59 queue_type: QueueType,
60 polling_interval: u64,
61 visibility_timeout: u32,
62 handler_timeout: Duration,
63 max_retries: usize,
64 poller_id: usize,
65 poller_count: usize,
66}
67
68pub async fn spawn_worker_for_queue(
82 sqs_client: aws_sdk_sqs::Client,
83 queue_type: QueueType,
84 queue_url: String,
85 app_state: Arc<ThinData<crate::models::DefaultAppState>>,
86 shutdown_rx: watch::Receiver<bool>,
87 runtime_handle: tokio::runtime::Handle,
88) -> Result<WorkerHandle, QueueBackendError> {
89 let concurrency = get_concurrency_for_queue(queue_type);
90 let max_retries = queue_type.max_retries();
91 let polling_interval = get_wait_time_for_queue(queue_type);
92 let poller_count = get_poller_count_for_queue(queue_type);
93 let visibility_timeout = queue_type.visibility_timeout_secs();
94 let handler_timeout_secs = handler_timeout_secs(queue_type);
95 let handler_timeout = Duration::from_secs(handler_timeout_secs);
96
97 info!(
98 queue_type = ?queue_type,
99 queue_url = %queue_url,
100 concurrency = concurrency,
101 max_retries = max_retries,
102 polling_interval_secs = polling_interval,
103 poller_count = poller_count,
104 visibility_timeout_secs = visibility_timeout,
105 handler_timeout_secs = handler_timeout_secs,
106 "Spawning SQS worker"
107 );
108
109 let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
111
112 let handle: JoinHandle<()> = runtime_handle.spawn(async move {
115 let mut poller_handles: JoinSet<()> = JoinSet::new();
116
117 for poller_id in 0..poller_count {
118 let client = sqs_client.clone();
119 let url = queue_url.clone();
120 let state = app_state.clone();
121 let sem = semaphore.clone();
122 let mut rx = shutdown_rx.clone();
123 let config = PollLoopConfig {
124 queue_type,
125 polling_interval,
126 visibility_timeout,
127 handler_timeout,
128 max_retries,
129 poller_id,
130 poller_count,
131 };
132
133 poller_handles.spawn(async move {
134 run_poll_loop(client, url, state, sem, &mut rx, config).await;
135 });
136 }
137
138 while let Some(join_result) = poller_handles.join_next().await {
140 if let Err(err) = join_result {
141 error!(
142 queue_type = ?queue_type,
143 error = %err,
144 "SQS poller task terminated unexpectedly"
145 );
146 }
147 }
148 info!(queue_type = ?queue_type, "SQS worker stopped");
149 });
150
151 Ok(WorkerHandle::Tokio(handle))
152}
153
154async fn run_poll_loop(
157 sqs_client: aws_sdk_sqs::Client,
158 queue_url: String,
159 app_state: Arc<ThinData<crate::models::DefaultAppState>>,
160 semaphore: Arc<tokio::sync::Semaphore>,
161 shutdown_rx: &mut watch::Receiver<bool>,
162 config: PollLoopConfig,
163) {
164 let PollLoopConfig {
165 queue_type,
166 polling_interval,
167 visibility_timeout,
168 handler_timeout,
169 max_retries,
170 poller_id,
171 poller_count,
172 } = config;
173 let mut inflight: JoinSet<Option<String>> = JoinSet::new();
174 let mut consecutive_poll_errors: u32 = 0;
175 let mut pending_deletes: Vec<String> = Vec::new();
176
177 loop {
178 while let Some(result) = inflight.try_join_next() {
180 match result {
181 Ok(Some(receipt_handle)) => pending_deletes.push(receipt_handle),
182 Ok(None) => {} Err(e) => {
184 warn!(
185 queue_type = ?queue_type,
186 poller_id = poller_id,
187 error = %e,
188 "In-flight task failed"
189 );
190 }
191 }
192 }
193
194 if !pending_deletes.is_empty() {
196 flush_delete_batch(&sqs_client, &queue_url, &pending_deletes, queue_type).await;
197 pending_deletes.clear();
198 }
199
200 if *shutdown_rx.borrow() {
202 info!(queue_type = ?queue_type, poller_id = poller_id, "Shutdown signal received, stopping SQS poller");
203 break;
204 }
205
206 let available_permits = semaphore.available_permits();
213 let base_share = available_permits / poller_count;
214 let remainder = available_permits % poller_count;
215 let my_share = base_share + usize::from(poller_id < remainder);
216 if my_share == 0 {
217 tokio::select! {
218 _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
219 _ = shutdown_rx.changed() => {
220 info!(queue_type = ?queue_type, poller_id = poller_id, "Shutdown signal received, stopping SQS poller");
221 break;
222 }
223 }
224 }
225
226 let batch_size = my_share.min(10) as i32;
228
229 let messages_result = tokio::select! {
231 result = sqs_client
232 .receive_message()
233 .queue_url(&queue_url)
234 .max_number_of_messages(batch_size) .wait_time_seconds(polling_interval as i32)
236 .visibility_timeout(visibility_timeout as i32)
237 .message_system_attribute_names(MessageSystemAttributeName::ApproximateReceiveCount)
238 .message_system_attribute_names(MessageSystemAttributeName::MessageGroupId)
239 .message_system_attribute_names(MessageSystemAttributeName::SentTimestamp)
240 .message_attribute_names("target_scheduled_on")
241 .message_attribute_names("retry_attempt")
242 .send() => result,
243 _ = shutdown_rx.changed() => {
244 info!(queue_type = ?queue_type, poller_id = poller_id, "Shutdown signal received during SQS poll, stopping poller");
245 break;
246 }
247 };
248
249 match messages_result {
250 Ok(output) => {
251 if consecutive_poll_errors > 0 {
252 info!(
253 queue_type = ?queue_type,
254 poller_id = poller_id,
255 previous_errors = consecutive_poll_errors,
256 "SQS polling recovered after consecutive errors"
257 );
258 }
259 consecutive_poll_errors = 0;
260
261 if let Some(messages) = output.messages {
262 if !messages.is_empty() {
263 debug!(
264 queue_type = ?queue_type,
265 poller_id = poller_id,
266 message_count = messages.len(),
267 "Received messages from SQS"
268 );
269
270 for message in messages {
272 let permit = match semaphore.clone().acquire_owned().await {
273 Ok(permit) => permit,
274 Err(err) => {
275 error!(
276 queue_type = ?queue_type,
277 poller_id = poller_id,
278 error = %err,
279 "Semaphore closed, stopping SQS poller loop"
280 );
281 return;
282 }
283 };
284 let client = sqs_client.clone();
285 let url = queue_url.clone();
286 let state = app_state.clone();
287
288 inflight.spawn(async move {
289 let _permit = permit; let result = tokio::time::timeout(
292 handler_timeout,
293 AssertUnwindSafe(process_message(
294 client.clone(),
295 message,
296 queue_type,
297 &url,
298 state,
299 max_retries,
300 ))
301 .catch_unwind(),
302 )
303 .await;
304
305 match result {
306 Ok(Ok(Ok(MessageOutcome::Delete { receipt_handle }))) => {
307 Some(receipt_handle)
308 }
309 Ok(Ok(Ok(MessageOutcome::Retain))) => None,
310 Ok(Ok(Err(e))) => {
311 error!(
312 queue_type = ?queue_type,
313 error = %e,
314 "Failed to process message"
315 );
316 None
317 }
318 Ok(Err(panic_info)) => {
319 let msg = panic_info
320 .downcast_ref::<String>()
321 .map(|s| s.as_str())
322 .or_else(|| {
323 panic_info.downcast_ref::<&str>().copied()
324 })
325 .unwrap_or("unknown panic");
326 error!(
327 queue_type = ?queue_type,
328 panic = %msg,
329 "Message handler panicked"
330 );
331 None
332 }
333 Err(_) => {
334 error!(
335 queue_type = ?queue_type,
336 timeout_secs = handler_timeout.as_secs(),
337 "Message handler timed out; message will be retried after visibility timeout"
338 );
339 None
340 }
341 }
342 });
343 }
344 }
345 }
346 }
347 Err(e) => {
348 consecutive_poll_errors = consecutive_poll_errors.saturating_add(1);
349 let backoff_secs = poll_error_backoff_secs(consecutive_poll_errors);
350 let (error_code, error_message) = match &e {
351 SdkError::ServiceError(ctx) => (ctx.err().code(), ctx.err().message()),
352 _ => (None, None),
353 };
354 error!(
355 queue_type = ?queue_type,
356 poller_id = poller_id,
357 error.kind = classify_sdk_error(&e),
358 error.detail = %DisplayErrorContext(&e),
359 error_code = error_code.unwrap_or("unknown"),
360 error_message = error_message.unwrap_or("n/a"),
361 consecutive_errors = consecutive_poll_errors,
362 backoff_secs = backoff_secs,
363 "Failed to receive messages from SQS, backing off"
364 );
365 tokio::select! {
366 _ = tokio::time::sleep(Duration::from_secs(backoff_secs)) => {}
367 _ = shutdown_rx.changed() => {
368 info!(queue_type = ?queue_type, poller_id = poller_id, "Shutdown signal received during backoff, stopping poller");
369 break;
370 }
371 }
372 }
373 }
374 }
375
376 if !inflight.is_empty() {
378 info!(
379 queue_type = ?queue_type,
380 poller_id = poller_id,
381 count = inflight.len(),
382 "Draining in-flight tasks before shutdown"
383 );
384 match tokio::time::timeout(Duration::from_secs(30), async {
385 while let Some(result) = inflight.join_next().await {
386 match result {
387 Ok(Some(receipt_handle)) => pending_deletes.push(receipt_handle),
388 Ok(None) => {}
389 Err(e) => {
390 warn!(
391 queue_type = ?queue_type,
392 poller_id = poller_id,
393 error = %e,
394 "In-flight task failed during drain"
395 );
396 }
397 }
398 }
399 })
400 .await
401 {
402 Ok(()) => {
403 info!(queue_type = ?queue_type, poller_id = poller_id, "All in-flight tasks drained")
404 }
405 Err(_) => {
406 warn!(
407 queue_type = ?queue_type,
408 poller_id = poller_id,
409 remaining = inflight.len(),
410 "Drain timeout, abandoning remaining tasks"
411 );
412 inflight.abort_all();
413 }
414 }
415 }
416
417 if !pending_deletes.is_empty() {
419 flush_delete_batch(&sqs_client, &queue_url, &pending_deletes, queue_type).await;
420 }
421}
422
423async fn process_message(
428 sqs_client: aws_sdk_sqs::Client,
429 message: Message,
430 queue_type: QueueType,
431 queue_url: &str,
432 app_state: Arc<ThinData<crate::models::DefaultAppState>>,
433 max_retries: usize,
434) -> Result<MessageOutcome, QueueBackendError> {
435 let body = message
436 .body()
437 .ok_or_else(|| QueueBackendError::QueueError("Empty message body".to_string()))?;
438
439 let receipt_handle = message
440 .receipt_handle()
441 .ok_or_else(|| QueueBackendError::QueueError("Missing receipt handle".to_string()))?;
442
443 if let Some(baseline) = queue_pickup_baseline_ms(&message) {
457 let now_ms = chrono::Utc::now().timestamp_millis();
458 let delta_ms = now_ms - baseline;
463 if parse_target_scheduled_on(&message).is_none()
464 && delta_ms > PICKUP_LATENCY_CLOCK_SKEW_THRESHOLD_MS
465 {
466 warn!(
467 queue_type = ?queue_type,
468 latency_ms = delta_ms,
469 "queue_pickup_latency above sanity threshold for non-scheduled SQS message; check broker/consumer clock skew"
470 );
471 }
472 observe_queue_pickup_latency(
473 queue_type.queue_name(),
474 "sqs",
475 pickup_latency_secs(baseline, now_ms),
476 );
477 }
478
479 if let Some(target_scheduled_on) = parse_target_scheduled_on(&message) {
481 let now = std::time::SystemTime::now()
482 .duration_since(std::time::SystemTime::UNIX_EPOCH)
483 .map_err(|e| QueueBackendError::QueueError(format!("System clock error: {e}")))?
484 .as_secs() as i64;
485 let remaining = target_scheduled_on - now;
486 if remaining > 0 {
487 let should_delete_original = defer_message(
488 &sqs_client,
489 queue_url,
490 body.to_string(),
491 &message,
492 target_scheduled_on,
493 remaining.min(900) as i32,
494 )
495 .await?;
496
497 debug!(
498 queue_type = ?queue_type,
499 remaining_seconds = remaining,
500 "Deferred scheduled SQS message for next delay hop"
501 );
502 return if should_delete_original {
503 Ok(MessageOutcome::Delete {
504 receipt_handle: receipt_handle.to_string(),
505 })
506 } else {
507 Ok(MessageOutcome::Retain)
508 };
509 }
510 }
511
512 let receive_count = message
514 .attributes()
515 .and_then(|attrs| attrs.get(&MessageSystemAttributeName::ApproximateReceiveCount))
516 .and_then(|count| count.parse::<usize>().ok())
517 .unwrap_or(1);
518 let attempt_number = receive_count.saturating_sub(1);
520 let logical_retry_attempt =
527 effective_handler_attempt(receive_count, parse_retry_attempt(&message));
528
529 let sqs_message_id = message.message_id().unwrap_or("unknown").to_string();
531
532 debug!(
533 queue_type = ?queue_type,
534 message_id = %sqs_message_id,
535 attempt = attempt_number,
536 receive_count = receive_count,
537 max_retries = max_retries,
538 "Processing message"
539 );
540
541 let result = match queue_type {
543 QueueType::TransactionRequest => {
544 process_job::<TransactionRequest, _, _>(
545 body,
546 app_state,
547 logical_retry_attempt,
548 sqs_message_id,
549 "TransactionRequest",
550 transaction_request_handler,
551 )
552 .await
553 }
554 QueueType::TransactionSubmission => {
555 process_job::<TransactionSend, _, _>(
556 body,
557 app_state,
558 logical_retry_attempt,
559 sqs_message_id,
560 "TransactionSend",
561 transaction_submission_handler,
562 )
563 .await
564 }
565 QueueType::StatusCheck | QueueType::StatusCheckEvm | QueueType::StatusCheckStellar => {
566 process_job::<TransactionStatusCheck, _, _>(
567 body,
568 app_state,
569 logical_retry_attempt,
570 sqs_message_id,
571 "TransactionStatusCheck",
572 transaction_status_handler,
573 )
574 .await
575 }
576 QueueType::Notification => {
577 process_job::<NotificationSend, _, _>(
578 body,
579 app_state,
580 logical_retry_attempt,
581 sqs_message_id,
582 "NotificationSend",
583 notification_handler,
584 )
585 .await
586 }
587 QueueType::TokenSwapRequest => {
588 process_job::<TokenSwapRequest, _, _>(
589 body,
590 app_state,
591 logical_retry_attempt,
592 sqs_message_id,
593 "TokenSwapRequest",
594 token_swap_request_handler,
595 )
596 .await
597 }
598 QueueType::RelayerHealthCheck => {
599 process_job::<RelayerHealthCheck, _, _>(
600 body,
601 app_state,
602 logical_retry_attempt,
603 sqs_message_id,
604 "RelayerHealthCheck",
605 relayer_health_check_handler,
606 )
607 .await
608 }
609 };
610
611 match result {
612 Ok(()) => {
613 debug!(
614 queue_type = ?queue_type,
615 attempt = attempt_number,
616 "Message processed successfully"
617 );
618
619 Ok(MessageOutcome::Delete {
620 receipt_handle: receipt_handle.to_string(),
621 })
622 }
623 Err(ProcessingError::Permanent(e)) => {
624 error!(
625 queue_type = ?queue_type,
626 attempt = attempt_number,
627 error = %e,
628 "Permanent handler failure, message will be deleted"
629 );
630
631 Ok(MessageOutcome::Delete {
632 receipt_handle: receipt_handle.to_string(),
633 })
634 }
635 Err(ProcessingError::Retryable(e)) => {
636 if max_retries != usize::MAX && receive_count > max_retries {
638 error!(
639 queue_type = ?queue_type,
640 attempt = attempt_number,
641 receive_count = receive_count,
642 max_retries = max_retries,
643 error = %e,
644 "Max retries exceeded; message will be automatically moved to DLQ by SQS redrive policy"
645 );
646 return Ok(MessageOutcome::Retain);
647 }
648
649 let delay = if queue_type.is_status_check() {
653 compute_status_retry_delay(body, logical_retry_attempt)
654 } else {
655 retry_delay_secs(backoff_config_for_queue(queue_type), logical_retry_attempt)
656 };
657
658 if is_fifo_queue_url(queue_url) {
661 if let Err(err) = sqs_client
662 .change_message_visibility()
663 .queue_url(queue_url)
664 .receipt_handle(receipt_handle)
665 .visibility_timeout(delay.clamp(1, 900))
666 .send()
667 .await
668 {
669 error!(
670 queue_type = ?queue_type,
671 error = %err,
672 "Failed to set visibility timeout for retry; falling back to existing visibility timeout"
673 );
674 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 "Retry scheduled via visibility timeout"
683 );
684
685 return Ok(MessageOutcome::Retain);
686 }
687
688 let next_retry_attempt = logical_retry_attempt.saturating_add(1);
689
690 if let Err(send_err) = sqs_client
694 .send_message()
695 .queue_url(queue_url)
696 .message_body(body.to_string())
697 .delay_seconds(delay)
698 .message_attributes(
699 "retry_attempt",
700 MessageAttributeValue::builder()
701 .data_type("Number")
702 .string_value(next_retry_attempt.to_string())
703 .build()
704 .map_err(|err| {
705 QueueBackendError::SqsError(format!(
706 "Failed to build retry_attempt attribute: {err}"
707 ))
708 })?,
709 )
710 .send()
711 .await
712 {
713 error!(
714 queue_type = ?queue_type,
715 error.kind = classify_sdk_error(&send_err),
716 error.detail = %DisplayErrorContext(&send_err),
717 "Failed to re-enqueue message; leaving original for visibility timeout retry"
718 );
719 return Ok(MessageOutcome::Retain);
721 }
722
723 debug!(
724 queue_type = ?queue_type,
725 attempt = logical_retry_attempt,
726 delay_seconds = delay,
727 error = %e,
728 "Message re-enqueued with backoff delay"
729 );
730
731 Ok(MessageOutcome::Delete {
733 receipt_handle: receipt_handle.to_string(),
734 })
735 }
736 }
737}
738
739async fn process_job<T, F, Fut>(
742 body: &str,
743 app_state: Arc<ThinData<crate::models::DefaultAppState>>,
744 attempt: usize,
745 task_id: String,
746 type_name: &str,
747 handler: F,
748) -> Result<(), ProcessingError>
749where
750 T: DeserializeOwned,
751 F: FnOnce(Job<T>, ThinData<crate::models::DefaultAppState>, WorkerContext) -> Fut,
752 Fut: Future<Output = Result<(), HandlerError>>,
753{
754 let job: Job<T> = serde_json::from_str(body).map_err(|e| {
755 error!(error = %e, "Failed to deserialize {} job", type_name);
756 ProcessingError::Permanent(format!("Failed to deserialize {type_name} job: {e}"))
758 })?;
759
760 let ctx = WorkerContext::new(attempt, task_id);
761 handler(job, (*app_state).clone(), ctx)
762 .await
763 .map_err(map_handler_error)
764}
765
766fn map_handler_error(error: HandlerError) -> ProcessingError {
767 match error {
768 HandlerError::Abort(msg) => ProcessingError::Permanent(msg),
769 HandlerError::Retry(msg) => ProcessingError::Retryable(msg),
770 }
771}
772
773fn parse_target_scheduled_on(message: &Message) -> Option<i64> {
774 message
775 .message_attributes()
776 .and_then(|attrs| attrs.get("target_scheduled_on"))
777 .and_then(|value| value.string_value())
778 .and_then(|value| value.parse::<i64>().ok())
779}
780
781fn parse_retry_attempt(message: &Message) -> Option<usize> {
782 message
783 .message_attributes()
784 .and_then(|attrs| attrs.get("retry_attempt"))
785 .and_then(|value| value.string_value())
786 .and_then(|value| value.parse::<usize>().ok())
787}
788
789fn effective_handler_attempt(receive_count: usize, retry_attempt: Option<usize>) -> usize {
802 retry_attempt.unwrap_or_else(|| receive_count.saturating_sub(1))
803}
804
805fn pickup_latency_secs(baseline_ms: i64, now_ms: i64) -> f64 {
809 (now_ms - baseline_ms).max(0) as f64 / 1000.0
810}
811
812const PICKUP_LATENCY_CLOCK_SKEW_THRESHOLD_MS: i64 = 60 * 60 * 1000;
817
818fn queue_pickup_baseline_ms(message: &Message) -> Option<i64> {
819 let receive_count = message
845 .attributes()
846 .and_then(|attrs| attrs.get(&MessageSystemAttributeName::ApproximateReceiveCount))
847 .and_then(|count| count.parse::<usize>().ok())
848 .unwrap_or(1);
849 if receive_count != 1 {
850 return None;
851 }
852
853 if parse_retry_attempt(message).is_some_and(|n| n > 0) {
857 return None;
858 }
859
860 parse_target_scheduled_on(message)
861 .map(|ts_secs| ts_secs * 1000)
862 .or_else(|| {
863 message
864 .attributes()
865 .and_then(|a| a.get(&MessageSystemAttributeName::SentTimestamp))
866 .and_then(|v| v.parse::<i64>().ok())
867 })
868}
869
870fn is_fifo_queue_url(queue_url: &str) -> bool {
871 queue_url.ends_with(".fifo")
872}
873
874async fn defer_message(
875 sqs_client: &aws_sdk_sqs::Client,
876 queue_url: &str,
877 body: String,
878 message: &Message,
879 target_scheduled_on: i64,
880 delay_seconds: i32,
881) -> Result<bool, QueueBackendError> {
882 if is_fifo_queue_url(queue_url) {
883 let receipt_handle = message.receipt_handle().ok_or_else(|| {
884 QueueBackendError::QueueError(
885 "Cannot defer FIFO message: missing receipt handle".to_string(),
886 )
887 })?;
888
889 sqs_client
890 .change_message_visibility()
891 .queue_url(queue_url)
892 .receipt_handle(receipt_handle)
893 .visibility_timeout(delay_seconds.clamp(1, 900))
894 .send()
895 .await
896 .map_err(|e| {
897 error!(
898 error.kind = classify_sdk_error(&e),
899 error.detail = %DisplayErrorContext(&e),
900 queue_url = %queue_url,
901 "Failed to defer FIFO message via visibility timeout"
902 );
903 QueueBackendError::SqsError(format!(
904 "Failed to defer FIFO message via visibility timeout: {}",
905 classify_sdk_error(&e)
906 ))
907 })?;
908
909 return Ok(false);
910 }
911
912 let request = sqs_client
915 .send_message()
916 .queue_url(queue_url)
917 .message_body(body)
918 .delay_seconds(delay_seconds.clamp(1, 900))
919 .message_attributes(
920 "target_scheduled_on",
921 MessageAttributeValue::builder()
922 .data_type("Number")
923 .string_value(target_scheduled_on.to_string())
924 .build()
925 .map_err(|e| {
926 QueueBackendError::SqsError(format!(
927 "Failed to build deferred scheduled attribute: {e}"
928 ))
929 })?,
930 );
931
932 request.send().await.map_err(|e| {
933 error!(
934 error.kind = classify_sdk_error(&e),
935 error.detail = %DisplayErrorContext(&e),
936 queue_url = %queue_url,
937 "Failed to defer scheduled message"
938 );
939 QueueBackendError::SqsError(format!(
940 "Failed to defer scheduled message: {}",
941 classify_sdk_error(&e)
942 ))
943 })?;
944
945 Ok(true)
946}
947
948#[derive(serde::Deserialize)]
953struct StatusCheckData {
954 network_type: Option<crate::models::NetworkType>,
955}
956
957#[derive(serde::Deserialize)]
962struct PartialStatusCheckJob {
963 data: StatusCheckData,
964}
965
966fn compute_status_retry_delay(body: &str, attempt: usize) -> i32 {
973 let network_type = serde_json::from_str::<PartialStatusCheckJob>(body)
974 .ok()
975 .and_then(|j| j.data.network_type);
976
977 crate::queues::retry_config::status_check_retry_delay_secs(network_type, attempt)
978}
979
980fn get_wait_time_for_queue(queue_type: QueueType) -> u64 {
982 ServerConfig::get_sqs_wait_time(
983 queue_type.sqs_env_key(),
984 queue_type.default_wait_time_secs(),
985 )
986}
987
988fn get_poller_count_for_queue(queue_type: QueueType) -> usize {
990 let configured = ServerConfig::get_sqs_poller_count(
991 queue_type.sqs_env_key(),
992 queue_type.default_poller_count(),
993 );
994 if configured == 0 {
995 warn!(
996 queue_type = ?queue_type,
997 "Configured poller count is 0; clamping to 1"
998 );
999 1
1000 } else {
1001 configured
1002 }
1003}
1004
1005fn get_concurrency_for_queue(queue_type: QueueType) -> usize {
1007 let configured = ServerConfig::get_worker_concurrency(
1008 queue_type.concurrency_env_key(),
1009 queue_type.default_concurrency(),
1010 );
1011 if configured == 0 {
1012 warn!(
1013 queue_type = ?queue_type,
1014 "Configured concurrency is 0; clamping to 1"
1015 );
1016 1
1017 } else {
1018 configured
1019 }
1020}
1021
1022fn handler_timeout_secs(queue_type: QueueType) -> u64 {
1026 u64::from(queue_type.visibility_timeout_secs().max(1))
1027}
1028
1029const MAX_POLL_BACKOFF_SECS: u64 = 60;
1031
1032const RECOVERY_PROBE_EVERY: u32 = 4;
1036
1037fn poll_error_backoff_secs(consecutive_errors: u32) -> u64 {
1041 let base: u64 = 5;
1042
1043 if consecutive_errors >= 7 && consecutive_errors.is_multiple_of(RECOVERY_PROBE_EVERY) {
1046 return base;
1047 }
1048
1049 let exponent = consecutive_errors.saturating_sub(1).min(16);
1050 base.saturating_mul(2_u64.saturating_pow(exponent))
1051 .min(MAX_POLL_BACKOFF_SECS)
1052}
1053
1054async fn flush_delete_batch(
1060 sqs_client: &aws_sdk_sqs::Client,
1061 queue_url: &str,
1062 batch: &[String],
1063 queue_type: QueueType,
1064) -> usize {
1065 if batch.is_empty() {
1066 return 0;
1067 }
1068
1069 let mut deleted = 0;
1070
1071 for chunk in batch.chunks(10) {
1072 let entries: Vec<DeleteMessageBatchRequestEntry> = chunk
1073 .iter()
1074 .enumerate()
1075 .map(|(i, handle)| {
1076 DeleteMessageBatchRequestEntry::builder()
1077 .id(i.to_string())
1078 .receipt_handle(handle)
1079 .build()
1080 .expect("id and receipt_handle are always set")
1081 })
1082 .collect();
1083
1084 match sqs_client
1085 .delete_message_batch()
1086 .queue_url(queue_url)
1087 .set_entries(Some(entries))
1088 .send()
1089 .await
1090 {
1091 Ok(output) => {
1092 deleted += output.successful().len();
1093
1094 for f in output.failed() {
1095 warn!(
1096 queue_type = ?queue_type,
1097 id = %f.id(),
1098 code = %f.code(),
1099 message = f.message().unwrap_or("unknown"),
1100 "Batch delete entry failed (message will be redelivered)"
1101 );
1102 }
1103 }
1104 Err(e) => {
1105 error!(
1106 queue_type = ?queue_type,
1107 error.kind = classify_sdk_error(&e),
1108 error.detail = %DisplayErrorContext(&e),
1109 batch_size = chunk.len(),
1110 "Batch delete API call failed (messages will be redelivered)"
1111 );
1112 }
1113 }
1114 }
1115
1116 deleted
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122
1123 #[test]
1124 fn test_get_concurrency_for_queue() {
1125 let concurrency = get_concurrency_for_queue(QueueType::TransactionRequest);
1127 assert!(concurrency > 0);
1128
1129 let concurrency = get_concurrency_for_queue(QueueType::StatusCheck);
1130 assert!(concurrency > 0);
1131 }
1132
1133 #[test]
1134 fn test_handler_timeout_secs_is_positive() {
1135 let all = [
1136 QueueType::TransactionRequest,
1137 QueueType::TransactionSubmission,
1138 QueueType::StatusCheck,
1139 QueueType::StatusCheckEvm,
1140 QueueType::StatusCheckStellar,
1141 QueueType::Notification,
1142 QueueType::TokenSwapRequest,
1143 QueueType::RelayerHealthCheck,
1144 ];
1145 for queue_type in all {
1146 assert!(handler_timeout_secs(queue_type) > 0);
1147 }
1148 }
1149
1150 #[test]
1151 fn test_handler_timeout_secs_uses_visibility_timeout() {
1152 assert_eq!(
1153 handler_timeout_secs(QueueType::StatusCheckEvm),
1154 QueueType::StatusCheckEvm.visibility_timeout_secs() as u64
1155 );
1156 assert_eq!(
1157 handler_timeout_secs(QueueType::Notification),
1158 QueueType::Notification.visibility_timeout_secs() as u64
1159 );
1160 }
1161
1162 #[test]
1163 fn test_parse_target_scheduled_on() {
1164 let message = Message::builder().build();
1166
1167 assert_eq!(parse_target_scheduled_on(&message), None);
1169
1170 let message = Message::builder()
1172 .message_attributes(
1173 "target_scheduled_on",
1174 MessageAttributeValue::builder()
1175 .data_type("Number")
1176 .string_value("1234567890")
1177 .build()
1178 .unwrap(),
1179 )
1180 .build();
1181
1182 assert_eq!(parse_target_scheduled_on(&message), Some(1234567890));
1183 }
1184
1185 #[test]
1186 fn test_parse_retry_attempt() {
1187 let message = Message::builder().build();
1188 assert_eq!(parse_retry_attempt(&message), None);
1189
1190 let message = Message::builder()
1191 .message_attributes(
1192 "retry_attempt",
1193 MessageAttributeValue::builder()
1194 .data_type("Number")
1195 .string_value("7")
1196 .build()
1197 .unwrap(),
1198 )
1199 .build();
1200 assert_eq!(parse_retry_attempt(&message), Some(7));
1201 }
1202
1203 #[test]
1204 fn test_map_handler_error() {
1205 let error = HandlerError::Abort("Validation failed".to_string());
1207 let result = map_handler_error(error);
1208 assert!(matches!(result, ProcessingError::Permanent(_)));
1209
1210 let error = HandlerError::Retry("Network timeout".to_string());
1212 let result = map_handler_error(error);
1213 assert!(matches!(result, ProcessingError::Retryable(_)));
1214 }
1215
1216 #[test]
1217 fn test_is_fifo_queue_url() {
1218 assert!(is_fifo_queue_url(
1219 "https://sqs.us-east-1.amazonaws.com/123/queue.fifo"
1220 ));
1221 assert!(!is_fifo_queue_url(
1222 "https://sqs.us-east-1.amazonaws.com/123/queue"
1223 ));
1224 }
1225
1226 #[test]
1227 fn test_compute_status_retry_delay_evm() {
1228 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1","network_type":"evm"}}"#;
1230 assert_eq!(compute_status_retry_delay(body, 0), 8);
1231 assert_eq!(compute_status_retry_delay(body, 1), 12);
1232 assert_eq!(compute_status_retry_delay(body, 8), 12);
1233 }
1234
1235 #[test]
1236 fn test_compute_status_retry_delay_stellar() {
1237 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1","network_type":"stellar"}}"#;
1238 assert_eq!(compute_status_retry_delay(body, 0), 2);
1239 assert_eq!(compute_status_retry_delay(body, 1), 3);
1240 assert_eq!(compute_status_retry_delay(body, 8), 3);
1241 }
1242
1243 #[test]
1244 fn test_compute_status_retry_delay_solana() {
1245 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1","network_type":"solana"}}"#;
1246 assert_eq!(compute_status_retry_delay(body, 0), 5);
1247 assert_eq!(compute_status_retry_delay(body, 1), 8);
1248 assert_eq!(compute_status_retry_delay(body, 8), 8);
1249 }
1250
1251 #[test]
1252 fn test_compute_status_retry_delay_missing_network() {
1253 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1"}}"#;
1254 assert_eq!(compute_status_retry_delay(body, 0), 5);
1255 assert_eq!(compute_status_retry_delay(body, 1), 8);
1256 assert_eq!(compute_status_retry_delay(body, 8), 8);
1257 }
1258
1259 #[test]
1260 fn test_compute_status_retry_delay_invalid_body() {
1261 assert_eq!(compute_status_retry_delay("not json", 0), 5);
1262 assert_eq!(compute_status_retry_delay("not json", 1), 8);
1263 assert_eq!(compute_status_retry_delay("not json", 8), 8);
1264 }
1265
1266 #[tokio::test]
1267 async fn test_semaphore_released_on_panic() {
1268 let sem = Arc::new(tokio::sync::Semaphore::new(1));
1269 let permit = sem.clone().acquire_owned().await.unwrap();
1270
1271 let handle = tokio::spawn(async move {
1272 let _permit = permit; let _ = AssertUnwindSafe(async { panic!("test panic") })
1274 .catch_unwind()
1275 .await;
1276 });
1277
1278 handle.await.unwrap();
1279 let _p = tokio::time::timeout(Duration::from_millis(100), sem.acquire())
1281 .await
1282 .expect("permit should be available after panic");
1283 }
1284
1285 #[test]
1286 fn test_poll_error_backoff_secs() {
1287 assert_eq!(poll_error_backoff_secs(1), 5);
1289 assert_eq!(poll_error_backoff_secs(2), 10);
1291 assert_eq!(poll_error_backoff_secs(3), 20);
1293 assert_eq!(poll_error_backoff_secs(4), 40);
1295 assert_eq!(poll_error_backoff_secs(5), 60);
1297 assert_eq!(poll_error_backoff_secs(6), 60);
1298 assert_eq!(poll_error_backoff_secs(7), 60);
1299 assert_eq!(poll_error_backoff_secs(8), 5);
1301 assert_eq!(poll_error_backoff_secs(9), 60);
1302 assert_eq!(poll_error_backoff_secs(12), 5); }
1304
1305 #[test]
1306 fn test_poll_error_backoff_zero_errors() {
1307 assert_eq!(poll_error_backoff_secs(0), 5);
1309 }
1310
1311 #[test]
1312 fn test_poll_error_backoff_recovery_probes() {
1313 for i in (8..=100).step_by(RECOVERY_PROBE_EVERY as usize) {
1315 assert_eq!(
1316 poll_error_backoff_secs(i as u32),
1317 5,
1318 "Expected recovery probe at error {i}"
1319 );
1320 }
1321 }
1322
1323 #[test]
1324 fn test_message_outcome_delete_carries_receipt_handle() {
1325 let handle = "test-receipt-handle-123".to_string();
1326 let outcome = MessageOutcome::Delete {
1327 receipt_handle: handle.clone(),
1328 };
1329 match outcome {
1330 MessageOutcome::Delete { receipt_handle } => {
1331 assert_eq!(receipt_handle, handle);
1332 }
1333 MessageOutcome::Retain => panic!("Expected Delete variant"),
1334 }
1335 }
1336
1337 #[test]
1338 fn test_message_outcome_retain() {
1339 let outcome = MessageOutcome::Retain;
1340 assert!(matches!(outcome, MessageOutcome::Retain));
1341 }
1342
1343 #[test]
1344 fn test_batch_delete_entry_builder() {
1345 let handles = vec![
1348 "receipt-0".to_string(),
1349 "receipt-1".to_string(),
1350 "receipt-2".to_string(),
1351 ];
1352 let entries: Vec<DeleteMessageBatchRequestEntry> = handles
1353 .iter()
1354 .enumerate()
1355 .map(|(i, handle)| {
1356 DeleteMessageBatchRequestEntry::builder()
1357 .id(i.to_string())
1358 .receipt_handle(handle)
1359 .build()
1360 .expect("id and receipt_handle are set")
1361 })
1362 .collect();
1363
1364 assert_eq!(entries.len(), 3);
1365 assert_eq!(entries[0].id(), "0");
1366 assert_eq!(entries[0].receipt_handle(), "receipt-0");
1367 assert_eq!(entries[2].id(), "2");
1368 assert_eq!(entries[2].receipt_handle(), "receipt-2");
1369 }
1370
1371 #[test]
1372 fn test_batch_chunking_logic() {
1373 let handles: Vec<String> = (0..25).map(|i| format!("receipt-{i}")).collect();
1376 let chunks: Vec<&[String]> = handles.chunks(10).collect();
1377
1378 assert_eq!(chunks.len(), 3);
1379 assert_eq!(chunks[0].len(), 10);
1380 assert_eq!(chunks[1].len(), 10);
1381 assert_eq!(chunks[2].len(), 5);
1382 }
1383
1384 #[test]
1385 fn test_outcome_collection_pattern() {
1386 let outcomes = vec![
1389 Some("receipt-1".to_string()), None, Some("receipt-2".to_string()), None, Some("receipt-3".to_string()), ];
1395
1396 let pending_deletes: Vec<String> = outcomes.into_iter().flatten().collect();
1397
1398 assert_eq!(pending_deletes.len(), 3);
1399 assert_eq!(pending_deletes[0], "receipt-1");
1400 assert_eq!(pending_deletes[1], "receipt-2");
1401 assert_eq!(pending_deletes[2], "receipt-3");
1402 }
1403
1404 #[test]
1407 fn test_parse_target_scheduled_on_non_numeric_string() {
1408 let message = Message::builder()
1409 .message_attributes(
1410 "target_scheduled_on",
1411 MessageAttributeValue::builder()
1412 .data_type("String")
1413 .string_value("not-a-number")
1414 .build()
1415 .unwrap(),
1416 )
1417 .build();
1418 assert_eq!(parse_target_scheduled_on(&message), None);
1419 }
1420
1421 #[test]
1422 fn test_parse_target_scheduled_on_empty_string() {
1423 let message = Message::builder()
1424 .message_attributes(
1425 "target_scheduled_on",
1426 MessageAttributeValue::builder()
1427 .data_type("Number")
1428 .string_value("")
1429 .build()
1430 .unwrap(),
1431 )
1432 .build();
1433 assert_eq!(parse_target_scheduled_on(&message), None);
1434 }
1435
1436 #[test]
1437 fn test_parse_target_scheduled_on_negative_value() {
1438 let message = Message::builder()
1439 .message_attributes(
1440 "target_scheduled_on",
1441 MessageAttributeValue::builder()
1442 .data_type("Number")
1443 .string_value("-1000")
1444 .build()
1445 .unwrap(),
1446 )
1447 .build();
1448 assert_eq!(parse_target_scheduled_on(&message), Some(-1000));
1450 }
1451
1452 #[test]
1453 fn test_parse_target_scheduled_on_float_string() {
1454 let message = Message::builder()
1455 .message_attributes(
1456 "target_scheduled_on",
1457 MessageAttributeValue::builder()
1458 .data_type("Number")
1459 .string_value("1234567890.5")
1460 .build()
1461 .unwrap(),
1462 )
1463 .build();
1464 assert_eq!(parse_target_scheduled_on(&message), None);
1466 }
1467
1468 #[test]
1469 fn test_parse_target_scheduled_on_zero() {
1470 let message = Message::builder()
1471 .message_attributes(
1472 "target_scheduled_on",
1473 MessageAttributeValue::builder()
1474 .data_type("Number")
1475 .string_value("0")
1476 .build()
1477 .unwrap(),
1478 )
1479 .build();
1480 assert_eq!(parse_target_scheduled_on(&message), Some(0));
1481 }
1482
1483 #[test]
1484 fn test_parse_target_scheduled_on_wrong_attribute_name() {
1485 let message = Message::builder()
1487 .message_attributes(
1488 "wrong_key",
1489 MessageAttributeValue::builder()
1490 .data_type("Number")
1491 .string_value("1234567890")
1492 .build()
1493 .unwrap(),
1494 )
1495 .build();
1496 assert_eq!(parse_target_scheduled_on(&message), None);
1497 }
1498
1499 #[test]
1502 fn test_parse_retry_attempt_non_numeric_string() {
1503 let message = Message::builder()
1504 .message_attributes(
1505 "retry_attempt",
1506 MessageAttributeValue::builder()
1507 .data_type("String")
1508 .string_value("abc")
1509 .build()
1510 .unwrap(),
1511 )
1512 .build();
1513 assert_eq!(parse_retry_attempt(&message), None);
1514 }
1515
1516 #[test]
1517 fn test_parse_retry_attempt_negative_value() {
1518 let message = Message::builder()
1519 .message_attributes(
1520 "retry_attempt",
1521 MessageAttributeValue::builder()
1522 .data_type("Number")
1523 .string_value("-1")
1524 .build()
1525 .unwrap(),
1526 )
1527 .build();
1528 assert_eq!(parse_retry_attempt(&message), None);
1530 }
1531
1532 #[test]
1533 fn test_parse_retry_attempt_zero() {
1534 let message = Message::builder()
1535 .message_attributes(
1536 "retry_attempt",
1537 MessageAttributeValue::builder()
1538 .data_type("Number")
1539 .string_value("0")
1540 .build()
1541 .unwrap(),
1542 )
1543 .build();
1544 assert_eq!(parse_retry_attempt(&message), Some(0));
1545 }
1546
1547 #[test]
1548 fn test_parse_retry_attempt_large_value() {
1549 let message = Message::builder()
1550 .message_attributes(
1551 "retry_attempt",
1552 MessageAttributeValue::builder()
1553 .data_type("Number")
1554 .string_value("999999")
1555 .build()
1556 .unwrap(),
1557 )
1558 .build();
1559 assert_eq!(parse_retry_attempt(&message), Some(999999));
1560 }
1561
1562 #[test]
1563 fn test_queue_pickup_baseline_ms_uses_scheduled_time_on_first_delivery() {
1564 let message = Message::builder()
1565 .message_attributes(
1566 "target_scheduled_on",
1567 MessageAttributeValue::builder()
1568 .data_type("Number")
1569 .string_value("123")
1570 .build()
1571 .unwrap(),
1572 )
1573 .set_attributes(Some(std::collections::HashMap::from([(
1574 MessageSystemAttributeName::SentTimestamp,
1575 "999999".to_string(),
1576 )])))
1577 .build();
1578
1579 assert_eq!(queue_pickup_baseline_ms(&message), Some(123_000));
1581 }
1582
1583 #[test]
1584 fn test_queue_pickup_baseline_ms_falls_back_to_sent_timestamp() {
1585 let message = Message::builder()
1586 .set_attributes(Some(std::collections::HashMap::from([(
1587 MessageSystemAttributeName::SentTimestamp,
1588 "123456".to_string(),
1589 )])))
1590 .build();
1591
1592 assert_eq!(queue_pickup_baseline_ms(&message), Some(123456));
1593 }
1594
1595 #[test]
1596 fn test_pickup_latency_secs_clamps_negative_skew() {
1597 let now_ms = 1_000_000_i64;
1600 let baseline_ms = now_ms + 5_000;
1601 assert_eq!(pickup_latency_secs(baseline_ms, now_ms), 0.0);
1602 }
1603
1604 #[test]
1605 fn test_pickup_latency_secs_positive_delta() {
1606 assert_eq!(pickup_latency_secs(1_000_000, 1_002_500), 2.5);
1608 }
1609
1610 #[test]
1611 fn test_queue_pickup_baseline_ms_skips_when_retry_attempt_positive() {
1612 let message = Message::builder()
1613 .message_attributes(
1614 "target_scheduled_on",
1615 MessageAttributeValue::builder()
1616 .data_type("Number")
1617 .string_value("123")
1618 .build()
1619 .unwrap(),
1620 )
1621 .message_attributes(
1622 "retry_attempt",
1623 MessageAttributeValue::builder()
1624 .data_type("Number")
1625 .string_value("1")
1626 .build()
1627 .unwrap(),
1628 )
1629 .set_attributes(Some(std::collections::HashMap::from([(
1630 MessageSystemAttributeName::SentTimestamp,
1631 "123456".to_string(),
1632 )])))
1633 .build();
1634
1635 assert_eq!(queue_pickup_baseline_ms(&message), None);
1637 }
1638
1639 #[test]
1640 fn test_queue_pickup_baseline_ms_accepts_retry_attempt_zero() {
1641 let message = Message::builder()
1644 .message_attributes(
1645 "target_scheduled_on",
1646 MessageAttributeValue::builder()
1647 .data_type("Number")
1648 .string_value("777")
1649 .build()
1650 .unwrap(),
1651 )
1652 .message_attributes(
1653 "retry_attempt",
1654 MessageAttributeValue::builder()
1655 .data_type("Number")
1656 .string_value("0")
1657 .build()
1658 .unwrap(),
1659 )
1660 .build();
1661
1662 assert_eq!(queue_pickup_baseline_ms(&message), Some(777_000));
1663 }
1664
1665 #[test]
1666 fn test_effective_handler_attempt_prefers_persisted_retry_attempt() {
1667 assert_eq!(effective_handler_attempt(1, Some(3)), 3);
1671 }
1672
1673 #[test]
1674 fn test_effective_handler_attempt_falls_back_to_receive_count_for_fifo() {
1675 assert_eq!(effective_handler_attempt(4, None), 3);
1678 }
1679
1680 #[test]
1681 fn test_effective_handler_attempt_first_delivery_is_zero() {
1682 assert_eq!(effective_handler_attempt(1, None), 0);
1683 assert_eq!(effective_handler_attempt(1, Some(0)), 0);
1684 }
1685
1686 #[test]
1687 fn test_queue_pickup_baseline_ms_skips_when_receive_count_gt_one() {
1688 let message = Message::builder()
1693 .message_attributes(
1694 "target_scheduled_on",
1695 MessageAttributeValue::builder()
1696 .data_type("Number")
1697 .string_value("500")
1698 .build()
1699 .unwrap(),
1700 )
1701 .set_attributes(Some(std::collections::HashMap::from([
1702 (
1703 MessageSystemAttributeName::ApproximateReceiveCount,
1704 "2".to_string(),
1705 ),
1706 (MessageSystemAttributeName::SentTimestamp, "999".to_string()),
1707 ])))
1708 .build();
1709
1710 assert_eq!(queue_pickup_baseline_ms(&message), None);
1711 }
1712
1713 #[test]
1714 fn test_queue_pickup_baseline_ms_observes_when_receive_count_explicitly_one() {
1715 let message = Message::builder()
1719 .message_attributes(
1720 "target_scheduled_on",
1721 MessageAttributeValue::builder()
1722 .data_type("Number")
1723 .string_value("250")
1724 .build()
1725 .unwrap(),
1726 )
1727 .set_attributes(Some(std::collections::HashMap::from([(
1728 MessageSystemAttributeName::ApproximateReceiveCount,
1729 "1".to_string(),
1730 )])))
1731 .build();
1732
1733 assert_eq!(queue_pickup_baseline_ms(&message), Some(250_000));
1734 }
1735
1736 #[test]
1739 fn test_is_fifo_queue_url_empty_string() {
1740 assert!(!is_fifo_queue_url(""));
1741 }
1742
1743 #[test]
1744 fn test_is_fifo_queue_url_just_fifo_suffix() {
1745 assert!(is_fifo_queue_url("my-queue.fifo"));
1746 }
1747
1748 #[test]
1749 fn test_is_fifo_queue_url_fifo_in_middle() {
1750 assert!(!is_fifo_queue_url(
1752 "https://sqs.us-east-1.amazonaws.com/123/.fifo/queue"
1753 ));
1754 }
1755
1756 #[test]
1757 fn test_is_fifo_queue_url_case_sensitive() {
1758 assert!(!is_fifo_queue_url(
1759 "https://sqs.us-east-1.amazonaws.com/123/queue.FIFO"
1760 ));
1761 assert!(!is_fifo_queue_url(
1762 "https://sqs.us-east-1.amazonaws.com/123/queue.Fifo"
1763 ));
1764 }
1765
1766 #[test]
1767 fn test_is_fifo_queue_url_standard_queue_variations() {
1768 assert!(!is_fifo_queue_url(
1769 "https://sqs.us-east-1.amazonaws.com/123456789/my-queue"
1770 ));
1771 assert!(!is_fifo_queue_url(
1772 "https://sqs.eu-west-1.amazonaws.com/123456789/relayer-tx-request"
1773 ));
1774 assert!(!is_fifo_queue_url(
1775 "http://localhost:4566/000000000000/test-queue"
1776 ));
1777 }
1778
1779 #[test]
1780 fn test_is_fifo_queue_url_localstack() {
1781 assert!(is_fifo_queue_url(
1783 "http://localhost:4566/000000000000/test-queue.fifo"
1784 ));
1785 }
1786
1787 #[test]
1790 fn test_map_handler_error_preserves_abort_message() {
1791 let msg = "Validation failed: invalid nonce";
1792 let error = HandlerError::Abort(msg.to_string());
1793 match map_handler_error(error) {
1794 ProcessingError::Permanent(s) => assert_eq!(s, msg),
1795 ProcessingError::Retryable(_) => panic!("Expected Permanent"),
1796 }
1797 }
1798
1799 #[test]
1800 fn test_map_handler_error_preserves_retry_message() {
1801 let msg = "RPC timeout after 30s";
1802 let error = HandlerError::Retry(msg.to_string());
1803 match map_handler_error(error) {
1804 ProcessingError::Retryable(s) => assert_eq!(s, msg),
1805 ProcessingError::Permanent(_) => panic!("Expected Retryable"),
1806 }
1807 }
1808
1809 #[test]
1810 fn test_map_handler_error_empty_message() {
1811 let error = HandlerError::Abort(String::new());
1812 match map_handler_error(error) {
1813 ProcessingError::Permanent(s) => assert!(s.is_empty()),
1814 ProcessingError::Retryable(_) => panic!("Expected Permanent"),
1815 }
1816 }
1817
1818 #[test]
1821 fn test_handler_timeout_secs_matches_visibility_timeout_for_all_queues() {
1822 let all = [
1823 QueueType::TransactionRequest,
1824 QueueType::TransactionSubmission,
1825 QueueType::StatusCheck,
1826 QueueType::StatusCheckEvm,
1827 QueueType::StatusCheckStellar,
1828 QueueType::Notification,
1829 QueueType::TokenSwapRequest,
1830 QueueType::RelayerHealthCheck,
1831 ];
1832 for qt in all {
1833 assert_eq!(
1834 handler_timeout_secs(qt),
1835 qt.visibility_timeout_secs().max(1) as u64,
1836 "{qt:?}: handler timeout should equal max(visibility_timeout, 1)"
1837 );
1838 }
1839 }
1840
1841 #[test]
1844 fn test_get_concurrency_for_queue_all_types_positive() {
1845 let all = [
1846 QueueType::TransactionRequest,
1847 QueueType::TransactionSubmission,
1848 QueueType::StatusCheck,
1849 QueueType::StatusCheckEvm,
1850 QueueType::StatusCheckStellar,
1851 QueueType::Notification,
1852 QueueType::TokenSwapRequest,
1853 QueueType::RelayerHealthCheck,
1854 ];
1855 for qt in all {
1856 assert!(
1857 get_concurrency_for_queue(qt) > 0,
1858 "{qt:?}: concurrency must be positive (clamped to at least 1)"
1859 );
1860 }
1861 }
1862
1863 #[test]
1866 fn test_poll_error_backoff_never_exceeds_max() {
1867 for i in 0..200 {
1868 let backoff = poll_error_backoff_secs(i);
1869 assert!(
1870 backoff <= MAX_POLL_BACKOFF_SECS,
1871 "Error count {i}: backoff {backoff}s exceeds MAX {MAX_POLL_BACKOFF_SECS}s"
1872 );
1873 }
1874 }
1875
1876 #[test]
1877 fn test_poll_error_backoff_u32_max_does_not_overflow() {
1878 let backoff = poll_error_backoff_secs(u32::MAX);
1879 assert!(backoff <= MAX_POLL_BACKOFF_SECS);
1880 assert!(backoff > 0);
1881 }
1882
1883 #[test]
1884 fn test_poll_error_backoff_always_positive() {
1885 for i in 0..200 {
1886 assert!(
1887 poll_error_backoff_secs(i) > 0,
1888 "Error count {i}: backoff must be positive"
1889 );
1890 }
1891 }
1892
1893 #[test]
1894 fn test_poll_error_backoff_monotonic_before_cap() {
1895 let mut prev = poll_error_backoff_secs(0);
1897 for i in 1..=4 {
1898 let curr = poll_error_backoff_secs(i);
1899 assert!(
1900 curr >= prev,
1901 "Backoff should be non-decreasing before cap: {prev} -> {curr} at error {i}"
1902 );
1903 prev = curr;
1904 }
1905 }
1906
1907 #[test]
1910 fn test_max_poll_backoff_is_reasonable() {
1911 assert!(
1912 MAX_POLL_BACKOFF_SECS >= 10,
1913 "Max backoff should be at least 10s to avoid tight error loops"
1914 );
1915 assert!(
1916 MAX_POLL_BACKOFF_SECS <= 300,
1917 "Max backoff should be at most 5 minutes to detect recovery promptly"
1918 );
1919 }
1920
1921 #[test]
1922 fn test_recovery_probe_every_is_valid() {
1923 assert!(
1924 RECOVERY_PROBE_EVERY >= 2,
1925 "Recovery probe interval must be at least 2 to avoid probing every attempt"
1926 );
1927 assert!(
1928 RECOVERY_PROBE_EVERY <= 10,
1929 "Recovery probe interval should not be too large or recovery detection is slow"
1930 );
1931 }
1932
1933 #[test]
1936 fn test_compute_status_retry_delay_very_high_attempt() {
1937 let body = r#"{"message_id":"m1","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"tx1","relayer_id":"r1","network_type":"evm"}}"#;
1938 assert_eq!(compute_status_retry_delay(body, 1000), 12);
1940 assert_eq!(compute_status_retry_delay(body, usize::MAX), 12);
1941 }
1942
1943 #[test]
1944 fn test_compute_status_retry_delay_empty_body() {
1945 assert_eq!(compute_status_retry_delay("", 0), 5);
1947 assert_eq!(compute_status_retry_delay("{}", 0), 5);
1948 }
1949
1950 #[test]
1951 fn test_compute_status_retry_delay_partial_json() {
1952 assert_eq!(compute_status_retry_delay(r#"{"data":{}}"#, 0), 5);
1954 assert_eq!(
1955 compute_status_retry_delay(r#"{"data":{"network_type":"evm"}}"#, 0),
1956 8
1957 );
1958 }
1959
1960 #[test]
1963 fn test_partial_status_check_job_deserializes_network_type() {
1964 let body = r#"{"data":{"network_type":"evm","extra_field":"ignored"}}"#;
1965 let parsed: PartialStatusCheckJob = serde_json::from_str(body).unwrap();
1966 assert_eq!(
1967 parsed.data.network_type,
1968 Some(crate::models::NetworkType::Evm)
1969 );
1970 }
1971
1972 #[test]
1973 fn test_partial_status_check_job_handles_missing_network_type() {
1974 let body = r#"{"data":{"transaction_id":"tx1"}}"#;
1975 let parsed: PartialStatusCheckJob = serde_json::from_str(body).unwrap();
1976 assert_eq!(parsed.data.network_type, None);
1977 }
1978
1979 #[test]
1980 fn test_partial_status_check_job_rejects_missing_data() {
1981 let body = r#"{"not_data":{}}"#;
1982 let result = serde_json::from_str::<PartialStatusCheckJob>(body);
1983 assert!(result.is_err());
1984 }
1985
1986 #[test]
1989 fn test_fifo_detection_consistent_with_defer_and_retry_logic() {
1990 let standard = "https://sqs.us-east-1.amazonaws.com/123/relayer-status-check";
1995 let fifo = "https://sqs.us-east-1.amazonaws.com/123/relayer-status-check.fifo";
1996
1997 assert!(!is_fifo_queue_url(standard));
1998 assert!(is_fifo_queue_url(fifo));
1999 }
2000
2001 #[test]
2004 fn test_get_wait_time_for_queue_returns_positive() {
2005 let all = [
2006 QueueType::TransactionRequest,
2007 QueueType::TransactionSubmission,
2008 QueueType::StatusCheck,
2009 QueueType::StatusCheckEvm,
2010 QueueType::StatusCheckStellar,
2011 QueueType::Notification,
2012 QueueType::TokenSwapRequest,
2013 QueueType::RelayerHealthCheck,
2014 ];
2015 for qt in all {
2016 let wt = get_wait_time_for_queue(qt);
2017 assert!(
2018 wt <= 20,
2019 "{qt:?}: wait time {wt} exceeds SQS maximum of 20s"
2020 );
2021 }
2022 }
2023
2024 #[test]
2025 fn test_get_wait_time_for_queue_matches_defaults() {
2026 assert_eq!(
2028 get_wait_time_for_queue(QueueType::TransactionRequest),
2029 QueueType::TransactionRequest.default_wait_time_secs()
2030 );
2031 assert_eq!(
2032 get_wait_time_for_queue(QueueType::StatusCheck),
2033 QueueType::StatusCheck.default_wait_time_secs()
2034 );
2035 }
2036
2037 #[test]
2038 #[serial_test::serial]
2039 fn test_get_wait_time_for_queue_respects_env_override() {
2040 let env_var = format!(
2042 "SQS_{}_WAIT_TIME_SECONDS",
2043 QueueType::StatusCheck.sqs_env_key()
2044 );
2045 std::env::set_var(&env_var, "12");
2046 assert_eq!(get_wait_time_for_queue(QueueType::StatusCheck), 12);
2047 std::env::remove_var(&env_var);
2048 }
2049
2050 #[test]
2051 #[serial_test::serial]
2052 fn test_get_wait_time_for_queue_env_override_clamped_to_20() {
2053 let env_var = format!(
2054 "SQS_{}_WAIT_TIME_SECONDS",
2055 QueueType::Notification.sqs_env_key()
2056 );
2057 std::env::set_var(&env_var, "99");
2058 assert_eq!(
2059 get_wait_time_for_queue(QueueType::Notification),
2060 20,
2061 "Should clamp to SQS maximum of 20"
2062 );
2063 std::env::remove_var(&env_var);
2064 }
2065
2066 #[test]
2069 fn test_get_poller_count_for_queue_all_types_positive() {
2070 let all = [
2071 QueueType::TransactionRequest,
2072 QueueType::TransactionSubmission,
2073 QueueType::StatusCheck,
2074 QueueType::StatusCheckEvm,
2075 QueueType::StatusCheckStellar,
2076 QueueType::Notification,
2077 QueueType::TokenSwapRequest,
2078 QueueType::RelayerHealthCheck,
2079 ];
2080 for qt in all {
2081 assert!(
2082 get_poller_count_for_queue(qt) >= 1,
2083 "{qt:?}: poller count must be at least 1"
2084 );
2085 }
2086 }
2087
2088 #[test]
2089 fn test_get_poller_count_for_queue_matches_defaults() {
2090 assert_eq!(
2092 get_poller_count_for_queue(QueueType::TransactionRequest),
2093 QueueType::TransactionRequest.default_poller_count().max(1)
2094 );
2095 assert_eq!(
2096 get_poller_count_for_queue(QueueType::Notification),
2097 QueueType::Notification.default_poller_count().max(1)
2098 );
2099 }
2100
2101 #[test]
2102 #[serial_test::serial]
2103 fn test_get_poller_count_for_queue_respects_env_override() {
2104 let env_var = format!("SQS_{}_POLLER_COUNT", QueueType::Notification.sqs_env_key());
2105 std::env::set_var(&env_var, "5");
2106 assert_eq!(get_poller_count_for_queue(QueueType::Notification), 5);
2107 std::env::remove_var(&env_var);
2108 }
2109
2110 #[test]
2111 #[serial_test::serial]
2112 fn test_get_poller_count_for_queue_env_zero_clamped_to_1() {
2113 let env_var = format!("SQS_{}_POLLER_COUNT", QueueType::StatusCheck.sqs_env_key());
2114 std::env::set_var(&env_var, "0");
2115 assert_eq!(
2116 get_poller_count_for_queue(QueueType::StatusCheck),
2117 1,
2118 "Zero poller count from env should be clamped to 1"
2119 );
2120 std::env::remove_var(&env_var);
2121 }
2122
2123 #[test]
2126 fn test_poll_loop_config_clone() {
2127 let config = PollLoopConfig {
2128 queue_type: QueueType::TransactionRequest,
2129 polling_interval: 15,
2130 visibility_timeout: 120,
2131 handler_timeout: Duration::from_secs(120),
2132 max_retries: 3,
2133 poller_id: 0,
2134 poller_count: 2,
2135 };
2136 let cloned = config.clone();
2137 assert_eq!(cloned.polling_interval, 15);
2138 assert_eq!(cloned.poller_id, 0);
2139 assert_eq!(cloned.poller_count, 2);
2140 assert_eq!(cloned.max_retries, 3);
2141 }
2142}