openzeppelin_relayer/jobs/handlers/
transaction_cleanup_handler.rs

1//! Transaction cleanup worker implementation.
2//!
3//! This module implements the transaction cleanup worker that processes
4//! expired transactions marked for deletion. It runs as a cron job to
5//! automatically clean up transactions that have passed their delete_at timestamp.
6//!
7//! ## Distributed Lock
8//!
9//! Since this runs on multiple service instances simultaneously (each with its own
10//! CronStream), a distributed lock is used to ensure only one instance processes
11//! the cleanup at a time. The lock has a 9-minute TTL (the cron runs every 10 minutes),
12//! ensuring the lock expires before the next scheduled run.
13
14use 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
37/// Maximum number of relayers to process concurrently
38const MAX_CONCURRENT_RELAYERS: usize = 10;
39
40/// Number of transactions to fetch per page during cleanup
41const CLEANUP_PAGE_SIZE: u32 = 100;
42
43/// Maximum number of transactions to delete in a single batch operation.
44/// This prevents overwhelming Redis with very large pipelines.
45const DELETE_BATCH_SIZE: usize = 100;
46
47/// Maximum page iterations per status before stopping.
48/// Prevents unbounded cleanup from exceeding the lock TTL.
49/// With CLEANUP_PAGE_SIZE=100, allows up to 150,000 transactions per status per run.
50const MAX_CLEANUP_ITERATIONS_PER_STATUS: u32 = 1500;
51
52/// Distributed lock name for transaction cleanup.
53/// Only one instance across the cluster should run cleanup at a time.
54const CLEANUP_LOCK_NAME: &str = "transaction_cleanup";
55
56/// Handles periodic transaction cleanup jobs from the queue.
57///
58/// This function processes expired transactions by:
59/// 1. Fetching all relayers from the system
60/// 2. For each relayer, finding transactions with final statuses
61/// 3. Checking if their delete_at timestamp has passed
62/// 4. Validating transactions are in final states before deletion
63/// 5. Deleting transactions that have expired (in parallel)
64///
65/// # Arguments
66/// * `job` - The cron reminder job triggering the cleanup
67/// * `data` - Application state containing repositories
68/// * `ctx` - Worker context with attempt number and task ID
69///
70/// # Returns
71/// * `Result<(), HandlerError>` - Success or failure of cleanup processing
72#[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/// Represents a cron reminder job for triggering cleanup operations.
97#[derive(Default, Debug, Clone)]
98pub struct TransactionCleanupCronReminder();
99
100/// Handles the actual transaction cleanup request logic.
101///
102/// This function first attempts to acquire a distributed lock to ensure only
103/// one instance processes cleanup at a time. If the lock is already held by
104/// another instance, this returns early without doing any work.
105///
106/// # Arguments
107/// * `_job` - The cron reminder job (currently unused)
108/// * `data` - Application state containing repositories
109///
110/// # Returns
111/// * `Result<()>` - Success or failure of the cleanup operation
112async fn handle_request(
113    _job: TransactionCleanupCronReminder,
114    data: &ThinData<DefaultAppState>,
115) -> Result<()> {
116    let transaction_repo = data.transaction_repository();
117
118    // In distributed mode, acquire a lock to prevent multiple instances from
119    // running cleanup simultaneously. In single-instance mode, skip locking.
120    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                    // Fail closed: skip cleanup if we can't communicate with Redis for locking,
141                    // to prevent concurrent execution across multiple instances
142                    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    // Fetch all relayers
168    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    // Process relayers in parallel batches
182    let cleanup_results = process_relayers_in_batches(relayers, transaction_repo, now).await;
183
184    // Aggregate and report results
185    let result = report_cleanup_results(cleanup_results).await;
186
187    // Lock guard is automatically released when dropped (via Drop impl).
188    // This happens regardless of whether we exit normally or via early return/error.
189    drop(lock_guard);
190
191    result
192}
193
194/// Processes multiple relayers in parallel batches for cleanup.
195///
196/// # Arguments
197/// * `relayers` - List of relayers to process
198/// * `transaction_repo` - Reference to the transaction repository
199/// * `now` - Current UTC timestamp for comparison
200///
201/// # Returns
202/// * `Vec<RelayerCleanupResult>` - Results from processing each relayer
203async 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    // Process relayers with limited concurrency to avoid overwhelming the system
211    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/// Result of processing a single relayer's transactions.
224#[derive(Debug)]
225struct RelayerCleanupResult {
226    relayer_id: String,
227    cleaned_count: usize,
228    error: Option<String>,
229}
230
231/// Processes cleanup for a single relayer by iterating over each final status independently.
232///
233/// Each status is processed separately to use efficient single-key Redis ZRANGE pagination
234/// instead of the multi-status merge path which fetches all IDs into memory.
235///
236/// # Arguments
237/// * `relayer` - The relayer to process
238/// * `transaction_repo` - Reference to the transaction repository
239/// * `now` - Current UTC timestamp for comparison
240///
241/// # Returns
242/// * `RelayerCleanupResult` - Result of processing this relayer
243async 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    // Reconcile before deleting so ghosts repaired into a final index are
256    // reaped in this run instead of waiting for the next cron cycle.
257    // A reconcile failure must not block deletion, so the error is only
258    // recorded and surfaced after the cleanup loop.
259    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                // Preserve a reconcile failure alongside the cleanup failure so
294                // neither error is lost in the combined-failure path.
295                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
324/// Processes cleanup for a single status of a single relayer.
325///
326/// Uses stable pagination: when items are deleted, the same page is re-queried
327/// because deletions shift subsequent items into the current page's range.
328/// Only advances the page when no deletions occurred (items remain in place).
329///
330/// # Arguments
331/// * `relayer_id` - ID of the relayer
332/// * `status` - The transaction status to process
333/// * `transaction_repo` - Reference to the transaction repository
334/// * `now` - Current UTC timestamp for comparison
335///
336/// # Returns
337/// * `Result<usize>` - Number of transactions cleaned up for this status
338async 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            // No items were deleted on this page, so items remain in place.
399            // Advance to the next page to check further items.
400            current_page += 1;
401        }
402        // When items were deleted, stay on the same page: deletions shift
403        // subsequent items into the current page range, so re-querying
404        // the same page picks up previously-unreachable items.
405    }
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/// Fetches a page of transactions with final statuses for a specific relayer.
420/// Used in tests to verify pagination behavior across all final statuses.
421#[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
439/// Processes a list of transactions for cleanup using batch delete, deleting expired ones.
440///
441/// This function validates that transactions are in final states before deletion,
442/// ensuring data integrity by preventing accidental deletion of active transactions.
443/// Uses batch deletion for improved performance with large numbers of transactions.
444///
445/// # Arguments
446/// * `transactions` - List of transactions to process
447/// * `transaction_repo` - Reference to the transaction repository
448/// * `relayer_id` - ID of the relayer (for logging)
449/// * `now` - Current UTC timestamp for comparison
450///
451/// # Returns
452/// * `usize` - Number of transactions successfully cleaned up
453async 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    // Filter expired transactions and validate they are in final states,
470    // then convert to delete requests with pre-extracted data
471    let delete_requests: Vec<TransactionDeleteRequest> = transactions
472        .into_iter()
473        .filter(|tx| {
474            // Must be in a final state
475            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            // Must be expired
484            should_delete_transaction(tx, now)
485        })
486        .map(|tx| {
487            // Extract nonce from network data for index cleanup
488            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    // Process deletions in batches to avoid overwhelming Redis with large pipelines
509    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
563/// Extracts the nonce from network transaction data if available.
564/// This is used for cleaning up nonce indexes during deletion.
565fn 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
572/// Determines if a transaction should be deleted based on its delete_at timestamp.
573///
574/// # Arguments
575/// * `transaction` - The transaction to check
576/// * `now` - Current UTC timestamp for comparison
577///
578/// # Returns
579/// * `bool` - True if the transaction should be deleted, false otherwise
580fn 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
607/// Reports the aggregated results of the cleanup operation.
608///
609/// # Arguments
610/// * `cleanup_results` - Results from processing all relayers
611///
612/// # Returns
613/// * `Result<()>` - Success if all went well, error if there were failures
614async 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    // Log detailed results for relayers with errors
620    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        // Return error if there were failures, but don't fail the entire job
637        // This allows for partial success and retry of failed relayers
638        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        // Create test transactions
748        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        // Store transactions
771        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        // Process transactions
778        let cleaned_count =
779            process_transactions_for_cleanup(transactions, &transaction_repo, relayer_id, now)
780                .await;
781
782        // Should have cleaned up 1 expired transaction
783        assert_eq!(cleaned_count, 1);
784
785        // Verify expired transaction was deleted
786        assert!(transaction_repo
787            .get_by_id("expired-tx".to_string())
788            .await
789            .is_err());
790
791        // Verify non-expired transactions still exist
792        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        // Create multiple expired transactions
809        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        // Verify they exist
822        assert_eq!(transaction_repo.count().await.unwrap(), 5);
823
824        // Delete them using batch delete
825        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        // Verify they were deleted
832        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        // Create one transaction
841        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        // Try to delete existing and non-existing transactions
850        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        // Should delete the existing one and report failures for the others
858        assert_eq!(result.deleted_count, 1);
859        assert_eq!(result.failed.len(), 2);
860
861        // Verify the existing one was deleted
862        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        // Create a transaction with non-final status but expired delete_at
875        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, // Non-final status
880            Some(expired_delete_at),
881        );
882        transaction_repo.create(pending_tx.clone()).await.unwrap();
883
884        let transactions = vec![pending_tx];
885
886        // Process should skip non-final status transactions
887        let cleaned_count =
888            process_transactions_for_cleanup(transactions, &transaction_repo, relayer_id, now)
889                .await;
890
891        // Should not have cleaned any transactions
892        assert_eq!(cleaned_count, 0);
893
894        // Transaction should still exist
895        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        // Create transactions with different statuses
907        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        // Store transactions
919        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        // Fetch final transactions with pagination
924        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        // Should only return transactions with final statuses (Confirmed, Failed)
933        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        // Create expired and non-expired transactions
999        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        // Create a relayer with no transactions in the repo
1025        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        // This should succeed but find no transactions
1043        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()); // No error, just no transactions found
1048    }
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        // Create only non-expired transactions
1116        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        // Should be considered expired when exactly at expiry time
1157        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        // Create multiple expired transactions
1167        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        // Store only some transactions (others will fail deletion due to NotFound)
1187        transaction_repo.create(expired_tx1.clone()).await.unwrap();
1188        transaction_repo.create(expired_tx2.clone()).await.unwrap();
1189        // Don't store expired_tx3 - it will fail deletion
1190
1191        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        // Should have cleaned 2 out of 3 transactions (one failed due to NotFound)
1198        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        // Create transactions with all possible statuses
1214        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        // Store all transactions
1231        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        // Fetch final transactions with pagination
1239        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        // Should only return the 4 final status transactions
1248        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        // Create 5 confirmed transactions
1265        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        // Test first page with 2 items
1277        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        // Test second page
1290        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        // Test last page (partial)
1303        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        // Create expired and non-expired transactions of the same status
1323        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        // Expired one deleted, future one remains
1351        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        // Create expired transaction with Failed status
1386        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        // Cleanup for Confirmed status should not touch Failed transactions
1395        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        // Create one expired transaction per final status
1432        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        // All should be deleted
1457        assert_eq!(transaction_repo.count().await.unwrap(), 0);
1458    }
1459}