openzeppelin_relayer/queues/sqs/
worker.rs

1//! SQS worker implementation for polling and processing messages.
2//!
3//! This module provides worker tasks that poll SQS queues and process jobs
4//! using the existing handler functions.
5
6use 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/// Outcome of processing a single SQS message, used to decide whether the
44/// message should be batch-deleted or left in the queue.
45#[derive(Debug)]
46enum MessageOutcome {
47    /// Message processed successfully — should be deleted from queue.
48    Delete { receipt_handle: String },
49    /// Message should remain in queue (e.g. status-check retry via visibility
50    /// change, or retryable error awaiting visibility timeout).
51    Retain,
52}
53
54/// Configuration for a single SQS poll loop, bundling parameters that
55/// would otherwise require too many function arguments.
56#[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
67/// Spawns a worker task for a specific SQS queue.
68///
69/// The worker continuously polls the queue, processes messages, and handles
70/// retries via SQS visibility timeout.
71///
72/// # Arguments
73/// * `sqs_client` - AWS SQS client for all operations (poll, send, delete, change visibility)
74/// * `queue_type` - Type of queue (determines handler and concurrency)
75/// * `queue_url` - SQS queue URL
76/// * `app_state` - Application state with repositories and services
77///
78/// # Returns
79/// JoinHandle to the spawned worker task
80pub 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    // All pollers share the same semaphore so total concurrency is bounded.
108    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        // Wait for all pollers to finish (they exit on shutdown signal)
135        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
150/// Runs a single SQS poll loop. Multiple instances may share the same semaphore
151/// to increase pickup smoothness without exceeding handler concurrency limits.
152async 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        // Reap completed tasks and collect receipt handles for batch delete
175        while let Some(result) = inflight.try_join_next() {
176            match result {
177                Ok(Some(receipt_handle)) => pending_deletes.push(receipt_handle),
178                Ok(None) => {} // Retained message, no delete needed
179                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        // Flush any accumulated deletes as a batch
191        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        // Check shutdown before each iteration
197        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        // Distribute available permits fairly across pollers to prevent
203        // collective overfetch. Each poller gets floor(available / N)
204        // messages, and the first (available % N) pollers (by poller_id)
205        // each get one extra from the remainder. This ensures:
206        // - No stall: at least one poller polls when any permits exist
207        // - Bounded overfetch: at most poller_count extra from racing
208        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        // SQS MaxNumberOfMessages must be 1-10.
223        let batch_size = my_share.min(10) as i32;
224
225        // Poll SQS for messages, racing with shutdown signal
226        let messages_result = tokio::select! {
227            result = sqs_client
228                .receive_message()
229                .queue_url(&queue_url)
230                .max_number_of_messages(batch_size) // SQS max is 10
231                .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                        // Process messages concurrently (up to semaphore limit)
266                        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; // always dropped, even on panic
285
286                                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    // Drain in-flight tasks before shutdown, collecting final deletes
372    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    // Flush any remaining deletes accumulated during drain
413    if !pending_deletes.is_empty() {
414        flush_delete_batch(&sqs_client, &queue_url, &pending_deletes, queue_type).await;
415    }
416}
417
418/// Processes a single SQS message.
419///
420/// Routes the message to the appropriate handler based on queue type,
421/// handles success/failure, and manages message deletion/retry.
422async 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    // For jobs with scheduling beyond SQS 15-minute max delay, keep deferring in hops.
439    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    // Get retry attempt count from message attributes
472    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    // SQS receive count starts at 1; Apalis Attempt starts at 0.
478    let attempt_number = receive_count.saturating_sub(1);
479    // Persisted retry attempt for self-reenqueued status checks. Falls back to receive_count-based
480    // attempt when attribute is missing.
481    let logical_retry_attempt = parse_retry_attempt(&message).unwrap_or(attempt_number);
482
483    // Use SQS MessageId as the worker task_id for log correlation.
484    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    // Route to appropriate handler
496    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            // Check max retries for non-infinite queues (status checks use usize::MAX)
591            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            // Compute retry delay based on queue type:
604            // - Status checks use network-type-aware backoff from the message body
605            // - All other queues use their configured backoff profile from retry_config
606            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            // FIFO queues do not support per-message DelaySeconds. Use visibility
613            // timeout on the in-flight message to schedule the retry.
614            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            // Standard queues: re-enqueue with native DelaySeconds,
645            // no group_id or dedup_id needed. Duplicate deliveries are
646            // harmless because handlers are idempotent.
647            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                // Fall through — original message will retry after visibility timeout
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                "Message re-enqueued with backoff delay"
683            );
684
685            // Delete the original message now that the re-enqueue succeeded
686            Ok(MessageOutcome::Delete {
687                receipt_handle: receipt_handle.to_string(),
688            })
689        }
690    }
691}
692
693/// Generic job processor — deserializes `Job<T>`, creates a `WorkerContext`,
694/// and delegates to the provided handler function.
695async 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        // Malformed payload is not recoverable by retrying the same message body.
711        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    // Standard queues support native per-message DelaySeconds — no need for
786    // group_id or dedup_id. Just re-send with the delay and scheduling attribute.
787    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/// Partial struct for deserializing only the `network_type` field from a status check job.
822///
823/// Used to avoid deserializing the entire `Job<TransactionStatusCheck>` when we only
824/// need the network type to determine retry delay.
825#[derive(serde::Deserialize)]
826struct StatusCheckData {
827    network_type: Option<crate::models::NetworkType>,
828}
829
830/// Partial struct matching `Job<TransactionStatusCheck>` structure.
831///
832/// Used for efficient partial deserialization to extract only the `network_type`
833/// field without parsing the entire job payload.
834#[derive(serde::Deserialize)]
835struct PartialStatusCheckJob {
836    data: StatusCheckData,
837}
838
839/// Extracts `network_type` from a status check payload and computes retry delay.
840///
841/// This uses hardcoded network-specific backoff windows aligned with Redis/Apalis:
842/// - EVM: 8s -> 12s cap
843/// - Stellar: 2s -> 3s cap
844/// - Solana/default: 5s -> 8s cap
845fn 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
853/// Gets the SQS long-poll wait time for a queue type from environment or default.
854fn 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
861/// Gets the number of poll loops to run for a queue type from environment or default.
862fn 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
878/// Gets the concurrency limit for a queue type from environment.
879fn 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
895/// Maximum allowed wall-clock processing time per message before the handler task is canceled.
896///
897/// Keep this bounded so permits cannot be held forever by hung handlers.
898fn handler_timeout_secs(queue_type: QueueType) -> u64 {
899    u64::from(queue_type.visibility_timeout_secs().max(1))
900}
901
902/// Maximum backoff duration for poll errors (1 minute).
903const MAX_POLL_BACKOFF_SECS: u64 = 60;
904
905/// Number of consecutive errors between recovery probes at the backoff ceiling.
906/// Once the backoff reaches `MAX_POLL_BACKOFF_SECS`, every Nth error cycle uses
907/// the base interval (5s) to quickly detect when the SQS endpoint recovers.
908const RECOVERY_PROBE_EVERY: u32 = 4;
909
910/// Computes exponential backoff for consecutive poll errors with recovery probes.
911///
912/// Returns: 5, 10, 20, 40, 60, 60, 60, **5** (probe), 60, 60, 60, **5**, ...
913fn poll_error_backoff_secs(consecutive_errors: u32) -> u64 {
914    let base: u64 = 5;
915
916    // Once well past the ceiling, periodically try the base interval
917    // to quickly detect when the SQS endpoint recovers.
918    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
927/// Deletes messages from SQS in batches of up to 10 (the SQS maximum per call).
928///
929/// Returns the total number of successfully deleted messages. Any per-entry
930/// failures are logged as warnings — SQS will redeliver those messages after
931/// the visibility timeout expires.
932async 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        // Test that concurrency is retrieved (exact value depends on env)
999        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        // Test parsing target_scheduled_on from message attributes
1038        let message = Message::builder().build();
1039
1040        // Message without attribute should return None
1041        assert_eq!(parse_target_scheduled_on(&message), None);
1042
1043        // Message with valid attribute
1044        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        // Test Abort maps to Permanent
1079        let error = HandlerError::Abort("Validation failed".to_string());
1080        let result = map_handler_error(error);
1081        assert!(matches!(result, ProcessingError::Permanent(_)));
1082
1083        // Test Retry maps to Retryable
1084        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        // NetworkType uses #[serde(rename_all = "lowercase")]
1102        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; // dropped on scope exit, even after panic
1146            let _ = AssertUnwindSafe(async { panic!("test panic") })
1147                .catch_unwind()
1148                .await;
1149        });
1150
1151        handle.await.unwrap();
1152        // Would hang forever if permit leaked
1153        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        // First error: 5s
1161        assert_eq!(poll_error_backoff_secs(1), 5);
1162        // Second: 10s
1163        assert_eq!(poll_error_backoff_secs(2), 10);
1164        // Third: 20s
1165        assert_eq!(poll_error_backoff_secs(3), 20);
1166        // Fourth: 40s
1167        assert_eq!(poll_error_backoff_secs(4), 40);
1168        // Capped at MAX_POLL_BACKOFF_SECS (60)
1169        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        // Recovery probe: base interval at multiples of RECOVERY_PROBE_EVERY (>= 7)
1173        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); // next probe
1176    }
1177
1178    #[test]
1179    fn test_poll_error_backoff_zero_errors() {
1180        // Zero consecutive errors should still produce a reasonable value
1181        assert_eq!(poll_error_backoff_secs(0), 5);
1182    }
1183
1184    #[test]
1185    fn test_poll_error_backoff_recovery_probes() {
1186        // Verify probes repeat at regular intervals once past threshold
1187        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        // Verify DeleteMessageBatchRequestEntry builds correctly with sequential IDs,
1219        // matching the pattern used in flush_delete_batch.
1220        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        // Verify that chunks(10) correctly splits receipt handles,
1247        // matching the pattern used in flush_delete_batch.
1248        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        // Verify the pattern used in the main loop to collect receipt handles
1260        // from a mix of Delete and Retain outcomes.
1261        let outcomes = vec![
1262            Some("receipt-1".to_string()), // Delete
1263            None,                          // Retain
1264            Some("receipt-2".to_string()), // Delete
1265            None,                          // Retain
1266            Some("receipt-3".to_string()), // Delete
1267        ];
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    // ── parse_target_scheduled_on: edge cases ─────────────────────────
1278
1279    #[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        // Negative values parse fine as i64
1322        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        // Floats can't parse as i64
1338        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        // Attribute exists but under a different key
1359        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    // ── parse_retry_attempt: edge cases ───────────────────────────────
1373
1374    #[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        // Negative values can't parse as usize
1402        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    // ── is_fifo_queue_url: comprehensive cases ────────────────────────
1436
1437    #[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        // .fifo appearing in the path but not as suffix
1450        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        // LocalStack FIFO queue URL format
1481        assert!(is_fifo_queue_url(
1482            "http://localhost:4566/000000000000/test-queue.fifo"
1483        ));
1484    }
1485
1486    // ── map_handler_error: message preservation ───────────────────────
1487
1488    #[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    // ── handler_timeout_secs: all queue types ─────────────────────────
1518
1519    #[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    // ── get_concurrency_for_queue: all queue types ────────────────────
1541
1542    #[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    // ── poll_error_backoff_secs: overflow and invariants ───────────────
1563
1564    #[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        // Before hitting the cap, backoff should be non-decreasing
1595        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    // ── Constants validation ──────────────────────────────────────────
1607
1608    #[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    // ── compute_status_retry_delay: edge cases ────────────────────────
1633
1634    #[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        // Very high attempts should stay capped at the max (12s for EVM)
1638        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        // Empty JSON body should fall back to generic/Solana defaults
1645        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        // JSON with missing inner structure
1652        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    // ── PartialStatusCheckJob deserialization ──────────────────────────
1660
1661    #[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    // ── is_fifo_queue_url used consistently ───────────────────────────
1686
1687    #[test]
1688    fn test_fifo_detection_consistent_with_defer_and_retry_logic() {
1689        // Both defer_message and the retry path in process_message use
1690        // is_fifo_queue_url to decide between visibility-timeout vs re-enqueue.
1691        // Verify our standard and FIFO URLs are classified identically by both
1692        // call sites (they both call the same function).
1693        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    // ── get_wait_time_for_queue ──────────────────────────────────────────
1701
1702    #[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        // Without env overrides the helper should return the queue's default
1726        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        // StatusCheck default is 5; override to 12 via the real env var path
1740        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    // ── get_poller_count_for_queue ───────────────────────────────────────
1766
1767    #[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        // Without env overrides the helper should return the queue's default (clamped to >= 1)
1790        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    // ── PollLoopConfig ──────────────────────────────────────────────────
1823
1824    #[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}