1use actix_web::web::ThinData;
15use chrono::{DateTime, Utc};
16use eyre::Result;
17use std::sync::Arc;
18use std::time::Duration;
19use tracing::{debug, error, info, instrument, warn};
20
21use crate::{
22 config::ServerConfig,
23 constants::{
24 FINAL_TRANSACTION_STATUSES, TRANSACTION_CLEANUP_LOCK_TTL_SECS,
25 WORKER_TRANSACTION_CLEANUP_RETRIES,
26 },
27 jobs::handle_result,
28 models::{
29 DefaultAppState, NetworkTransactionData, PaginationQuery, RelayerRepoModel,
30 TransactionRepoModel, TransactionStatus,
31 },
32 queues::{HandlerError, WorkerContext},
33 repositories::{Repository, TransactionDeleteRequest, TransactionRepository},
34 utils::DistributedLock,
35};
36
37const MAX_CONCURRENT_RELAYERS: usize = 10;
39
40const CLEANUP_PAGE_SIZE: u32 = 100;
42
43const DELETE_BATCH_SIZE: usize = 100;
46
47const MAX_CLEANUP_ITERATIONS_PER_STATUS: u32 = 1500;
51
52const CLEANUP_LOCK_NAME: &str = "transaction_cleanup";
55
56#[instrument(
73 level = "debug",
74 skip(job, data),
75 fields(
76 job_type = "transaction_cleanup",
77 attempt = %ctx.attempt,
78 ),
79 err
80)]
81pub async fn transaction_cleanup_handler(
82 job: TransactionCleanupCronReminder,
83 data: ThinData<DefaultAppState>,
84 ctx: WorkerContext,
85) -> Result<(), HandlerError> {
86 let result = handle_request(job, &data).await;
87
88 handle_result(
89 result,
90 &ctx,
91 "TransactionCleanup",
92 WORKER_TRANSACTION_CLEANUP_RETRIES,
93 )
94}
95
96#[derive(Default, Debug, Clone)]
98pub struct TransactionCleanupCronReminder();
99
100async fn handle_request(
113 _job: TransactionCleanupCronReminder,
114 data: &ThinData<DefaultAppState>,
115) -> Result<()> {
116 let transaction_repo = data.transaction_repository();
117
118 let lock_guard = if ServerConfig::get_distributed_mode() {
121 if let Some((connections, prefix)) = transaction_repo.connection_info() {
122 let conn = connections.primary().clone();
123 let lock_key = format!("{prefix}:lock:{CLEANUP_LOCK_NAME}");
124 let lock = DistributedLock::new(
125 conn,
126 &lock_key,
127 Duration::from_secs(TRANSACTION_CLEANUP_LOCK_TTL_SECS),
128 );
129
130 match lock.try_acquire().await {
131 Ok(Some(guard)) => {
132 debug!(lock_key = %lock_key, "acquired distributed lock for transaction cleanup");
133 Some(guard)
134 }
135 Ok(None) => {
136 info!(lock_key = %lock_key, "transaction cleanup skipped - another instance is processing");
137 return Ok(());
138 }
139 Err(e) => {
140 warn!(
143 error = %e,
144 lock_key = %lock_key,
145 "failed to acquire distributed lock, skipping cleanup"
146 );
147 return Ok(());
148 }
149 }
150 } else {
151 debug!("in-memory repository detected, skipping distributed lock");
152 None
153 }
154 } else {
155 debug!("distributed mode disabled, skipping lock acquisition");
156 None
157 };
158
159 let now = Utc::now();
160 info!(
161 timestamp = %now.to_rfc3339(),
162 "executing transaction cleanup from storage"
163 );
164
165 let relayer_repo = data.relayer_repository();
166
167 let relayers = relayer_repo.list_all().await.map_err(|e| {
169 error!(
170 error = %e,
171 "failed to fetch relayers for cleanup"
172 );
173 eyre::eyre!("Failed to fetch relayers: {}", e)
174 })?;
175
176 info!(
177 relayer_count = relayers.len(),
178 "found relayers to process for cleanup"
179 );
180
181 let cleanup_results = process_relayers_in_batches(relayers, transaction_repo, now).await;
183
184 let result = report_cleanup_results(cleanup_results).await;
186
187 drop(lock_guard);
190
191 result
192}
193
194async fn process_relayers_in_batches(
204 relayers: Vec<RelayerRepoModel>,
205 transaction_repo: Arc<impl TransactionRepository + Sync>,
206 now: DateTime<Utc>,
207) -> Vec<RelayerCleanupResult> {
208 use futures::stream::{self, StreamExt};
209
210 let results: Vec<RelayerCleanupResult> = stream::iter(relayers)
212 .map(|relayer| {
213 let repo_clone = Arc::clone(&transaction_repo);
214 async move { process_single_relayer(relayer, repo_clone, now).await }
215 })
216 .buffer_unordered(MAX_CONCURRENT_RELAYERS)
217 .collect()
218 .await;
219
220 results
221}
222
223#[derive(Debug)]
225struct RelayerCleanupResult {
226 relayer_id: String,
227 cleaned_count: usize,
228 error: Option<String>,
229}
230
231async fn process_single_relayer(
244 relayer: RelayerRepoModel,
245 transaction_repo: Arc<impl TransactionRepository + Sync>,
246 now: DateTime<Utc>,
247) -> RelayerCleanupResult {
248 debug!(
249 relayer_id = %relayer.id,
250 "processing cleanup for relayer"
251 );
252
253 let mut total_cleaned = 0usize;
254
255 let reconcile_error = match transaction_repo
260 .reconcile_stale_status_indexes(&relayer.id)
261 .await
262 {
263 Ok(repaired_count) => {
264 if repaired_count > 0 {
265 info!(
266 repaired_count,
267 relayer_id = %relayer.id,
268 "repaired stale transaction status indexes"
269 );
270 }
271 None
272 }
273 Err(e) => {
274 error!(
275 error = %e,
276 relayer_id = %relayer.id,
277 "failed to reconcile stale transaction status indexes"
278 );
279 Some(e.to_string())
280 }
281 };
282
283 for status in FINAL_TRANSACTION_STATUSES {
284 match process_status_cleanup(&relayer.id, status, &transaction_repo, now).await {
285 Ok(cleaned) => total_cleaned += cleaned,
286 Err(e) => {
287 error!(
288 error = %e,
289 relayer_id = %relayer.id,
290 status = ?status,
291 "failed to cleanup transactions for status"
292 );
293 return RelayerCleanupResult {
296 relayer_id: relayer.id,
297 cleaned_count: total_cleaned,
298 error: Some(match &reconcile_error {
299 Some(reconcile_error) => {
300 format!("{reconcile_error}; status cleanup failed: {e}")
301 }
302 None => e.to_string(),
303 }),
304 };
305 }
306 }
307 }
308
309 if total_cleaned > 0 {
310 info!(
311 cleaned_count = total_cleaned,
312 relayer_id = %relayer.id,
313 "cleaned up expired transactions"
314 );
315 }
316
317 RelayerCleanupResult {
318 relayer_id: relayer.id,
319 cleaned_count: total_cleaned,
320 error: reconcile_error,
321 }
322}
323
324async fn process_status_cleanup(
339 relayer_id: &str,
340 status: &TransactionStatus,
341 transaction_repo: &Arc<impl TransactionRepository + Sync>,
342 now: DateTime<Utc>,
343) -> Result<usize> {
344 let mut current_page = 1u32;
345 let mut total_cleaned = 0usize;
346 let mut iterations = 0u32;
347
348 loop {
349 if iterations >= MAX_CLEANUP_ITERATIONS_PER_STATUS {
350 warn!(
351 relayer_id = %relayer_id,
352 status = ?status,
353 iterations,
354 total_cleaned,
355 "reached max cleanup iterations, stopping"
356 );
357 break;
358 }
359 iterations += 1;
360
361 let query = PaginationQuery {
362 page: current_page,
363 per_page: CLEANUP_PAGE_SIZE,
364 };
365
366 let page_result = transaction_repo
367 .find_by_status_paginated(relayer_id, std::slice::from_ref(status), query, true)
368 .await
369 .map_err(|e| {
370 eyre::eyre!(
371 "Failed to fetch {:?} transactions for relayer {}: {}",
372 status,
373 relayer_id,
374 e
375 )
376 })?;
377
378 if page_result.items.is_empty() {
379 break;
380 }
381
382 debug!(
383 page = current_page,
384 page_count = page_result.items.len(),
385 total = page_result.total,
386 relayer_id = %relayer_id,
387 status = ?status,
388 "processing page of transactions for cleanup"
389 );
390
391 let cleaned_count =
392 process_transactions_for_cleanup(page_result.items, transaction_repo, relayer_id, now)
393 .await;
394
395 total_cleaned += cleaned_count;
396
397 if cleaned_count == 0 {
398 current_page += 1;
401 }
402 }
406
407 if total_cleaned > 0 {
408 debug!(
409 total_cleaned,
410 relayer_id = %relayer_id,
411 status = ?status,
412 "status cleanup completed"
413 );
414 }
415
416 Ok(total_cleaned)
417}
418
419#[cfg(test)]
422async fn fetch_final_transactions_paginated(
423 relayer_id: &str,
424 transaction_repo: &Arc<impl TransactionRepository + Sync>,
425 query: PaginationQuery,
426) -> Result<crate::repositories::PaginatedResult<TransactionRepoModel>> {
427 transaction_repo
428 .find_by_status_paginated(relayer_id, FINAL_TRANSACTION_STATUSES, query, true)
429 .await
430 .map_err(|e| {
431 eyre::eyre!(
432 "Failed to fetch final transactions for relayer {}: {}",
433 relayer_id,
434 e
435 )
436 })
437}
438
439async fn process_transactions_for_cleanup(
454 transactions: Vec<TransactionRepoModel>,
455 transaction_repo: &Arc<impl TransactionRepository>,
456 relayer_id: &str,
457 now: DateTime<Utc>,
458) -> usize {
459 if transactions.is_empty() {
460 return 0;
461 }
462
463 debug!(
464 transaction_count = transactions.len(),
465 relayer_id = %relayer_id,
466 "processing transactions for cleanup"
467 );
468
469 let delete_requests: Vec<TransactionDeleteRequest> = transactions
472 .into_iter()
473 .filter(|tx| {
474 if !FINAL_TRANSACTION_STATUSES.contains(&tx.status) {
476 debug!(
477 tx_id = %tx.id,
478 status = ?tx.status,
479 "skipping transaction not in final state"
480 );
481 return false;
482 }
483 should_delete_transaction(tx, now)
485 })
486 .map(|tx| {
487 let nonce = extract_nonce_from_network_data(&tx.network_data);
489 TransactionDeleteRequest::new(tx.id, tx.relayer_id, nonce)
490 })
491 .collect();
492
493 if delete_requests.is_empty() {
494 debug!(
495 relayer_id = %relayer_id,
496 "no expired transactions found"
497 );
498 return 0;
499 }
500
501 let total_expired = delete_requests.len();
502 debug!(
503 expired_count = total_expired,
504 relayer_id = %relayer_id,
505 "found expired transactions to delete"
506 );
507
508 let mut total_deleted = 0;
510 let mut total_failed = 0;
511
512 for (batch_idx, batch) in delete_requests.chunks(DELETE_BATCH_SIZE).enumerate() {
513 let batch_requests: Vec<TransactionDeleteRequest> = batch.to_vec();
514 let batch_size = batch_requests.len();
515
516 debug!(
517 batch = batch_idx + 1,
518 batch_size = batch_size,
519 relayer_id = %relayer_id,
520 "processing delete batch"
521 );
522
523 match transaction_repo.delete_by_requests(batch_requests).await {
524 Ok(result) => {
525 if !result.failed.is_empty() {
526 for (id, error) in &result.failed {
527 error!(
528 tx_id = %id,
529 error = %error,
530 relayer_id = %relayer_id,
531 "failed to delete expired transaction in batch"
532 );
533 }
534 }
535
536 total_deleted += result.deleted_count;
537 total_failed += result.failed.len();
538 }
539 Err(e) => {
540 error!(
541 error = %e,
542 relayer_id = %relayer_id,
543 batch = batch_idx + 1,
544 batch_size = batch_size,
545 "batch delete failed completely"
546 );
547 total_failed += batch_size;
548 }
549 }
550 }
551
552 debug!(
553 total_deleted,
554 total_failed,
555 total_expired,
556 relayer_id = %relayer_id,
557 "batch delete completed"
558 );
559
560 total_deleted
561}
562
563fn extract_nonce_from_network_data(network_data: &NetworkTransactionData) -> Option<u64> {
566 match network_data {
567 NetworkTransactionData::Evm(evm_data) => evm_data.nonce,
568 _ => None,
569 }
570}
571
572fn should_delete_transaction(transaction: &TransactionRepoModel, now: DateTime<Utc>) -> bool {
581 transaction
582 .delete_at
583 .as_ref()
584 .and_then(|delete_at_str| DateTime::parse_from_rfc3339(delete_at_str).ok())
585 .map(|delete_at| {
586 let is_expired = now >= delete_at.with_timezone(&Utc);
587 if is_expired {
588 debug!(
589 tx_id = %transaction.id,
590 expired_at = %delete_at.to_rfc3339(),
591 "transaction is expired"
592 );
593 }
594 is_expired
595 })
596 .unwrap_or_else(|| {
597 if transaction.delete_at.is_some() {
598 warn!(
599 tx_id = %transaction.id,
600 "transaction has invalid delete_at timestamp"
601 );
602 }
603 false
604 })
605}
606
607async fn report_cleanup_results(cleanup_results: Vec<RelayerCleanupResult>) -> Result<()> {
615 let total_cleaned: usize = cleanup_results.iter().map(|r| r.cleaned_count).sum();
616 let total_errors = cleanup_results.iter().filter(|r| r.error.is_some()).count();
617 let total_relayers = cleanup_results.len();
618
619 for result in &cleanup_results {
621 if let Some(error) = &result.error {
622 error!(
623 relayer_id = %result.relayer_id,
624 error = %error,
625 "failed to cleanup transactions for relayer"
626 );
627 }
628 }
629
630 if total_errors > 0 {
631 warn!(
632 total_errors,
633 total_relayers, total_cleaned, "transaction cleanup completed with errors"
634 );
635
636 Err(eyre::eyre!(
639 "Cleanup completed with {} errors out of {} relayers",
640 total_errors,
641 total_relayers
642 ))
643 } else {
644 info!(
645 total_cleaned,
646 total_relayers, "transaction cleanup completed successfully"
647 );
648 Ok(())
649 }
650}
651
652#[cfg(test)]
653mod tests {
654
655 use super::*;
656 use chrono::{Duration, Utc};
657
658 use crate::{
659 models::{
660 NetworkType, RelayerEvmPolicy, RelayerNetworkPolicy, RelayerRepoModel, RepositoryError,
661 TransactionRepoModel, TransactionStatus,
662 },
663 repositories::{
664 InMemoryTransactionRepository, MockTransactionRepository, PaginatedResult, Repository,
665 },
666 utils::mocks::mockutils::create_mock_transaction,
667 };
668
669 fn create_test_transaction(
670 id: &str,
671 relayer_id: &str,
672 status: TransactionStatus,
673 delete_at: Option<String>,
674 ) -> TransactionRepoModel {
675 let mut tx = create_mock_transaction();
676 tx.id = id.to_string();
677 tx.relayer_id = relayer_id.to_string();
678 tx.status = status;
679 tx.delete_at = delete_at;
680 tx
681 }
682
683 #[tokio::test]
684 async fn test_should_delete_transaction_expired() {
685 let now = Utc::now();
686 let expired_delete_at = (now - Duration::hours(1)).to_rfc3339();
687
688 let transaction = create_test_transaction(
689 "test-tx",
690 "test-relayer",
691 TransactionStatus::Confirmed,
692 Some(expired_delete_at),
693 );
694
695 assert!(should_delete_transaction(&transaction, now));
696 }
697
698 #[tokio::test]
699 async fn test_should_delete_transaction_not_expired() {
700 let now = Utc::now();
701 let future_delete_at = (now + Duration::hours(1)).to_rfc3339();
702
703 let transaction = create_test_transaction(
704 "test-tx",
705 "test-relayer",
706 TransactionStatus::Confirmed,
707 Some(future_delete_at),
708 );
709
710 assert!(!should_delete_transaction(&transaction, now));
711 }
712
713 #[tokio::test]
714 async fn test_should_delete_transaction_no_delete_at() {
715 let now = Utc::now();
716
717 let transaction = create_test_transaction(
718 "test-tx",
719 "test-relayer",
720 TransactionStatus::Confirmed,
721 None,
722 );
723
724 assert!(!should_delete_transaction(&transaction, now));
725 }
726
727 #[tokio::test]
728 async fn test_should_delete_transaction_invalid_timestamp() {
729 let now = Utc::now();
730
731 let transaction = create_test_transaction(
732 "test-tx",
733 "test-relayer",
734 TransactionStatus::Confirmed,
735 Some("invalid-timestamp".to_string()),
736 );
737
738 assert!(!should_delete_transaction(&transaction, now));
739 }
740
741 #[tokio::test]
742 async fn test_process_transactions_for_cleanup_parallel() {
743 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
744 let relayer_id = "test-relayer";
745 let now = Utc::now();
746
747 let expired_delete_at = (now - Duration::hours(1)).to_rfc3339();
749 let future_delete_at = (now + Duration::hours(1)).to_rfc3339();
750
751 let expired_tx = create_test_transaction(
752 "expired-tx",
753 relayer_id,
754 TransactionStatus::Confirmed,
755 Some(expired_delete_at),
756 );
757 let future_tx = create_test_transaction(
758 "future-tx",
759 relayer_id,
760 TransactionStatus::Failed,
761 Some(future_delete_at),
762 );
763 let no_delete_tx = create_test_transaction(
764 "no-delete-tx",
765 relayer_id,
766 TransactionStatus::Canceled,
767 None,
768 );
769
770 transaction_repo.create(expired_tx.clone()).await.unwrap();
772 transaction_repo.create(future_tx.clone()).await.unwrap();
773 transaction_repo.create(no_delete_tx.clone()).await.unwrap();
774
775 let transactions = vec![expired_tx, future_tx, no_delete_tx];
776
777 let cleaned_count =
779 process_transactions_for_cleanup(transactions, &transaction_repo, relayer_id, now)
780 .await;
781
782 assert_eq!(cleaned_count, 1);
784
785 assert!(transaction_repo
787 .get_by_id("expired-tx".to_string())
788 .await
789 .is_err());
790
791 assert!(transaction_repo
793 .get_by_id("future-tx".to_string())
794 .await
795 .is_ok());
796 assert!(transaction_repo
797 .get_by_id("no-delete-tx".to_string())
798 .await
799 .is_ok());
800 }
801
802 #[tokio::test]
803 async fn test_batch_delete_expired_transactions() {
804 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
805 let relayer_id = "test-relayer";
806 let now = Utc::now();
807
808 let expired_delete_at = (now - Duration::hours(1)).to_rfc3339();
810
811 for i in 0..5 {
812 let tx = create_test_transaction(
813 &format!("expired-tx-{i}"),
814 relayer_id,
815 TransactionStatus::Confirmed,
816 Some(expired_delete_at.clone()),
817 );
818 transaction_repo.create(tx).await.unwrap();
819 }
820
821 assert_eq!(transaction_repo.count().await.unwrap(), 5);
823
824 let ids: Vec<String> = (0..5).map(|i| format!("expired-tx-{i}")).collect();
826 let result = transaction_repo.delete_by_ids(ids).await.unwrap();
827
828 assert_eq!(result.deleted_count, 5);
829 assert!(result.failed.is_empty());
830
831 assert_eq!(transaction_repo.count().await.unwrap(), 0);
833 }
834
835 #[tokio::test]
836 async fn test_batch_delete_with_nonexistent_ids() {
837 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
838 let relayer_id = "test-relayer";
839
840 let tx = create_test_transaction(
842 "existing-tx",
843 relayer_id,
844 TransactionStatus::Confirmed,
845 Some(Utc::now().to_rfc3339()),
846 );
847 transaction_repo.create(tx).await.unwrap();
848
849 let ids = vec![
851 "existing-tx".to_string(),
852 "nonexistent-1".to_string(),
853 "nonexistent-2".to_string(),
854 ];
855 let result = transaction_repo.delete_by_ids(ids).await.unwrap();
856
857 assert_eq!(result.deleted_count, 1);
859 assert_eq!(result.failed.len(), 2);
860
861 assert!(transaction_repo
863 .get_by_id("existing-tx".to_string())
864 .await
865 .is_err());
866 }
867
868 #[tokio::test]
869 async fn test_process_transactions_skips_non_final_status() {
870 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
871 let relayer_id = "test-relayer";
872 let now = Utc::now();
873
874 let expired_delete_at = (now - Duration::hours(1)).to_rfc3339();
876 let pending_tx = create_test_transaction(
877 "pending-tx",
878 relayer_id,
879 TransactionStatus::Pending, Some(expired_delete_at),
881 );
882 transaction_repo.create(pending_tx.clone()).await.unwrap();
883
884 let transactions = vec![pending_tx];
885
886 let cleaned_count =
888 process_transactions_for_cleanup(transactions, &transaction_repo, relayer_id, now)
889 .await;
890
891 assert_eq!(cleaned_count, 0);
893
894 assert!(transaction_repo
896 .get_by_id("pending-tx".to_string())
897 .await
898 .is_ok());
899 }
900
901 #[tokio::test]
902 async fn test_fetch_final_transactions_paginated() {
903 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
904 let relayer_id = "test-relayer";
905
906 let confirmed_tx = create_test_transaction(
908 "confirmed-tx",
909 relayer_id,
910 TransactionStatus::Confirmed,
911 None,
912 );
913 let pending_tx =
914 create_test_transaction("pending-tx", relayer_id, TransactionStatus::Pending, None);
915 let failed_tx =
916 create_test_transaction("failed-tx", relayer_id, TransactionStatus::Failed, None);
917
918 transaction_repo.create(confirmed_tx).await.unwrap();
920 transaction_repo.create(pending_tx).await.unwrap();
921 transaction_repo.create(failed_tx).await.unwrap();
922
923 let query = PaginationQuery {
925 page: 1,
926 per_page: 10,
927 };
928 let result = fetch_final_transactions_paginated(relayer_id, &transaction_repo, query)
929 .await
930 .unwrap();
931
932 assert_eq!(result.total, 2);
934 assert_eq!(result.items.len(), 2);
935 let final_ids: Vec<&String> = result.items.iter().map(|tx| &tx.id).collect();
936 assert!(final_ids.contains(&&"confirmed-tx".to_string()));
937 assert!(final_ids.contains(&&"failed-tx".to_string()));
938 assert!(!final_ids.contains(&&"pending-tx".to_string()));
939 }
940
941 #[tokio::test]
942 async fn test_report_cleanup_results_success() {
943 let results = vec![
944 RelayerCleanupResult {
945 relayer_id: "relayer-1".to_string(),
946 cleaned_count: 2,
947 error: None,
948 },
949 RelayerCleanupResult {
950 relayer_id: "relayer-2".to_string(),
951 cleaned_count: 1,
952 error: None,
953 },
954 ];
955
956 let result = report_cleanup_results(results).await;
957 assert!(result.is_ok());
958 }
959
960 #[tokio::test]
961 async fn test_report_cleanup_results_with_errors() {
962 let results = vec![
963 RelayerCleanupResult {
964 relayer_id: "relayer-1".to_string(),
965 cleaned_count: 2,
966 error: None,
967 },
968 RelayerCleanupResult {
969 relayer_id: "relayer-2".to_string(),
970 cleaned_count: 0,
971 error: Some("Database error".to_string()),
972 },
973 ];
974
975 let result = report_cleanup_results(results).await;
976 assert!(result.is_err());
977 }
978
979 #[tokio::test]
980 async fn test_process_single_relayer_success() {
981 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
982 let relayer = RelayerRepoModel {
983 id: "test-relayer".to_string(),
984 name: "Test Relayer".to_string(),
985 network: "ethereum".to_string(),
986 paused: false,
987 network_type: NetworkType::Evm,
988 signer_id: "test-signer".to_string(),
989 policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()),
990 address: "0x1234567890123456789012345678901234567890".to_string(),
991 notification_id: None,
992 system_disabled: false,
993 custom_rpc_urls: None,
994 ..Default::default()
995 };
996 let now = Utc::now();
997
998 let expired_tx = create_test_transaction(
1000 "expired-tx",
1001 &relayer.id,
1002 TransactionStatus::Confirmed,
1003 Some((now - Duration::hours(1)).to_rfc3339()),
1004 );
1005 let future_tx = create_test_transaction(
1006 "future-tx",
1007 &relayer.id,
1008 TransactionStatus::Failed,
1009 Some((now + Duration::hours(1)).to_rfc3339()),
1010 );
1011
1012 transaction_repo.create(expired_tx).await.unwrap();
1013 transaction_repo.create(future_tx).await.unwrap();
1014
1015 let result = process_single_relayer(relayer.clone(), transaction_repo.clone(), now).await;
1016
1017 assert_eq!(result.relayer_id, relayer.id);
1018 assert_eq!(result.cleaned_count, 1);
1019 assert!(result.error.is_none());
1020 }
1021
1022 #[tokio::test]
1023 async fn test_process_single_relayer_no_transactions() {
1024 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1026 let relayer = RelayerRepoModel {
1027 id: "empty-relayer".to_string(),
1028 name: "Empty Relayer".to_string(),
1029 network: "ethereum".to_string(),
1030 paused: false,
1031 network_type: NetworkType::Evm,
1032 signer_id: "test-signer".to_string(),
1033 policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()),
1034 address: "0x1234567890123456789012345678901234567890".to_string(),
1035 notification_id: None,
1036 system_disabled: false,
1037 custom_rpc_urls: None,
1038 ..Default::default()
1039 };
1040 let now = Utc::now();
1041
1042 let result = process_single_relayer(relayer.clone(), transaction_repo, now).await;
1044
1045 assert_eq!(result.relayer_id, relayer.id);
1046 assert_eq!(result.cleaned_count, 0);
1047 assert!(result.error.is_none()); }
1049
1050 #[tokio::test]
1051 async fn test_process_single_relayer_surfaces_reconcile_error() {
1052 let mut transaction_repo = MockTransactionRepository::new();
1053 transaction_repo
1054 .expect_find_by_status_paginated()
1055 .times(FINAL_TRANSACTION_STATUSES.len())
1056 .returning(|_, _, query, _| {
1057 Ok(PaginatedResult {
1058 items: vec![],
1059 total: 0,
1060 page: query.page,
1061 per_page: query.per_page,
1062 })
1063 });
1064 transaction_repo
1065 .expect_reconcile_stale_status_indexes()
1066 .times(1)
1067 .returning(|_| Err(RepositoryError::Other("reconcile failed".to_string())));
1068
1069 let relayer = RelayerRepoModel {
1070 id: "test-relayer".to_string(),
1071 name: "Test Relayer".to_string(),
1072 network: "ethereum".to_string(),
1073 paused: false,
1074 network_type: NetworkType::Evm,
1075 signer_id: "test-signer".to_string(),
1076 policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()),
1077 address: "0x1234567890123456789012345678901234567890".to_string(),
1078 notification_id: None,
1079 system_disabled: false,
1080 custom_rpc_urls: None,
1081 ..Default::default()
1082 };
1083
1084 let result =
1085 process_single_relayer(relayer.clone(), Arc::new(transaction_repo), Utc::now()).await;
1086
1087 assert_eq!(result.relayer_id, relayer.id);
1088 assert_eq!(result.cleaned_count, 0);
1089 assert_eq!(
1090 result.error,
1091 Some("Other error: reconcile failed".to_string())
1092 );
1093 }
1094
1095 #[tokio::test]
1096 async fn test_process_transactions_with_empty_list() {
1097 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1098 let relayer_id = "test-relayer";
1099 let now = Utc::now();
1100 let transactions = vec![];
1101
1102 let cleaned_count =
1103 process_transactions_for_cleanup(transactions, &transaction_repo, relayer_id, now)
1104 .await;
1105
1106 assert_eq!(cleaned_count, 0);
1107 }
1108
1109 #[tokio::test]
1110 async fn test_process_transactions_with_no_expired() {
1111 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1112 let relayer_id = "test-relayer";
1113 let now = Utc::now();
1114
1115 let future_tx1 = create_test_transaction(
1117 "future-tx-1",
1118 relayer_id,
1119 TransactionStatus::Confirmed,
1120 Some((now + Duration::hours(1)).to_rfc3339()),
1121 );
1122 let future_tx2 = create_test_transaction(
1123 "future-tx-2",
1124 relayer_id,
1125 TransactionStatus::Failed,
1126 Some((now + Duration::hours(2)).to_rfc3339()),
1127 );
1128 let no_delete_tx = create_test_transaction(
1129 "no-delete-tx",
1130 relayer_id,
1131 TransactionStatus::Canceled,
1132 None,
1133 );
1134
1135 let transactions = vec![future_tx1, future_tx2, no_delete_tx];
1136
1137 let cleaned_count =
1138 process_transactions_for_cleanup(transactions, &transaction_repo, relayer_id, now)
1139 .await;
1140
1141 assert_eq!(cleaned_count, 0);
1142 }
1143
1144 #[tokio::test]
1145 async fn test_should_delete_transaction_exactly_at_expiry_time() {
1146 let now = Utc::now();
1147 let exact_expiry_time = now.to_rfc3339();
1148
1149 let transaction = create_test_transaction(
1150 "test-tx",
1151 "test-relayer",
1152 TransactionStatus::Confirmed,
1153 Some(exact_expiry_time),
1154 );
1155
1156 assert!(should_delete_transaction(&transaction, now));
1158 }
1159
1160 #[tokio::test]
1161 async fn test_parallel_processing_with_mixed_results() {
1162 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1163 let relayer_id = "test-relayer";
1164 let now = Utc::now();
1165
1166 let expired_tx1 = create_test_transaction(
1168 "expired-tx-1",
1169 relayer_id,
1170 TransactionStatus::Confirmed,
1171 Some((now - Duration::hours(1)).to_rfc3339()),
1172 );
1173 let expired_tx2 = create_test_transaction(
1174 "expired-tx-2",
1175 relayer_id,
1176 TransactionStatus::Failed,
1177 Some((now - Duration::hours(2)).to_rfc3339()),
1178 );
1179 let expired_tx3 = create_test_transaction(
1180 "expired-tx-3",
1181 relayer_id,
1182 TransactionStatus::Canceled,
1183 Some((now - Duration::hours(3)).to_rfc3339()),
1184 );
1185
1186 transaction_repo.create(expired_tx1.clone()).await.unwrap();
1188 transaction_repo.create(expired_tx2.clone()).await.unwrap();
1189 let transactions = vec![expired_tx1, expired_tx2, expired_tx3];
1192
1193 let cleaned_count =
1194 process_transactions_for_cleanup(transactions, &transaction_repo, relayer_id, now)
1195 .await;
1196
1197 assert_eq!(cleaned_count, 2);
1199 }
1200
1201 #[tokio::test]
1202 async fn test_report_cleanup_results_empty() {
1203 let results = vec![];
1204 let result = report_cleanup_results(results).await;
1205 assert!(result.is_ok());
1206 }
1207
1208 #[tokio::test]
1209 async fn test_fetch_final_transactions_paginated_with_mixed_statuses() {
1210 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1211 let relayer_id = "test-relayer";
1212
1213 let confirmed_tx = create_test_transaction(
1215 "confirmed-tx",
1216 relayer_id,
1217 TransactionStatus::Confirmed,
1218 None,
1219 );
1220 let failed_tx =
1221 create_test_transaction("failed-tx", relayer_id, TransactionStatus::Failed, None);
1222 let canceled_tx =
1223 create_test_transaction("canceled-tx", relayer_id, TransactionStatus::Canceled, None);
1224 let expired_tx =
1225 create_test_transaction("expired-tx", relayer_id, TransactionStatus::Expired, None);
1226 let pending_tx =
1227 create_test_transaction("pending-tx", relayer_id, TransactionStatus::Pending, None);
1228 let sent_tx = create_test_transaction("sent-tx", relayer_id, TransactionStatus::Sent, None);
1229
1230 transaction_repo.create(confirmed_tx).await.unwrap();
1232 transaction_repo.create(failed_tx).await.unwrap();
1233 transaction_repo.create(canceled_tx).await.unwrap();
1234 transaction_repo.create(expired_tx).await.unwrap();
1235 transaction_repo.create(pending_tx).await.unwrap();
1236 transaction_repo.create(sent_tx).await.unwrap();
1237
1238 let query = PaginationQuery {
1240 page: 1,
1241 per_page: 10,
1242 };
1243 let result = fetch_final_transactions_paginated(relayer_id, &transaction_repo, query)
1244 .await
1245 .unwrap();
1246
1247 assert_eq!(result.total, 4);
1249 assert_eq!(result.items.len(), 4);
1250 let final_ids: Vec<&String> = result.items.iter().map(|tx| &tx.id).collect();
1251 assert!(final_ids.contains(&&"confirmed-tx".to_string()));
1252 assert!(final_ids.contains(&&"failed-tx".to_string()));
1253 assert!(final_ids.contains(&&"canceled-tx".to_string()));
1254 assert!(final_ids.contains(&&"expired-tx".to_string()));
1255 assert!(!final_ids.contains(&&"pending-tx".to_string()));
1256 assert!(!final_ids.contains(&&"sent-tx".to_string()));
1257 }
1258
1259 #[tokio::test]
1260 async fn test_fetch_final_transactions_paginated_pagination() {
1261 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1262 let relayer_id = "test-relayer";
1263
1264 for i in 1..=5 {
1266 let mut tx = create_test_transaction(
1267 &format!("tx-{i}"),
1268 relayer_id,
1269 TransactionStatus::Confirmed,
1270 None,
1271 );
1272 tx.created_at = format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i);
1273 transaction_repo.create(tx).await.unwrap();
1274 }
1275
1276 let query = PaginationQuery {
1278 page: 1,
1279 per_page: 2,
1280 };
1281 let result = fetch_final_transactions_paginated(relayer_id, &transaction_repo, query)
1282 .await
1283 .unwrap();
1284
1285 assert_eq!(result.total, 5);
1286 assert_eq!(result.items.len(), 2);
1287 assert_eq!(result.page, 1);
1288
1289 let query = PaginationQuery {
1291 page: 2,
1292 per_page: 2,
1293 };
1294 let result = fetch_final_transactions_paginated(relayer_id, &transaction_repo, query)
1295 .await
1296 .unwrap();
1297
1298 assert_eq!(result.total, 5);
1299 assert_eq!(result.items.len(), 2);
1300 assert_eq!(result.page, 2);
1301
1302 let query = PaginationQuery {
1304 page: 3,
1305 per_page: 2,
1306 };
1307 let result = fetch_final_transactions_paginated(relayer_id, &transaction_repo, query)
1308 .await
1309 .unwrap();
1310
1311 assert_eq!(result.total, 5);
1312 assert_eq!(result.items.len(), 1);
1313 assert_eq!(result.page, 3);
1314 }
1315
1316 #[tokio::test]
1317 async fn test_process_status_cleanup_deletes_expired() {
1318 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1319 let relayer_id = "test-relayer";
1320 let now = Utc::now();
1321
1322 let expired_tx = create_test_transaction(
1324 "expired-tx",
1325 relayer_id,
1326 TransactionStatus::Confirmed,
1327 Some((now - Duration::hours(1)).to_rfc3339()),
1328 );
1329 let future_tx = create_test_transaction(
1330 "future-tx",
1331 relayer_id,
1332 TransactionStatus::Confirmed,
1333 Some((now + Duration::hours(1)).to_rfc3339()),
1334 );
1335
1336 transaction_repo.create(expired_tx).await.unwrap();
1337 transaction_repo.create(future_tx).await.unwrap();
1338
1339 let cleaned = process_status_cleanup(
1340 relayer_id,
1341 &TransactionStatus::Confirmed,
1342 &transaction_repo,
1343 now,
1344 )
1345 .await
1346 .unwrap();
1347
1348 assert_eq!(cleaned, 1);
1349
1350 assert!(transaction_repo
1352 .get_by_id("expired-tx".to_string())
1353 .await
1354 .is_err());
1355 assert!(transaction_repo
1356 .get_by_id("future-tx".to_string())
1357 .await
1358 .is_ok());
1359 }
1360
1361 #[tokio::test]
1362 async fn test_process_status_cleanup_no_transactions() {
1363 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1364 let relayer_id = "test-relayer";
1365 let now = Utc::now();
1366
1367 let cleaned = process_status_cleanup(
1368 relayer_id,
1369 &TransactionStatus::Confirmed,
1370 &transaction_repo,
1371 now,
1372 )
1373 .await
1374 .unwrap();
1375
1376 assert_eq!(cleaned, 0);
1377 }
1378
1379 #[tokio::test]
1380 async fn test_process_status_cleanup_skips_other_statuses() {
1381 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1382 let relayer_id = "test-relayer";
1383 let now = Utc::now();
1384
1385 let tx = create_test_transaction(
1387 "failed-tx",
1388 relayer_id,
1389 TransactionStatus::Failed,
1390 Some((now - Duration::hours(1)).to_rfc3339()),
1391 );
1392 transaction_repo.create(tx).await.unwrap();
1393
1394 let cleaned = process_status_cleanup(
1396 relayer_id,
1397 &TransactionStatus::Confirmed,
1398 &transaction_repo,
1399 now,
1400 )
1401 .await
1402 .unwrap();
1403
1404 assert_eq!(cleaned, 0);
1405 assert!(transaction_repo
1406 .get_by_id("failed-tx".to_string())
1407 .await
1408 .is_ok());
1409 }
1410
1411 #[tokio::test]
1412 async fn test_process_single_relayer_processes_all_final_statuses() {
1413 let transaction_repo = Arc::new(InMemoryTransactionRepository::new());
1414 let relayer = RelayerRepoModel {
1415 id: "test-relayer".to_string(),
1416 name: "Test Relayer".to_string(),
1417 network: "ethereum".to_string(),
1418 paused: false,
1419 network_type: NetworkType::Evm,
1420 signer_id: "test-signer".to_string(),
1421 policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()),
1422 address: "0x1234567890123456789012345678901234567890".to_string(),
1423 notification_id: None,
1424 system_disabled: false,
1425 custom_rpc_urls: None,
1426 ..Default::default()
1427 };
1428 let now = Utc::now();
1429 let expired_at = (now - Duration::hours(1)).to_rfc3339();
1430
1431 for (i, status) in [
1433 TransactionStatus::Confirmed,
1434 TransactionStatus::Failed,
1435 TransactionStatus::Canceled,
1436 TransactionStatus::Expired,
1437 ]
1438 .iter()
1439 .enumerate()
1440 {
1441 let tx = create_test_transaction(
1442 &format!("tx-{}", i),
1443 &relayer.id,
1444 status.clone(),
1445 Some(expired_at.clone()),
1446 );
1447 transaction_repo.create(tx).await.unwrap();
1448 }
1449
1450 let result = process_single_relayer(relayer.clone(), transaction_repo.clone(), now).await;
1451
1452 assert_eq!(result.relayer_id, relayer.id);
1453 assert_eq!(result.cleaned_count, 4);
1454 assert!(result.error.is_none());
1455
1456 assert_eq!(transaction_repo.count().await.unwrap(), 0);
1458 }
1459}