openzeppelin_relayer/queues/pubsub/
worker.rs

1//! Pub/Sub worker implementation for pulling and processing messages.
2//!
3//! One pull-loop worker per subscription. Key properties:
4//! **permit-before-pull** (a pulled message's lease isn't ticking while it
5//! waits locally for a permit), a single **600s ack deadline** extended up front
6//! and the handler bounded to it (no renewal loop; the extension is retried and,
7//! if it can't be secured, the message is released rather than run under a
8//! too-short lease), **re-enqueue-to-Redis** on retry (including panics and
9//! timeouts, so bounded queues honor max_retries), **drop** on bounded
10//! exhaustion, and **never ack incomplete work**.
11
12use std::future::Future;
13use std::panic::AssertUnwindSafe;
14use std::sync::Arc;
15use std::time::Duration;
16
17use actix_web::web::ThinData;
18use deadpool_redis::Pool;
19use futures::FutureExt;
20use gcloud_pubsub::client::Client;
21use gcloud_pubsub::subscriber::ReceivedMessage;
22use serde::de::DeserializeOwned;
23use tokio::sync::{watch, Semaphore};
24use tokio::task::{JoinHandle, JoinSet};
25use tracing::{debug, error, info, warn};
26
27use crate::config::ServerConfig;
28use crate::metrics::observe_queue_pickup_latency;
29use crate::queues::{backoff_config_for_queue, retry_delay_secs};
30use crate::{
31    jobs::{
32        notification_handler, relayer_health_check_handler, token_swap_request_handler,
33        transaction_request_handler, transaction_status_handler, transaction_submission_handler,
34        Job, NotificationSend, RelayerHealthCheck, TokenSwapRequest, TransactionRequest,
35        TransactionSend, TransactionStatusCheck,
36    },
37    models::DefaultAppState,
38};
39
40use super::backend::retry_attempt_from_attrs;
41use super::schedule::{zadd_scheduled, ScheduledJob};
42use super::{HandlerError, QueueType, WorkerContext, WorkerHandle};
43
44/// Single 600s lease (Pub/Sub's max). Every handler is <= 60s, so this is a
45/// ~10x margin and needs no renewal loop (the crate doesn't auto-extend).
46const ACK_DEADLINE_SECS: i32 = 600;
47
48/// Handler is bounded to the lease: a handler exceeding this is cancelled and
49/// the message left un-acked for redelivery (a stuck handler is reprocessed,
50/// not double-run concurrently).
51const HANDLER_TIMEOUT: Duration = Duration::from_secs(600);
52
53/// Max in-flight handlers to await during graceful-shutdown drain.
54const DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
55
56/// How many times to try extending a message's ack deadline before releasing it
57/// for redelivery instead of processing under a too-short lease.
58const ACK_EXTEND_ATTEMPTS: usize = 3;
59
60/// Backoff between ack-deadline extension attempts (lets a transient gRPC blip
61/// clear; negligible against the subscription's default lease).
62const ACK_EXTEND_BACKOFF: Duration = Duration::from_millis(100);
63
64#[derive(Debug)]
65enum ProcessingError {
66    Retryable(String),
67    Permanent(String),
68}
69
70/// Bundles per-worker parameters threaded through the pull loop.
71#[derive(Clone)]
72struct WorkerConfig {
73    queue_type: QueueType,
74    max_retries: usize,
75    idle_wait: Duration,
76    key_prefix: String,
77}
78
79/// Spawns a pull-loop worker for one subscription.
80///
81/// The worker pulls messages (sized to available concurrency permits), extends
82/// each message's lease to 600s, runs the handler under a 600s timeout, then
83/// acks / re-enqueues-for-retry / leaves un-acked. Fully interruptible by
84/// `shutdown_rx`; drains in-flight handlers on shutdown.
85#[allow(clippy::too_many_arguments)]
86pub(crate) fn spawn_worker_for_subscription(
87    client: Client,
88    queue_type: QueueType,
89    subscription_name: String,
90    app_state: Arc<ThinData<DefaultAppState>>,
91    redis_pool: Arc<Pool>,
92    key_prefix: String,
93    shutdown_rx: watch::Receiver<bool>,
94    runtime_handle: tokio::runtime::Handle,
95) -> WorkerHandle {
96    let concurrency = get_concurrency_for_queue(queue_type);
97    let config = WorkerConfig {
98        queue_type,
99        max_retries: queue_type.max_retries(),
100        idle_wait: Duration::from_secs(queue_type.default_wait_time_secs()),
101        key_prefix,
102    };
103
104    info!(
105        queue_type = %queue_type,
106        subscription = %subscription_name,
107        concurrency = concurrency,
108        max_retries = config.max_retries,
109        ack_deadline_secs = ACK_DEADLINE_SECS,
110        "Spawning Pub/Sub worker"
111    );
112
113    let handle: JoinHandle<()> = runtime_handle.spawn(async move {
114        let subscription = client.subscription(&subscription_name);
115        let semaphore = Arc::new(Semaphore::new(concurrency));
116        run_pull_loop(
117            subscription,
118            semaphore,
119            app_state,
120            redis_pool,
121            config,
122            shutdown_rx,
123        )
124        .await;
125        info!(queue_type = %queue_type, "Pub/Sub worker stopped");
126    });
127
128    WorkerHandle::Tokio(handle)
129}
130
131async fn run_pull_loop(
132    subscription: gcloud_pubsub::subscription::Subscription,
133    semaphore: Arc<Semaphore>,
134    app_state: Arc<ThinData<DefaultAppState>>,
135    redis_pool: Arc<Pool>,
136    config: WorkerConfig,
137    mut shutdown_rx: watch::Receiver<bool>,
138) {
139    let queue_type = config.queue_type;
140    let mut inflight: JoinSet<()> = JoinSet::new();
141
142    loop {
143        // Reap finished handlers so the JoinSet doesn't grow unbounded.
144        while inflight.try_join_next().is_some() {}
145
146        if *shutdown_rx.borrow() {
147            break;
148        }
149
150        // Permit-before-pull: only pull as many as we can process now, so a
151        // pulled message's lease is not ticking while it waits for a permit.
152        let available = semaphore.available_permits();
153        if available == 0 {
154            tokio::select! {
155                _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
156                _ = shutdown_rx.changed() => break,
157            }
158        }
159
160        let pull_result = tokio::select! {
161            r = subscription.pull(available as i32, None) => r,
162            _ = shutdown_rx.changed() => break,
163        };
164
165        match pull_result {
166            Ok(messages) => {
167                if messages.is_empty() {
168                    // Idle: back off, but stay responsive to shutdown.
169                    tokio::select! {
170                        _ = tokio::time::sleep(config.idle_wait) => {}
171                        _ = shutdown_rx.changed() => break,
172                    }
173                    continue;
174                }
175
176                for message in messages {
177                    let permit = match semaphore.clone().acquire_owned().await {
178                        Ok(p) => p,
179                        Err(_) => {
180                            error!(queue_type = %queue_type, "Semaphore closed, stopping worker");
181                            return;
182                        }
183                    };
184                    let state = app_state.clone();
185                    let pool = redis_pool.clone();
186                    let cfg = config.clone();
187                    inflight.spawn(async move {
188                        let _permit = permit; // released even on panic
189                        process_received_message(message, &cfg, state, &pool).await;
190                    });
191                }
192            }
193            Err(status) => {
194                error!(
195                    queue_type = %queue_type,
196                    error = %status,
197                    "Pub/Sub pull failed; backing off"
198                );
199                tokio::select! {
200                    _ = tokio::time::sleep(Duration::from_secs(5)) => {}
201                    _ = shutdown_rx.changed() => break,
202                }
203            }
204        }
205    }
206
207    // Graceful drain: stop pulling, let in-flight handlers finish (bounded);
208    // leave any un-acked work for redelivery — never ack incomplete work.
209    if !inflight.is_empty() {
210        info!(
211            queue_type = %queue_type,
212            count = inflight.len(),
213            "Draining in-flight Pub/Sub handlers before shutdown"
214        );
215        let drain = async { while inflight.join_next().await.is_some() {} };
216        if tokio::time::timeout(DRAIN_TIMEOUT, drain).await.is_err() {
217            warn!(
218                queue_type = %queue_type,
219                "Drain timeout; aborting remaining handlers (left un-acked for redelivery)"
220            );
221            inflight.abort_all();
222        }
223    }
224}
225
226/// Extends the lease, runs the handler under the 600s bound, and settles the
227/// message (ack / re-enqueue-for-retry / leave un-acked).
228async fn process_received_message(
229    message: ReceivedMessage,
230    config: &WorkerConfig,
231    app_state: Arc<ThinData<DefaultAppState>>,
232    redis_pool: &Arc<Pool>,
233) {
234    let queue_type = config.queue_type;
235    let retry_attempt = retry_attempt_from_attrs(&message.message.attributes);
236    let correlation_id = job_correlation_id(&message.message.data);
237
238    // Secure the 600s lease BEFORE running the handler: a handler running past
239    // the subscription's (short) default ack deadline would be redelivered and
240    // run concurrently on another worker. If we can't extend the lease after a
241    // few tries, release the message (nack) instead of processing it under a
242    // deadline we know is too short — it is redelivered and retried with a fresh
243    // lease. This trades a rare bounce (only on persistent extend failure) for
244    // never risking a concurrent double-execution.
245    if !extend_lease(&message, queue_type, &correlation_id).await {
246        if let Err(e) = message.nack().await {
247            warn!(
248                queue_type = %queue_type,
249                correlation_id = %correlation_id,
250                error = %e,
251                "Failed to nack after ack-deadline extension failure; relying on lease expiry"
252            );
253        }
254        return;
255    }
256
257    // Observe pickup latency on the first delivery only (retries are republished
258    // with retry_attempt > 0). publish_time ~= due time, so this excludes
259    // intentional scheduling delay.
260    if retry_attempt == 0 {
261        if let Some(publish_secs) = message.message.publish_time.as_ref().map(|t| t.seconds) {
262            let now = chrono::Utc::now().timestamp();
263            observe_queue_pickup_latency(
264                queue_type.queue_name(),
265                "pubsub",
266                (now - publish_secs).max(0) as f64,
267            );
268        }
269    }
270
271    debug!(
272        queue_type = %queue_type,
273        correlation_id = %correlation_id,
274        retry_attempt = retry_attempt,
275        "Processing Pub/Sub message"
276    );
277
278    let outcome = tokio::time::timeout(
279        HANDLER_TIMEOUT,
280        AssertUnwindSafe(dispatch(
281            &message.message.data,
282            queue_type,
283            app_state,
284            retry_attempt,
285            correlation_id.clone(),
286        ))
287        .catch_unwind(),
288    )
289    .await;
290
291    match outcome {
292        Ok(Ok(Ok(()))) => {
293            debug!(queue_type = %queue_type, correlation_id = %correlation_id, "Handler succeeded; acking");
294            ack(&message, queue_type, &correlation_id).await;
295        }
296        Ok(Ok(Err(ProcessingError::Permanent(e)))) => {
297            // Terminal state already persisted by the handler; drop the message.
298            error!(
299                queue_type = %queue_type,
300                correlation_id = %correlation_id,
301                error = %e,
302                "Permanent handler failure; acking/dropping (terminal state persisted)"
303            );
304            ack(&message, queue_type, &correlation_id).await;
305        }
306        Ok(Ok(Err(ProcessingError::Retryable(e)))) => {
307            settle_retry(
308                &message,
309                config,
310                redis_pool,
311                retry_attempt,
312                &correlation_id,
313                &e,
314            )
315            .await;
316        }
317        Ok(Err(_panic)) => {
318            // Handler panicked: count it as a failed attempt and route through
319            // the bounded retry path (re-enqueue with backoff + ack the
320            // original), so a consistently-panicking handler on a bounded queue
321            // still hits max_retries instead of being redelivered forever.
322            error!(
323                queue_type = %queue_type,
324                correlation_id = %correlation_id,
325                "Handler panicked; routing through bounded retry"
326            );
327            settle_retry(
328                &message,
329                config,
330                redis_pool,
331                retry_attempt,
332                &correlation_id,
333                "handler panicked",
334            )
335            .await;
336        }
337        Err(_elapsed) => {
338            // >600s: cancelled. Count it as a failed attempt and route through
339            // the bounded retry path so a chronically-slow handler on a bounded
340            // queue hits max_retries instead of a flat 600s redelivery loop.
341            error!(
342                queue_type = %queue_type,
343                correlation_id = %correlation_id,
344                timeout_secs = HANDLER_TIMEOUT.as_secs(),
345                "Handler exceeded the 600s lease; routing through bounded retry"
346            );
347            settle_retry(
348                &message,
349                config,
350                redis_pool,
351                retry_attempt,
352                &correlation_id,
353                "handler exceeded lease",
354            )
355            .await;
356        }
357    }
358}
359
360/// Re-enqueues a retryable failure to the Redis scheduled set (scored
361/// `now + backoff`) with an incremented attempt, then acks the original — or, if
362/// the budget is exhausted on a bounded queue, drops it (terminal state is the
363/// durable record). On re-enqueue failure the original is left un-acked.
364///
365/// At-least-once: the retry copy is written to Redis *before* the original
366/// Pub/Sub message is acked, and the two stores are not updated atomically. A
367/// crash or a failed ack in between can leave both the scheduled retry and the
368/// original eligible for delivery, so a job may run more than once. This is the
369/// queue subsystem's intended trade-off (favor no-loss over no-duplicates),
370/// matching the Redis/Apalis and SQS backends; handlers must be idempotent.
371/// Transport-level Pub/Sub message IDs are deliberately not used as a dedup
372/// boundary — the meaningful idempotency boundary is the job/transaction layer
373/// (e.g. nonce management), shared across all backends.
374async fn settle_retry(
375    message: &ReceivedMessage,
376    config: &WorkerConfig,
377    redis_pool: &Arc<Pool>,
378    retry_attempt: usize,
379    correlation_id: &str,
380    err: &str,
381) {
382    let queue_type = config.queue_type;
383    let next_attempt = retry_attempt.saturating_add(1);
384
385    // Bounded queues stop at max_retries; status checks are unbounded (usize::MAX).
386    if is_retry_exhausted(config.max_retries, retry_attempt) {
387        error!(
388            queue_type = %queue_type,
389            correlation_id = %correlation_id,
390            retry_attempt = retry_attempt,
391            max_retries = config.max_retries,
392            error = %err,
393            "Retry budget exhausted; dropping (terminal state persisted, no dead-letter)"
394        );
395        ack(message, queue_type, correlation_id).await;
396        return;
397    }
398
399    let delay = if queue_type.is_status_check() {
400        compute_status_retry_delay(&message.message.data, retry_attempt)
401    } else {
402        retry_delay_secs(backoff_config_for_queue(queue_type), retry_attempt)
403    };
404
405    let body = match String::from_utf8(message.message.data.clone()) {
406        Ok(b) => b,
407        Err(e) => {
408            // Body isn't UTF-8 JSON — unrecoverable; drop (can't re-enqueue).
409            error!(
410                queue_type = %queue_type,
411                correlation_id = %correlation_id,
412                error = %e,
413                "Message body is not valid UTF-8; dropping"
414            );
415            ack(message, queue_type, correlation_id).await;
416            return;
417        }
418    };
419
420    let scheduled = ScheduledJob {
421        body,
422        retry_attempt: next_attempt,
423    };
424    let run_at = chrono::Utc::now().timestamp() + delay as i64;
425
426    match zadd_scheduled(
427        redis_pool,
428        &config.key_prefix,
429        queue_type,
430        &scheduled,
431        run_at,
432    )
433    .await
434    {
435        Ok(()) => {
436            debug!(
437                queue_type = %queue_type,
438                correlation_id = %correlation_id,
439                retry_attempt = next_attempt,
440                delay_secs = delay,
441                error = %err,
442                "Re-enqueued for retry; acking original"
443            );
444            ack(message, queue_type, correlation_id).await;
445        }
446        Err(e) => {
447            // Leave the original un-acked → Pub/Sub redelivers (no loss).
448            error!(
449                queue_type = %queue_type,
450                correlation_id = %correlation_id,
451                error = %e,
452                "Failed to re-enqueue retry; leaving message un-acked for redelivery"
453            );
454        }
455    }
456}
457
458/// Extends a message's lease to the full 600s bound, retrying a few times with a
459/// short backoff. Returns `false` if every attempt fails — the caller then
460/// releases the message rather than processing it under a too-short lease.
461async fn extend_lease(
462    message: &ReceivedMessage,
463    queue_type: QueueType,
464    correlation_id: &str,
465) -> bool {
466    for attempt in 1..=ACK_EXTEND_ATTEMPTS {
467        match message.modify_ack_deadline(ACK_DEADLINE_SECS).await {
468            Ok(()) => return true,
469            Err(e) => {
470                warn!(
471                    queue_type = %queue_type,
472                    correlation_id = %correlation_id,
473                    attempt,
474                    max_attempts = ACK_EXTEND_ATTEMPTS,
475                    error = %e,
476                    "Failed to extend Pub/Sub ack deadline"
477                );
478                if attempt < ACK_EXTEND_ATTEMPTS {
479                    tokio::time::sleep(ACK_EXTEND_BACKOFF).await;
480                }
481            }
482        }
483    }
484    error!(
485        queue_type = %queue_type,
486        correlation_id = %correlation_id,
487        "Could not extend ack deadline after retries; releasing message for redelivery"
488    );
489    false
490}
491
492/// Acks a message (best-effort). On failure the message is redelivered and
493/// reprocessed; handlers are idempotent.
494async fn ack(message: &ReceivedMessage, queue_type: QueueType, correlation_id: &str) {
495    if let Err(e) = message.ack().await {
496        warn!(
497            queue_type = %queue_type,
498            correlation_id = %correlation_id,
499            error = %e,
500            "Failed to ack Pub/Sub message; will be redelivered (idempotent)"
501        );
502    }
503}
504
505/// Routes a message body to the appropriate handler based on queue type.
506async fn dispatch(
507    body: &[u8],
508    queue_type: QueueType,
509    app_state: Arc<ThinData<DefaultAppState>>,
510    attempt: usize,
511    task_id: String,
512) -> Result<(), ProcessingError> {
513    match queue_type {
514        QueueType::TransactionRequest => {
515            process_job::<TransactionRequest, _, _>(
516                body,
517                app_state,
518                attempt,
519                task_id,
520                "TransactionRequest",
521                transaction_request_handler,
522            )
523            .await
524        }
525        QueueType::TransactionSubmission => {
526            process_job::<TransactionSend, _, _>(
527                body,
528                app_state,
529                attempt,
530                task_id,
531                "TransactionSend",
532                transaction_submission_handler,
533            )
534            .await
535        }
536        QueueType::StatusCheck | QueueType::StatusCheckEvm | QueueType::StatusCheckStellar => {
537            process_job::<TransactionStatusCheck, _, _>(
538                body,
539                app_state,
540                attempt,
541                task_id,
542                "TransactionStatusCheck",
543                transaction_status_handler,
544            )
545            .await
546        }
547        QueueType::Notification => {
548            process_job::<NotificationSend, _, _>(
549                body,
550                app_state,
551                attempt,
552                task_id,
553                "NotificationSend",
554                notification_handler,
555            )
556            .await
557        }
558        QueueType::TokenSwapRequest => {
559            process_job::<TokenSwapRequest, _, _>(
560                body,
561                app_state,
562                attempt,
563                task_id,
564                "TokenSwapRequest",
565                token_swap_request_handler,
566            )
567            .await
568        }
569        QueueType::RelayerHealthCheck => {
570            process_job::<RelayerHealthCheck, _, _>(
571                body,
572                app_state,
573                attempt,
574                task_id,
575                "RelayerHealthCheck",
576                relayer_health_check_handler,
577            )
578            .await
579        }
580    }
581}
582
583/// Generic job processor — deserializes `Job<T>`, builds a `WorkerContext`, and
584/// delegates to the handler. A malformed payload is a permanent failure.
585async fn process_job<T, F, Fut>(
586    body: &[u8],
587    app_state: Arc<ThinData<DefaultAppState>>,
588    attempt: usize,
589    task_id: String,
590    type_name: &str,
591    handler: F,
592) -> Result<(), ProcessingError>
593where
594    T: DeserializeOwned,
595    F: FnOnce(Job<T>, ThinData<DefaultAppState>, WorkerContext) -> Fut,
596    Fut: Future<Output = Result<(), HandlerError>>,
597{
598    let job: Job<T> = serde_json::from_slice(body).map_err(|e| {
599        error!(error = %e, "Failed to deserialize {} job", type_name);
600        ProcessingError::Permanent(format!("Failed to deserialize {type_name} job: {e}"))
601    })?;
602
603    let ctx = WorkerContext::new(attempt, task_id);
604    handler(job, (*app_state).clone(), ctx)
605        .await
606        .map_err(map_handler_error)
607}
608
609fn map_handler_error(error: HandlerError) -> ProcessingError {
610    match error {
611        HandlerError::Abort(msg) => ProcessingError::Permanent(msg),
612        HandlerError::Retry(msg) => ProcessingError::Retryable(msg),
613    }
614}
615
616/// Whether a retryable failure has exhausted a bounded queue's retry budget.
617///
618/// Status-check queues are unbounded (`max_retries == usize::MAX`) and are never
619/// exhausted — they re-run until the transaction finalizes. A bounded
620/// queue is exhausted once the *next* attempt would exceed `max_retries`.
621fn is_retry_exhausted(max_retries: usize, retry_attempt: usize) -> bool {
622    max_retries != usize::MAX && retry_attempt.saturating_add(1) > max_retries
623}
624
625/// Partial view of a status-check job body to extract only `network_type`.
626#[derive(serde::Deserialize)]
627struct StatusCheckData {
628    network_type: Option<crate::models::NetworkType>,
629}
630
631#[derive(serde::Deserialize)]
632struct PartialStatusCheckJob {
633    data: StatusCheckData,
634}
635
636/// Network-aware retry delay for status checks (EVM 8→12s, Stellar 2→3s,
637/// Solana/default 5→8s), aligned with Redis/Apalis and the SQS backend.
638fn compute_status_retry_delay(body: &[u8], attempt: usize) -> i32 {
639    let network_type = serde_json::from_slice::<PartialStatusCheckJob>(body)
640        .ok()
641        .and_then(|j| j.data.network_type);
642    crate::queues::status_check_retry_delay_secs(network_type, attempt)
643}
644
645/// Partial view of any job body to extract the correlation id for log lines —
646/// never logs the body itself.
647#[derive(serde::Deserialize)]
648struct JobMeta {
649    message_id: String,
650}
651
652/// Extracts the job's stable correlation id (its `message_id`) for logging.
653fn job_correlation_id(body: &[u8]) -> String {
654    serde_json::from_slice::<JobMeta>(body)
655        .map(|m| m.message_id)
656        .unwrap_or_else(|_| "unknown".to_string())
657}
658
659/// Gets the concurrency limit for a queue type from env or default (reuses the
660/// existing `WORKER_*_CONCURRENCY` controls).
661fn get_concurrency_for_queue(queue_type: QueueType) -> usize {
662    let configured = ServerConfig::get_worker_concurrency(
663        queue_type.concurrency_env_key(),
664        queue_type.default_concurrency(),
665    );
666    if configured == 0 {
667        warn!(queue_type = %queue_type, "Configured concurrency is 0; clamping to 1");
668        1
669    } else {
670        configured
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn test_map_handler_error() {
680        assert!(matches!(
681            map_handler_error(HandlerError::Abort("x".into())),
682            ProcessingError::Permanent(_)
683        ));
684        assert!(matches!(
685            map_handler_error(HandlerError::Retry("x".into())),
686            ProcessingError::Retryable(_)
687        ));
688    }
689
690    #[test]
691    fn test_compute_status_retry_delay_by_network() {
692        let evm = br#"{"message_id":"m","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"t","relayer_id":"r","network_type":"evm"}}"#;
693        assert_eq!(compute_status_retry_delay(evm, 0), 8);
694        assert_eq!(compute_status_retry_delay(evm, 1), 12);
695
696        let stellar = br#"{"message_id":"m","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"t","relayer_id":"r","network_type":"stellar"}}"#;
697        assert_eq!(compute_status_retry_delay(stellar, 0), 2);
698
699        // Missing network → generic (Solana/default) profile.
700        let none = br#"{"message_id":"m","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"t","relayer_id":"r"}}"#;
701        assert_eq!(compute_status_retry_delay(none, 0), 5);
702
703        // Invalid body → generic fallback.
704        assert_eq!(compute_status_retry_delay(b"not json", 0), 5);
705    }
706
707    #[test]
708    fn test_job_correlation_id_extraction() {
709        let body = br#"{"message_id":"job-123","version":"1","timestamp":"0","job_type":"NotificationSend","data":{}}"#;
710        assert_eq!(job_correlation_id(body), "job-123");
711        assert_eq!(job_correlation_id(b"garbage"), "unknown");
712    }
713
714    #[test]
715    fn test_get_concurrency_for_queue_positive() {
716        assert!(get_concurrency_for_queue(QueueType::TransactionRequest) > 0);
717        assert!(get_concurrency_for_queue(QueueType::StatusCheck) > 0);
718    }
719
720    #[test]
721    fn test_lease_and_timeout_constants() {
722        // 600s lease == handler bound (SQS capped model at a far looser cap).
723        assert_eq!(ACK_DEADLINE_SECS, 600);
724        assert_eq!(HANDLER_TIMEOUT, Duration::from_secs(600));
725        // The lease must be secured with at least one retry before we fall back
726        // to releasing the message, and the backoff must stay well under any
727        // sane subscription default ack deadline.
728        assert!(ACK_EXTEND_ATTEMPTS >= 1);
729        assert!(ACK_EXTEND_BACKOFF < Duration::from_secs(1));
730    }
731
732    // ── bounded exhaustion vs unbounded status checks ───────
733
734    #[test]
735    fn test_is_retry_exhausted_bounded_queue() {
736        // A queue with max_retries = 5 exhausts once the next attempt (> 5).
737        // retry_attempt is the attempt that just failed; next = retry_attempt+1.
738        assert!(!is_retry_exhausted(5, 0)); // next=1 <= 5
739        assert!(!is_retry_exhausted(5, 4)); // next=5 <= 5
740        assert!(is_retry_exhausted(5, 5)); // next=6 > 5 → exhausted (drop)
741        assert!(is_retry_exhausted(5, 100));
742    }
743
744    #[test]
745    fn test_is_retry_exhausted_status_checks_never_exhaust() {
746        // Status-check queues are unbounded and must never be force-dropped.
747        assert!(!is_retry_exhausted(usize::MAX, 0));
748        assert!(!is_retry_exhausted(usize::MAX, 1_000_000));
749        assert!(!is_retry_exhausted(usize::MAX, usize::MAX - 1));
750    }
751
752    // ── retry backoff is observably increasing then capped ──
753
754    #[test]
755    fn test_status_retry_backoff_is_monotonic_and_capped() {
756        // EVM: 8s → 12s cap. Non-decreasing and never above the cap.
757        let evm = br#"{"message_id":"m","version":"1","timestamp":"0","job_type":"TransactionStatusCheck","data":{"transaction_id":"t","relayer_id":"r","network_type":"evm"}}"#;
758        let mut prev = 0;
759        for attempt in 0..10 {
760            let d = compute_status_retry_delay(evm, attempt);
761            assert!(d >= prev, "status backoff must be non-decreasing");
762            assert!(d <= 12, "status backoff must stay <= cap");
763            prev = d;
764        }
765        // It actually increases at least once before capping.
766        assert!(compute_status_retry_delay(evm, 1) > compute_status_retry_delay(evm, 0));
767    }
768
769    #[test]
770    fn test_bounded_queue_backoff_is_monotonic_and_capped() {
771        use crate::queues::{backoff_config_for_queue, retry_delay_secs};
772        let cfg = backoff_config_for_queue(QueueType::TransactionRequest);
773        let cap = (cfg.max_ms.div_ceil(1000)) as i32;
774        let mut prev = 0;
775        for attempt in 0..12 {
776            let d = retry_delay_secs(cfg, attempt);
777            assert!(d >= prev, "backoff must be non-decreasing");
778            assert!(d <= cap, "backoff must stay <= cap");
779            prev = d;
780        }
781    }
782}
783
784// ── Gated emulator integration tests ─────────────────────────────
785//
786// Require the Pub/Sub emulator (PUBSUB_EMULATOR_HOST) and, for the flow test, a
787// running Redis on localhost:6379. Run with:
788//   PUBSUB_EMULATOR_HOST=localhost:8085 \
789//   cargo test --lib queues::pubsub::worker::emulator_tests -- --ignored
790// The full transaction -> final-state end-to-end is validated by the
791// quickstart example and the pre-release regression gate.
792#[cfg(test)]
793mod emulator_tests {
794    use std::sync::Arc;
795    use std::time::Duration;
796
797    use deadpool_redis::Pool;
798    use gcloud_pubsub::client::{Client, ClientConfig};
799    use gcloud_pubsub::subscription::SubscriptionConfig;
800
801    use super::ACK_DEADLINE_SECS;
802    use crate::queues::pubsub::backend::{message_from_body, retry_attempt_from_attrs};
803    use crate::queues::pubsub::schedule::{claim_due, zadd_scheduled, ScheduledJob};
804    use crate::queues::QueueType;
805
806    async fn emulator_client() -> Client {
807        assert!(
808            std::env::var("PUBSUB_EMULATOR_HOST").is_ok(),
809            "set PUBSUB_EMULATOR_HOST to run the emulator tests"
810        );
811        let config = ClientConfig {
812            project_id: Some("test-project".to_string()),
813            ..ClientConfig::default()
814        };
815        Client::new(config).await.expect("emulator client")
816    }
817
818    fn redis_pool() -> Arc<Pool> {
819        Arc::new(
820            deadpool_redis::Config::from_url("redis://127.0.0.1:6379")
821                .builder()
822                .expect("pool builder")
823                .max_size(8)
824                .runtime(deadpool_redis::Runtime::Tokio1)
825                .build()
826                .expect("pool build"),
827        )
828    }
829
830    /// A message whose lease is extended to 600s is NOT redelivered
831    /// after the subscription's (short) default ack deadline elapses — proving
832    /// a slow (<= 60s) handler is never redelivered mid-run.
833    #[tokio::test]
834    #[ignore]
835    async fn integration_lease_held_past_default_deadline() {
836        let client = emulator_client().await;
837        let suffix = uuid::Uuid::new_v4().simple().to_string();
838        let topic_id = format!("lease-topic-{suffix}");
839        let sub_id = format!("lease-sub-{suffix}");
840
841        let topic = client
842            .create_topic(&topic_id, None, None)
843            .await
844            .expect("create topic");
845        // 10s is the crate's minimum default ack deadline; the worker extends to 600s.
846        let cfg = SubscriptionConfig {
847            ack_deadline_seconds: 10,
848            ..Default::default()
849        };
850        let subscription = client
851            .create_subscription(&sub_id, &topic_id, cfg, None)
852            .await
853            .expect("create subscription");
854
855        let publisher = topic.new_publisher(None);
856        publisher
857            .publish(message_from_body(b"{\"message_id\":\"lease\"}".to_vec(), 0))
858            .await
859            .get()
860            .await
861            .expect("publish");
862
863        let pulled = subscription.pull(1, None).await.expect("pull");
864        assert_eq!(pulled.len(), 1, "should receive the published message");
865        pulled[0]
866            .modify_ack_deadline(ACK_DEADLINE_SECS)
867            .await
868            .expect("extend lease to 600s");
869
870        // Past the 10s default; the extended lease must still hold the message.
871        tokio::time::sleep(Duration::from_secs(13)).await;
872        let again = subscription.pull(1, None).await.expect("second pull");
873        assert!(
874            again.is_empty(),
875            "extended lease must prevent mid-run redelivery"
876        );
877
878        pulled[0].ack().await.expect("ack");
879    }
880
881    /// The publish -> pull -> (retry re-enqueue) -> due-sweep -> re-pull
882    /// spine, and that the topic only ever carries already-due jobs (a future
883    /// scheduled job is never published until due). Requires emulator + Redis.
884    #[tokio::test]
885    #[ignore]
886    async fn integration_flow_topic_only_carries_due_jobs() {
887        let client = emulator_client().await;
888        let pool = redis_pool();
889        let suffix = uuid::Uuid::new_v4().simple().to_string();
890        let prefix = format!("test-pubsub-{suffix}");
891        let queue = QueueType::StatusCheckEvm;
892        let topic_id = format!("flow-topic-{suffix}");
893        let sub_id = format!("flow-sub-{suffix}");
894
895        let topic = client
896            .create_topic(&topic_id, None, None)
897            .await
898            .expect("create topic");
899        let subscription = client
900            .create_subscription(&sub_id, &topic_id, SubscriptionConfig::default(), None)
901            .await
902            .expect("create subscription");
903        let publisher = topic.new_publisher(None);
904
905        let now = chrono::Utc::now().timestamp();
906
907        // A far-future job sits in Redis and is never claimed/published.
908        let future = ScheduledJob {
909            body: r#"{"message_id":"future"}"#.to_string(),
910            retry_attempt: 0,
911        };
912        zadd_scheduled(&pool, &prefix, queue, &future, now + 3600)
913            .await
914            .expect("zadd future");
915        let claimed = claim_due(&pool, &prefix, queue, now, 256)
916            .await
917            .expect("claim");
918        assert!(claimed.is_empty(), "future job must not be due yet");
919
920        // A due retry (retry_attempt = 2) is claimed and published; the pulled
921        // message carries the incremented logical attempt.
922        let due = ScheduledJob {
923            body: r#"{"message_id":"due"}"#.to_string(),
924            retry_attempt: 2,
925        };
926        zadd_scheduled(&pool, &prefix, queue, &due, now - 1)
927            .await
928            .expect("zadd due");
929        for job in claim_due(&pool, &prefix, queue, now, 256)
930            .await
931            .expect("claim due")
932        {
933            publisher
934                .publish(message_from_body(job.body.into_bytes(), job.retry_attempt))
935                .await
936                .get()
937                .await
938                .expect("publish due");
939        }
940
941        let pulled = subscription.pull(10, None).await.expect("pull");
942        assert_eq!(pulled.len(), 1, "only the due job should be on the topic");
943        assert_eq!(
944            retry_attempt_from_attrs(&pulled[0].message.attributes),
945            2,
946            "logical retry_attempt must survive the schedule round-trip"
947        );
948        pulled[0].ack().await.expect("ack");
949
950        // Cleanup the Redis key.
951        let mut conn = pool.get().await.unwrap();
952        let key = crate::queues::pubsub::schedule::scheduled_set_key(&prefix, queue);
953        let _: () = redis::cmd("DEL")
954            .arg(&key)
955            .query_async(&mut conn)
956            .await
957            .unwrap();
958    }
959
960    /// Shutdown no-loss: a message that is pulled but
961    /// never acked (the worker's shutdown path: drain/abort without acking) is
962    /// redelivered once its lease lapses — nothing is lost or acked-incomplete.
963    #[tokio::test]
964    #[ignore]
965    async fn integration_unacked_work_is_redelivered_after_lease() {
966        let client = emulator_client().await;
967        let suffix = uuid::Uuid::new_v4().simple().to_string();
968        let topic_id = format!("noloss-topic-{suffix}");
969        let sub_id = format!("noloss-sub-{suffix}");
970
971        let topic = client
972            .create_topic(&topic_id, None, None)
973            .await
974            .expect("create topic");
975        // Short lease so the test is fast; the worker would extend to 600s, but
976        // here we simulate a shutdown that drops the message WITHOUT acking.
977        let cfg = SubscriptionConfig {
978            ack_deadline_seconds: 10,
979            ..Default::default()
980        };
981        let subscription = client
982            .create_subscription(&sub_id, &topic_id, cfg, None)
983            .await
984            .expect("create subscription");
985
986        topic
987            .new_publisher(None)
988            .publish(message_from_body(
989                b"{\"message_id\":\"noloss\"}".to_vec(),
990                0,
991            ))
992            .await
993            .get()
994            .await
995            .expect("publish");
996
997        {
998            let pulled = subscription.pull(1, None).await.expect("pull");
999            assert_eq!(pulled.len(), 1);
1000            // Simulate shutdown: drop the message without acking (never ack
1001            // incomplete work). No ack/nack is sent.
1002        }
1003
1004        // After the lease lapses the message becomes available again.
1005        tokio::time::sleep(Duration::from_secs(13)).await;
1006        let redelivered = subscription.pull(1, None).await.expect("second pull");
1007        assert_eq!(
1008            redelivered.len(),
1009            1,
1010            "un-acked work must be redelivered, never lost"
1011        );
1012        redelivered[0].ack().await.expect("ack");
1013    }
1014}