1use 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
44const ACK_DEADLINE_SECS: i32 = 600;
47
48const HANDLER_TIMEOUT: Duration = Duration::from_secs(600);
52
53const DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
55
56const ACK_EXTEND_ATTEMPTS: usize = 3;
59
60const ACK_EXTEND_BACKOFF: Duration = Duration::from_millis(100);
63
64#[derive(Debug)]
65enum ProcessingError {
66 Retryable(String),
67 Permanent(String),
68}
69
70#[derive(Clone)]
72struct WorkerConfig {
73 queue_type: QueueType,
74 max_retries: usize,
75 idle_wait: Duration,
76 key_prefix: String,
77}
78
79#[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 while inflight.try_join_next().is_some() {}
145
146 if *shutdown_rx.borrow() {
147 break;
148 }
149
150 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 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; 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 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
226async 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 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 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 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 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 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
360async 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 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 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 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
458async 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
492async 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
505async 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
583async 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
616fn 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#[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
636fn 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#[derive(serde::Deserialize)]
648struct JobMeta {
649 message_id: String,
650}
651
652fn 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
659fn 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 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 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 assert_eq!(ACK_DEADLINE_SECS, 600);
724 assert_eq!(HANDLER_TIMEOUT, Duration::from_secs(600));
725 assert!(ACK_EXTEND_ATTEMPTS >= 1);
729 assert!(ACK_EXTEND_BACKOFF < Duration::from_secs(1));
730 }
731
732 #[test]
735 fn test_is_retry_exhausted_bounded_queue() {
736 assert!(!is_retry_exhausted(5, 0)); assert!(!is_retry_exhausted(5, 4)); assert!(is_retry_exhausted(5, 5)); assert!(is_retry_exhausted(5, 100));
742 }
743
744 #[test]
745 fn test_is_retry_exhausted_status_checks_never_exhaust() {
746 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 #[test]
755 fn test_status_retry_backoff_is_monotonic_and_capped() {
756 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 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#[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 #[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 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 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 #[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 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 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 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 #[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 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 }
1003
1004 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}