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::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/// Outcome of processing a single SQS message, used to decide whether the
45/// message should be batch-deleted or left in the queue.
46#[derive(Debug)]
47enum MessageOutcome {
48    /// Message processed successfully — should be deleted from queue.
49    Delete { receipt_handle: String },
50    /// Message should remain in queue (e.g. status-check retry via visibility
51    /// change, or retryable error awaiting visibility timeout).
52    Retain,
53}
54
55/// Configuration for a single SQS poll loop, bundling parameters that
56/// would otherwise require too many function arguments.
57#[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
68/// Spawns a worker task for a specific SQS queue.
69///
70/// The worker continuously polls the queue, processes messages, and handles
71/// retries via SQS visibility timeout.
72///
73/// # Arguments
74/// * `sqs_client` - AWS SQS client for all operations (poll, send, delete, change visibility)
75/// * `queue_type` - Type of queue (determines handler and concurrency)
76/// * `queue_url` - SQS queue URL
77/// * `app_state` - Application state with repositories and services
78///
79/// # Returns
80/// JoinHandle to the spawned worker task
81pub 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    // All pollers share the same semaphore so total concurrency is bounded.
110    let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
111
112    // Re-home the poll loop onto the pipeline runtime; the inner JoinSet pollers
113    // inherit this runtime, so all SQS polling distributes across worker threads.
114    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        // Wait for all pollers to finish (they exit on shutdown signal)
139        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
154/// Runs a single SQS poll loop. Multiple instances may share the same semaphore
155/// to increase pickup smoothness without exceeding handler concurrency limits.
156async 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        // Reap completed tasks and collect receipt handles for batch delete
179        while let Some(result) = inflight.try_join_next() {
180            match result {
181                Ok(Some(receipt_handle)) => pending_deletes.push(receipt_handle),
182                Ok(None) => {} // Retained message, no delete needed
183                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        // Flush any accumulated deletes as a batch
195        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        // Check shutdown before each iteration
201        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        // Distribute available permits fairly across pollers to prevent
207        // collective overfetch. Each poller gets floor(available / N)
208        // messages, and the first (available % N) pollers (by poller_id)
209        // each get one extra from the remainder. This ensures:
210        // - No stall: at least one poller polls when any permits exist
211        // - Bounded overfetch: at most poller_count extra from racing
212        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        // SQS MaxNumberOfMessages must be 1-10.
227        let batch_size = my_share.min(10) as i32;
228
229        // Poll SQS for messages, racing with shutdown signal
230        let messages_result = tokio::select! {
231            result = sqs_client
232                .receive_message()
233                .queue_url(&queue_url)
234                .max_number_of_messages(batch_size) // SQS max is 10
235                .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                        // Process messages concurrently (up to semaphore limit)
271                        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; // always dropped, even on panic
290
291                                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    // Drain in-flight tasks before shutdown, collecting final deletes
377    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    // Flush any remaining deletes accumulated during drain
418    if !pending_deletes.is_empty() {
419        flush_delete_batch(&sqs_client, &queue_url, &pending_deletes, queue_type).await;
420    }
421}
422
423/// Processes a single SQS message.
424///
425/// Routes the message to the appropriate handler based on queue type,
426/// handles success/failure, and manages message deletion/retry.
427async 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    // Observe queue pickup latency on the FIRST physical delivery, before the
444    // defer block consumes it. Placement here is deliberate:
445    //   - Standard queues hold scheduled messages invisible via DelaySeconds
446    //     and deliver at ~target_scheduled_on, so latency reflects actual
447    //     sub-second pickup delay.
448    //   - FIFO queues deliver scheduled messages immediately (no native
449    //     DelaySeconds) and the consumer then defers via visibility timeout.
450    //     The negative `now - target_scheduled_on` clamps to 0, which honestly
451    //     says "consumer is keeping up with the schedule".
452    // Either way: receive_count==1 in `queue_pickup_baseline_ms` ensures we
453    // observe exactly once per logical message lifecycle. FIFO defer/retry
454    // re-deliveries (which bump receive_count) are skipped; standard-queue
455    // retries are skipped via the `retry_attempt` attribute.
456    if let Some(baseline) = queue_pickup_baseline_ms(&message) {
457        let now_ms = chrono::Utc::now().timestamp_millis();
458        // SentTimestamp is set by the AWS broker; if the consumer clock runs
459        // ahead of the broker by more than this threshold for a non-scheduled
460        // message, the latency is almost certainly clock skew, not a real
461        // backlog. Log so operators can detect bad data rather than alert on it.
462        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    // For jobs with scheduling beyond SQS 15-minute max delay, keep deferring in hops.
480    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    // Get retry attempt count from message attributes
513    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    // SQS receive count starts at 1; Apalis Attempt starts at 0.
519    let attempt_number = receive_count.saturating_sub(1);
520    // Attempt count handed to the handler for its max_attempts decision.
521    // Prefers the persisted `retry_attempt` attribute (set on standard-queue
522    // re-enqueues, where receive_count resets to 1 every retry) and falls back
523    // to receive_count-1 for FIFO (same physical message redelivered).
524    // Using receive_count alone would peg standard-queue retries at attempt 0
525    // forever, so max_attempts would never trigger and the job would loop.
526    let logical_retry_attempt =
527        effective_handler_attempt(receive_count, parse_retry_attempt(&message));
528
529    // Use SQS MessageId as the worker task_id for log correlation.
530    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    // Route to appropriate handler
542    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            // Check max retries for non-infinite queues (status checks use usize::MAX)
637            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            // Compute retry delay based on queue type:
650            // - Status checks use network-type-aware backoff from the message body
651            // - All other queues use their configured backoff profile from retry_config
652            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            // FIFO queues do not support per-message DelaySeconds. Use visibility
659            // timeout on the in-flight message to schedule the retry.
660            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            // Standard queues: re-enqueue with native DelaySeconds,
691            // no group_id or dedup_id needed. Duplicate deliveries are
692            // harmless because handlers are idempotent.
693            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                // Fall through — original message will retry after visibility timeout
720                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            // Delete the original message now that the re-enqueue succeeded
732            Ok(MessageOutcome::Delete {
733                receipt_handle: receipt_handle.to_string(),
734            })
735        }
736    }
737}
738
739/// Generic job processor — deserializes `Job<T>`, creates a `WorkerContext`,
740/// and delegates to the provided handler function.
741async 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        // Malformed payload is not recoverable by retrying the same message body.
757        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
789/// The attempt number handed to a job handler for its `max_attempts` decision.
790///
791/// Standard-queue retries re-enqueue a brand-new message, so the SQS
792/// `ApproximateReceiveCount` resets to 1 on every retry; the true retry count
793/// is carried instead in the persisted `retry_attempt` message attribute.
794/// FIFO retries reuse the same physical message via `change_message_visibility`,
795/// so `receive_count` climbs and no `retry_attempt` attribute is set.
796///
797/// Preferring the persisted `retry_attempt` (falling back to `receive_count - 1`)
798/// yields a monotonically increasing attempt count on BOTH queue types, so the
799/// handler's `max_attempts` ceiling is actually enforced and a permanently
800/// failing job cannot loop forever on a standard queue.
801fn effective_handler_attempt(receive_count: usize, retry_attempt: Option<usize>) -> usize {
802    retry_attempt.unwrap_or_else(|| receive_count.saturating_sub(1))
803}
804
805/// Compute pickup latency in seconds, clamping negative deltas to 0 so a
806/// consumer clock running ahead of the AWS broker (or a future-dated
807/// `target_scheduled_on`) cannot produce a negative-then-huge cast value.
808fn pickup_latency_secs(baseline_ms: i64, now_ms: i64) -> f64 {
809    (now_ms - baseline_ms).max(0) as f64 / 1000.0
810}
811
812/// Sanity threshold (ms) for non-scheduled latency observations. Above this,
813/// the consumer clock is almost certainly skewed relative to the AWS broker
814/// rather than the queue genuinely being backed up by an hour+. Used only to
815/// emit a warning — the value is still observed in the histogram.
816const PICKUP_LATENCY_CLOCK_SKEW_THRESHOLD_MS: i64 = 60 * 60 * 1000;
817
818fn queue_pickup_baseline_ms(message: &Message) -> Option<i64> {
819    // Observe pickup latency only on the very first physical delivery of
820    // a message. The gate is intentionally narrow because the relayer
821    // supports both standard and FIFO SQS queues — and FIFO defer-hops and
822    // error retries both reuse the same physical message via
823    // `change_message_visibility`, which cannot mutate the `retry_attempt`
824    // attribute. Without the receive-count gate, every FIFO redelivery
825    // would re-observe the latency, conflating the metric with retry
826    // backoff time.
827    //
828    // The trade-offs:
829    //   - Standard queues: behaves correctly. Initial delivery has
830    //     receive_count=1; standard-queue retries re-send a new message
831    //     (also receive_count=1) but carry `retry_attempt > 0`, so the
832    //     second check below skips them.
833    //   - Standard queues with defer-hop (only triggered when
834    //     scheduled_on - now > 900s): each defer-hop creates a new message
835    //     with receive_count=1 and no `retry_attempt`, so the metric
836    //     observes each hop. This is an accepted limitation; long-delay
837    //     scheduling is rare in this codebase (status checks use seconds-
838    //     scale backoff).
839    //   - FIFO queues: only the very first delivery observes. For
840    //     scheduled jobs that arrive before `target_scheduled_on`, the
841    //     computed latency is clamped to 0 by the caller. This is narrower
842    //     than the standard-queue semantic but consistent and free of
843    //     retry inflation.
844    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    // Standard-queue retries re-enqueue as new messages with receive_count=1
854    // and an explicit `retry_attempt` attribute. Skip those so the metric
855    // doesn't include retry backoff time.
856    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    // Standard queues support native per-message DelaySeconds — no need for
913    // group_id or dedup_id. Just re-send with the delay and scheduling attribute.
914    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/// Partial struct for deserializing only the `network_type` field from a status check job.
949///
950/// Used to avoid deserializing the entire `Job<TransactionStatusCheck>` when we only
951/// need the network type to determine retry delay.
952#[derive(serde::Deserialize)]
953struct StatusCheckData {
954    network_type: Option<crate::models::NetworkType>,
955}
956
957/// Partial struct matching `Job<TransactionStatusCheck>` structure.
958///
959/// Used for efficient partial deserialization to extract only the `network_type`
960/// field without parsing the entire job payload.
961#[derive(serde::Deserialize)]
962struct PartialStatusCheckJob {
963    data: StatusCheckData,
964}
965
966/// Extracts `network_type` from a status check payload and computes retry delay.
967///
968/// This uses hardcoded network-specific backoff windows aligned with Redis/Apalis:
969/// - EVM: 8s -> 12s cap
970/// - Stellar: 2s -> 3s cap
971/// - Solana/default: 5s -> 8s cap
972fn 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
980/// Gets the SQS long-poll wait time for a queue type from environment or default.
981fn 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
988/// Gets the number of poll loops to run for a queue type from environment or default.
989fn 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
1005/// Gets the concurrency limit for a queue type from environment.
1006fn 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
1022/// Maximum allowed wall-clock processing time per message before the handler task is canceled.
1023///
1024/// Keep this bounded so permits cannot be held forever by hung handlers.
1025fn handler_timeout_secs(queue_type: QueueType) -> u64 {
1026    u64::from(queue_type.visibility_timeout_secs().max(1))
1027}
1028
1029/// Maximum backoff duration for poll errors (1 minute).
1030const MAX_POLL_BACKOFF_SECS: u64 = 60;
1031
1032/// Number of consecutive errors between recovery probes at the backoff ceiling.
1033/// Once the backoff reaches `MAX_POLL_BACKOFF_SECS`, every Nth error cycle uses
1034/// the base interval (5s) to quickly detect when the SQS endpoint recovers.
1035const RECOVERY_PROBE_EVERY: u32 = 4;
1036
1037/// Computes exponential backoff for consecutive poll errors with recovery probes.
1038///
1039/// Returns: 5, 10, 20, 40, 60, 60, 60, **5** (probe), 60, 60, 60, **5**, ...
1040fn poll_error_backoff_secs(consecutive_errors: u32) -> u64 {
1041    let base: u64 = 5;
1042
1043    // Once well past the ceiling, periodically try the base interval
1044    // to quickly detect when the SQS endpoint recovers.
1045    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
1054/// Deletes messages from SQS in batches of up to 10 (the SQS maximum per call).
1055///
1056/// Returns the total number of successfully deleted messages. Any per-entry
1057/// failures are logged as warnings — SQS will redeliver those messages after
1058/// the visibility timeout expires.
1059async 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        // Test that concurrency is retrieved (exact value depends on env)
1126        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        // Test parsing target_scheduled_on from message attributes
1165        let message = Message::builder().build();
1166
1167        // Message without attribute should return None
1168        assert_eq!(parse_target_scheduled_on(&message), None);
1169
1170        // Message with valid attribute
1171        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        // Test Abort maps to Permanent
1206        let error = HandlerError::Abort("Validation failed".to_string());
1207        let result = map_handler_error(error);
1208        assert!(matches!(result, ProcessingError::Permanent(_)));
1209
1210        // Test Retry maps to Retryable
1211        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        // NetworkType uses #[serde(rename_all = "lowercase")]
1229        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; // dropped on scope exit, even after panic
1273            let _ = AssertUnwindSafe(async { panic!("test panic") })
1274                .catch_unwind()
1275                .await;
1276        });
1277
1278        handle.await.unwrap();
1279        // Would hang forever if permit leaked
1280        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        // First error: 5s
1288        assert_eq!(poll_error_backoff_secs(1), 5);
1289        // Second: 10s
1290        assert_eq!(poll_error_backoff_secs(2), 10);
1291        // Third: 20s
1292        assert_eq!(poll_error_backoff_secs(3), 20);
1293        // Fourth: 40s
1294        assert_eq!(poll_error_backoff_secs(4), 40);
1295        // Capped at MAX_POLL_BACKOFF_SECS (60)
1296        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        // Recovery probe: base interval at multiples of RECOVERY_PROBE_EVERY (>= 7)
1300        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); // next probe
1303    }
1304
1305    #[test]
1306    fn test_poll_error_backoff_zero_errors() {
1307        // Zero consecutive errors should still produce a reasonable value
1308        assert_eq!(poll_error_backoff_secs(0), 5);
1309    }
1310
1311    #[test]
1312    fn test_poll_error_backoff_recovery_probes() {
1313        // Verify probes repeat at regular intervals once past threshold
1314        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        // Verify DeleteMessageBatchRequestEntry builds correctly with sequential IDs,
1346        // matching the pattern used in flush_delete_batch.
1347        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        // Verify that chunks(10) correctly splits receipt handles,
1374        // matching the pattern used in flush_delete_batch.
1375        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        // Verify the pattern used in the main loop to collect receipt handles
1387        // from a mix of Delete and Retain outcomes.
1388        let outcomes = vec![
1389            Some("receipt-1".to_string()), // Delete
1390            None,                          // Retain
1391            Some("receipt-2".to_string()), // Delete
1392            None,                          // Retain
1393            Some("receipt-3".to_string()), // Delete
1394        ];
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    // ── parse_target_scheduled_on: edge cases ─────────────────────────
1405
1406    #[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        // Negative values parse fine as i64
1449        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        // Floats can't parse as i64
1465        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        // Attribute exists but under a different key
1486        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    // ── parse_retry_attempt: edge cases ───────────────────────────────
1500
1501    #[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        // Negative values can't parse as usize
1529        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        // No retry_attempt attribute → first attempt
1580        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        // Consumer clock running 5s behind the broker (baseline is "in the future")
1598        // must not produce a negative-then-huge cast value — clamp to 0.
1599        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        // 2.5s positive delta should be reported in seconds with ms precision.
1607        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        // retry_attempt > 0 → genuine retry, skip
1636        assert_eq!(queue_pickup_baseline_ms(&message), None);
1637    }
1638
1639    #[test]
1640    fn test_queue_pickup_baseline_ms_accepts_retry_attempt_zero() {
1641        // Explicit retry_attempt=0 should be treated as first attempt
1642        // (consistent with absent attribute).
1643        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        // Standard-queue retry: re-enqueued as a NEW message (receive_count=1)
1668        // but carrying retry_attempt=3. The handler must see attempt 3, not 0,
1669        // otherwise max_attempts is never reached and the job loops forever.
1670        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        // FIFO retry: same physical message redelivered, receive_count climbs,
1676        // and there is no retry_attempt attribute to read.
1677        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        // Receive count > 1 means the message has been delivered before,
1689        // which on FIFO queues happens for both scheduling defer-hops and
1690        // error retries — neither of which we want to record as a fresh
1691        // pickup. Gating on receive_count == 1 prevents this conflation.
1692        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        // Explicit receive_count=1 (first physical delivery) should be
1716        // observed, mirroring the implicit default when the attribute is
1717        // missing.
1718        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    // ── is_fifo_queue_url: comprehensive cases ────────────────────────
1737
1738    #[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        // .fifo appearing in the path but not as suffix
1751        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        // LocalStack FIFO queue URL format
1782        assert!(is_fifo_queue_url(
1783            "http://localhost:4566/000000000000/test-queue.fifo"
1784        ));
1785    }
1786
1787    // ── map_handler_error: message preservation ───────────────────────
1788
1789    #[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    // ── handler_timeout_secs: all queue types ─────────────────────────
1819
1820    #[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    // ── get_concurrency_for_queue: all queue types ────────────────────
1842
1843    #[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    // ── poll_error_backoff_secs: overflow and invariants ───────────────
1864
1865    #[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        // Before hitting the cap, backoff should be non-decreasing
1896        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    // ── Constants validation ──────────────────────────────────────────
1908
1909    #[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    // ── compute_status_retry_delay: edge cases ────────────────────────
1934
1935    #[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        // Very high attempts should stay capped at the max (12s for EVM)
1939        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        // Empty JSON body should fall back to generic/Solana defaults
1946        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        // JSON with missing inner structure
1953        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    // ── PartialStatusCheckJob deserialization ──────────────────────────
1961
1962    #[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    // ── is_fifo_queue_url used consistently ───────────────────────────
1987
1988    #[test]
1989    fn test_fifo_detection_consistent_with_defer_and_retry_logic() {
1990        // Both defer_message and the retry path in process_message use
1991        // is_fifo_queue_url to decide between visibility-timeout vs re-enqueue.
1992        // Verify our standard and FIFO URLs are classified identically by both
1993        // call sites (they both call the same function).
1994        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    // ── get_wait_time_for_queue ──────────────────────────────────────────
2002
2003    #[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        // Without env overrides the helper should return the queue's default
2027        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        // StatusCheck default is 5; override to 12 via the real env var path
2041        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    // ── get_poller_count_for_queue ───────────────────────────────────────
2067
2068    #[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        // Without env overrides the helper should return the queue's default (clamped to >= 1)
2091        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    // ── PollLoopConfig ──────────────────────────────────────────────────
2124
2125    #[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}