openzeppelin_relayer/queues/
cron.rs

1//! Backend-neutral cron scheduler for dumb-pipe queue backends (SQS, Pub/Sub).
2//!
3//! When running with a non-Apalis backend, Apalis's `CronStream` + `Monitor` are
4//! not available. This module provides a lightweight tokio-based replacement that
5//! uses `DistributedLock` to prevent duplicate execution across instances.
6//!
7//! The scheduler is shared by the SQS and Pub/Sub backends (SQS uses it via the
8//! `SqsCronScheduler` alias). Its distributed-lock keys are therefore
9//! **backend-neutral and identical** across backends: a fleet running both
10//! backends against one Redis forms a single lock domain per task and runs each
11//! cron at most once per interval. The lock keys MUST NOT embed the
12//! backend name.
13
14use std::panic::AssertUnwindSafe;
15use std::str::FromStr;
16use std::sync::Arc;
17use std::time::Duration;
18
19use actix_web::web::ThinData;
20use chrono::Utc;
21use futures::FutureExt;
22use tokio::sync::watch;
23use tracing::{debug, error, info, warn};
24
25use crate::{
26    config::ServerConfig,
27    constants::{
28        SYSTEM_CLEANUP_CRON_SCHEDULE, SYSTEM_CLEANUP_LOCK_TTL_SECS, TOKEN_SWAP_CRON_LOCK_TTL_SECS,
29        TRANSACTION_CLEANUP_CRON_SCHEDULE, TRANSACTION_CLEANUP_LOCK_TTL_SECS,
30    },
31    jobs::{
32        system_cleanup_handler, token_swap_cron_handler, transaction_cleanup_handler,
33        SystemCleanupCronReminder, TokenSwapCronReminder, TransactionCleanupCronReminder,
34    },
35    models::{DefaultAppState, RelayerNetworkPolicy},
36    queues::WorkerContext,
37    repositories::RelayerRepository,
38    utils::DistributedLock,
39};
40
41use super::filter_relayers_for_swap;
42use super::WorkerHandle;
43
44/// Safety margin subtracted from cron interval when deriving lock TTL.
45const CRON_LOCK_TTL_MARGIN_SECS: u64 = 5;
46/// Minimum derived lock TTL to avoid excessive lock churn on short intervals.
47const CRON_LOCK_TTL_MIN_SECS: u64 = 30;
48
49// ── Backend-neutral lock task names ──────────────────────────
50//
51// These MUST be identical across SQS and Pub/Sub (no backend name embedded) so a
52// mixed fleet shares one lock domain per task. They are deliberately DISTINCT
53// from the cleanup handlers' own self-lock keys (`transaction_cleanup` /
54// `system_queue_cleanup`): reusing those exact keys here would make the handler
55// find the lock already held (by this scheduler) and skip its own work. The
56// outer lock is a fleet-level "run this cron once" guard; the handlers' inner
57// self-lock is a redundant backstop. The token-swap handler does NOT self-lock,
58// so for it this outer lock is the sole at-most-once guarantee.
59
60/// Lock task name for the transaction-cleanup cron (backend-neutral).
61pub(crate) const TRANSACTION_CLEANUP_CRON_LOCK: &str = "cron-transaction-cleanup";
62/// Lock task name for the system-cleanup cron (backend-neutral).
63pub(crate) const SYSTEM_CLEANUP_CRON_LOCK: &str = "cron-system-cleanup";
64
65/// Lock task name for a relayer's token-swap cron (backend-neutral).
66pub(crate) fn token_swap_cron_lock(relayer_id: &str) -> String {
67    format!("cron-token-swap-{relayer_id}")
68}
69
70/// Cron scheduler that runs periodic tasks using tokio timers and distributed
71/// locks for cross-instance coordination.
72pub struct CronScheduler {
73    app_state: Arc<ThinData<DefaultAppState>>,
74    shutdown_rx: watch::Receiver<bool>,
75    /// Handle to the multi-thread pipeline runtime; cron tasks are spawned onto it.
76    runtime_handle: tokio::runtime::Handle,
77}
78
79impl CronScheduler {
80    pub fn new(
81        app_state: Arc<ThinData<DefaultAppState>>,
82        shutdown_rx: watch::Receiver<bool>,
83        runtime_handle: tokio::runtime::Handle,
84    ) -> Self {
85        Self {
86            app_state,
87            shutdown_rx,
88            runtime_handle,
89        }
90    }
91
92    /// Starts all cron tasks and returns their handles.
93    pub async fn start(self) -> Result<Vec<WorkerHandle>, super::QueueBackendError> {
94        let mut handles = Vec::new();
95
96        // Transaction cleanup: every 10 minutes, lock TTL 9 min
97        handles.push(spawn_cron_task(
98            TRANSACTION_CLEANUP_CRON_LOCK,
99            TRANSACTION_CLEANUP_CRON_SCHEDULE,
100            Duration::from_secs(TRANSACTION_CLEANUP_LOCK_TTL_SECS),
101            self.app_state.clone(),
102            self.shutdown_rx.clone(),
103            self.runtime_handle.clone(),
104            |state| {
105                Box::pin(async move {
106                    let ctx = WorkerContext::new(0, uuid::Uuid::new_v4().to_string());
107                    if let Err(e) = transaction_cleanup_handler(
108                        TransactionCleanupCronReminder(),
109                        (*state).clone(),
110                        ctx,
111                    )
112                    .await
113                    {
114                        warn!(error = %e, "Transaction cleanup handler failed");
115                    }
116                })
117            },
118        )?);
119
120        // System cleanup: every hour, lock TTL 14 min
121        handles.push(spawn_cron_task(
122            SYSTEM_CLEANUP_CRON_LOCK,
123            SYSTEM_CLEANUP_CRON_SCHEDULE,
124            Duration::from_secs(SYSTEM_CLEANUP_LOCK_TTL_SECS),
125            self.app_state.clone(),
126            self.shutdown_rx.clone(),
127            self.runtime_handle.clone(),
128            |state| {
129                Box::pin(async move {
130                    let ctx = WorkerContext::new(0, uuid::Uuid::new_v4().to_string());
131                    if let Err(e) =
132                        system_cleanup_handler(SystemCleanupCronReminder(), (*state).clone(), ctx)
133                            .await
134                    {
135                        warn!(error = %e, "System cleanup handler failed");
136                    }
137                })
138            },
139        )?);
140
141        // Token swap crons: one per eligible relayer
142        let swap_handles = self.start_token_swap_crons().await?;
143        handles.extend(swap_handles);
144
145        info!(
146            cron_count = handles.len(),
147            "Cron scheduler started all tasks"
148        );
149        Ok(handles)
150    }
151
152    /// Creates per-relayer token swap cron tasks for Solana/Stellar relayers
153    /// that have swap config with a cron schedule.
154    async fn start_token_swap_crons(&self) -> Result<Vec<WorkerHandle>, super::QueueBackendError> {
155        let active_relayers = self
156            .app_state
157            .relayer_repository()
158            .list_active()
159            .await
160            .map_err(|e| {
161                super::QueueBackendError::WorkerInitError(format!(
162                    "Failed to list active relayers for swap crons: {e}"
163                ))
164            })?;
165
166        let eligible_relayers = filter_relayers_for_swap(active_relayers);
167        let mut handles = Vec::new();
168
169        for relayer in eligible_relayers {
170            let cron_expr = match &relayer.policies {
171                RelayerNetworkPolicy::Solana(policy) => policy
172                    .get_swap_config()
173                    .and_then(|c| c.cron_schedule.clone()),
174                RelayerNetworkPolicy::Stellar(policy) => policy
175                    .get_swap_config()
176                    .and_then(|c| c.cron_schedule.clone()),
177                _ => None,
178            };
179
180            let Some(cron_expr) = cron_expr else {
181                continue;
182            };
183
184            let relayer_id = relayer.id.clone();
185            let task_name = token_swap_cron_lock(&relayer_id);
186            let lock_ttl = derive_cron_lock_ttl(
187                &cron_expr,
188                Duration::from_secs(TOKEN_SWAP_CRON_LOCK_TTL_SECS),
189            );
190
191            let handle = spawn_cron_task(
192                &task_name,
193                &cron_expr,
194                lock_ttl,
195                self.app_state.clone(),
196                self.shutdown_rx.clone(),
197                self.runtime_handle.clone(),
198                move |state| {
199                    let rid = relayer_id.clone();
200                    Box::pin(async move {
201                        let ctx = WorkerContext::new(0, uuid::Uuid::new_v4().to_string());
202                        if let Err(e) = token_swap_cron_handler(
203                            TokenSwapCronReminder(),
204                            rid.clone(),
205                            (*state).clone(),
206                            ctx,
207                        )
208                        .await
209                        {
210                            warn!(relayer_id = %rid, error = %e, "Token swap cron handler failed");
211                        }
212                    })
213                },
214            )?;
215
216            handles.push(handle);
217            debug!(task_name = %token_swap_cron_lock(&relayer.id), "Registered token swap cron");
218        }
219
220        Ok(handles)
221    }
222}
223
224/// Derives distributed lock TTL from cron schedule interval with a fallback.
225///
226/// TTL is set slightly below the schedule interval (`interval - margin`) to
227/// avoid overlap while allowing the next run to acquire the lock. If interval
228/// derivation fails, `fallback_ttl` is used.
229fn derive_cron_lock_ttl(cron_expr: &str, fallback_ttl: Duration) -> Duration {
230    let schedule = match cron::Schedule::from_str(cron_expr) {
231        Ok(s) => s,
232        Err(_) => return fallback_ttl,
233    };
234
235    let now = Utc::now();
236    let mut upcoming = schedule.after(&now);
237    let (Some(first), Some(second)) = (upcoming.next(), upcoming.next()) else {
238        return fallback_ttl;
239    };
240
241    let Ok(interval) = (second - first).to_std() else {
242        return fallback_ttl;
243    };
244
245    let interval_secs = interval.as_secs();
246    if interval_secs <= 1 {
247        return Duration::from_secs(1);
248    }
249
250    let capped_secs = interval_secs.saturating_sub(CRON_LOCK_TTL_MARGIN_SECS);
251    let derived_secs = capped_secs
252        .max(CRON_LOCK_TTL_MIN_SECS)
253        .min(interval_secs - 1);
254    Duration::from_secs(derived_secs)
255}
256
257/// Spawns a single cron task that:
258/// 1. Parses the cron expression
259/// 2. Sleeps until the next occurrence (interruptible by shutdown)
260/// 3. Acquires a distributed lock (skips if held by another instance)
261/// 4. Calls the handler
262fn spawn_cron_task(
263    name: &str,
264    cron_expr: &str,
265    lock_ttl: Duration,
266    app_state: Arc<ThinData<DefaultAppState>>,
267    mut shutdown_rx: watch::Receiver<bool>,
268    runtime_handle: tokio::runtime::Handle,
269    handler: impl Fn(
270            Arc<ThinData<DefaultAppState>>,
271        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
272        + Send
273        + Sync
274        + 'static,
275) -> Result<WorkerHandle, super::QueueBackendError> {
276    let schedule = cron::Schedule::from_str(cron_expr).map_err(|e| {
277        super::QueueBackendError::WorkerInitError(format!(
278            "Invalid cron expression '{cron_expr}' for {name}: {e}"
279        ))
280    })?;
281
282    let task_name = name.to_string();
283
284    info!(
285        name = %task_name,
286        cron = %cron_expr,
287        lock_ttl_secs = lock_ttl.as_secs(),
288        "Registering cron task"
289    );
290
291    let handle = runtime_handle.spawn(async move {
292        loop {
293            // Compute next tick
294            let next = match schedule.upcoming(Utc).next() {
295                Some(t) => t,
296                None => {
297                    warn!(name = %task_name, "Cron schedule exhausted, stopping task");
298                    break;
299                }
300            };
301
302            let until_next = (next - Utc::now())
303                .to_std()
304                .unwrap_or(Duration::from_secs(1));
305
306            debug!(
307                name = %task_name,
308                next = %next,
309                sleep_secs = until_next.as_secs(),
310                "Sleeping until next cron tick"
311            );
312
313            // Sleep until next tick, but remain responsive to shutdown
314            tokio::select! {
315                _ = tokio::time::sleep(until_next) => {}
316                _ = shutdown_rx.changed() => {
317                    info!(name = %task_name, "Shutdown signal received, stopping cron task");
318                    break;
319                }
320            }
321
322            if *shutdown_rx.borrow() {
323                info!(name = %task_name, "Shutdown detected, stopping cron task");
324                break;
325            }
326
327            // In distributed mode, acquire a lock to prevent duplicate execution.
328            // In single-instance mode, run the handler directly without locking.
329            let _guard = if !ServerConfig::get_distributed_mode() {
330                debug!(name = %task_name, "Distributed mode disabled, running cron without lock");
331                None
332            } else {
333                let transaction_repo = app_state.transaction_repository();
334                match crate::repositories::TransactionRepository::connection_info(
335                    transaction_repo.as_ref(),
336                ) {
337                    None => {
338                        debug!(name = %task_name, "In-memory mode, running cron without lock");
339                        None
340                    }
341                    Some((connections, key_prefix)) => {
342                        let pool = connections.primary().clone();
343                        let lock_key = format!("{key_prefix}:lock:{task_name}");
344                        let lock = DistributedLock::new(pool, &lock_key, lock_ttl);
345                        match lock.try_acquire().await {
346                            Ok(Some(guard)) => {
347                                info!(name = %task_name, "Distributed lock acquired, running cron handler");
348                                Some(guard)
349                            }
350                            Ok(None) => {
351                                debug!(name = %task_name, "Distributed lock held by another instance, skipping");
352                                continue;
353                            }
354                            Err(e) => {
355                                warn!(name = %task_name, error = %e, "Failed to acquire distributed lock, skipping");
356                                continue;
357                            }
358                        }
359                    }
360                }
361            };
362
363            if let Err(panic_info) = AssertUnwindSafe(handler(app_state.clone()))
364                .catch_unwind()
365                .await
366            {
367                let msg = panic_info
368                    .downcast_ref::<String>()
369                    .map(|s| s.as_str())
370                    .or_else(|| panic_info.downcast_ref::<&str>().copied())
371                    .unwrap_or("unknown panic");
372                error!(name = %task_name, panic = %msg, "Cron handler panicked");
373            }
374
375            drop(_guard);
376        }
377
378        info!(name = %task_name, "Cron task stopped");
379    });
380
381    Ok(WorkerHandle::Tokio(handle))
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    // ── backend-neutral lock keys ──────────────────────────
389
390    #[test]
391    fn test_cron_lock_keys_are_backend_neutral() {
392        // No lock task name may embed a backend name; a mixed SQS/Pub/Sub fleet
393        // must share one lock domain per task.
394        let names = [
395            TRANSACTION_CLEANUP_CRON_LOCK.to_string(),
396            SYSTEM_CLEANUP_CRON_LOCK.to_string(),
397            token_swap_cron_lock("relayer-1"),
398        ];
399        for name in &names {
400            assert!(!name.contains("sqs"), "lock key '{name}' embeds 'sqs'");
401            assert!(
402                !name.contains("pubsub"),
403                "lock key '{name}' embeds 'pubsub'"
404            );
405        }
406        // Stable, expected values.
407        assert_eq!(TRANSACTION_CLEANUP_CRON_LOCK, "cron-transaction-cleanup");
408        assert_eq!(SYSTEM_CLEANUP_CRON_LOCK, "cron-system-cleanup");
409        assert_eq!(token_swap_cron_lock("r-9"), "cron-token-swap-r-9");
410    }
411
412    #[test]
413    fn test_cron_lock_keys_distinct_from_handler_self_locks() {
414        // The scheduler's outer lock must NOT collide with the cleanup handlers'
415        // own self-lock keys (`transaction_cleanup` / `system_queue_cleanup`),
416        // or the handler would find the lock held and skip its work.
417        assert_ne!(TRANSACTION_CLEANUP_CRON_LOCK, "transaction_cleanup");
418        assert_ne!(SYSTEM_CLEANUP_CRON_LOCK, "system_queue_cleanup");
419    }
420
421    // ── Constants ──────────────────────────────────────────────────────
422
423    #[test]
424    fn test_cron_lock_ttl_constants_are_sane() {
425        // Margin/min are positive and the minimum exceeds the margin so derived
426        // TTLs never collapse. (Comparisons forced past const-eval via a runtime
427        // copy so this is a real assertion, not `assert!(true)`.)
428        let margin = std::hint::black_box(CRON_LOCK_TTL_MARGIN_SECS);
429        let min = std::hint::black_box(CRON_LOCK_TTL_MIN_SECS);
430        assert!(margin > 0);
431        assert!(min > 0);
432        assert!(min > margin);
433    }
434
435    // ── derive_cron_lock_ttl ──────────────────────────────────────────
436
437    #[test]
438    fn test_derive_cron_lock_ttl_for_five_minute_schedule() {
439        let ttl = derive_cron_lock_ttl("0 */5 * * * *", Duration::from_secs(240));
440        assert_eq!(ttl, Duration::from_secs(295));
441    }
442
443    #[test]
444    fn test_derive_cron_lock_ttl_for_minute_schedule() {
445        let ttl = derive_cron_lock_ttl("0 * * * * *", Duration::from_secs(240));
446        assert_eq!(ttl, Duration::from_secs(55));
447    }
448
449    #[test]
450    fn test_derive_cron_lock_ttl_hourly_schedule() {
451        let ttl = derive_cron_lock_ttl("0 0 * * * *", Duration::from_secs(240));
452        assert_eq!(ttl, Duration::from_secs(3595));
453    }
454
455    #[test]
456    fn test_derive_cron_lock_ttl_fallback_on_invalid_cron() {
457        let fallback = Duration::from_secs(240);
458        assert_eq!(derive_cron_lock_ttl("not-a-cron", fallback), fallback);
459    }
460
461    #[test]
462    fn test_derive_cron_lock_ttl_short_interval_floors_at_minimum() {
463        // 10s cron: interval=10, capped=5, max(5,30)=30, min(30,9)=9
464        let ttl = derive_cron_lock_ttl("*/10 * * * * *", Duration::from_secs(240));
465        assert_eq!(ttl, Duration::from_secs(9));
466    }
467
468    #[test]
469    fn test_derive_cron_lock_ttl_always_less_than_interval() {
470        let schedules = [
471            "*/5 * * * * *",
472            "*/30 * * * * *",
473            "0 * * * * *",
474            "0 */10 * * * *",
475            "0 0 * * * *",
476        ];
477        let fallback = Duration::from_secs(9999);
478        for expr in &schedules {
479            let ttl = derive_cron_lock_ttl(expr, fallback);
480            let schedule = cron::Schedule::from_str(expr).unwrap();
481            let now = Utc::now();
482            let mut upcoming = schedule.after(&now);
483            let first = upcoming.next().unwrap();
484            let second = upcoming.next().unwrap();
485            let interval = (second - first).to_std().unwrap();
486            assert!(ttl < interval, "TTL must be < interval for '{expr}'");
487        }
488    }
489
490    #[test]
491    fn test_derive_cron_lock_ttl_with_production_schedules() {
492        let tx = derive_cron_lock_ttl(
493            TRANSACTION_CLEANUP_CRON_SCHEDULE,
494            Duration::from_secs(TRANSACTION_CLEANUP_LOCK_TTL_SECS),
495        );
496        assert!(tx.as_secs() > 500 && tx.as_secs() < 600);
497
498        let sys = derive_cron_lock_ttl(
499            SYSTEM_CLEANUP_CRON_SCHEDULE,
500            Duration::from_secs(SYSTEM_CLEANUP_LOCK_TTL_SECS),
501        );
502        assert!(sys.as_secs() > 800 && sys.as_secs() < 900);
503    }
504
505    // ── spawn_cron_task error path + schedule parsing ──────────────────
506
507    #[test]
508    fn test_cron_schedule_parse_valid_expressions() {
509        for expr in [
510            TRANSACTION_CLEANUP_CRON_SCHEDULE,
511            SYSTEM_CLEANUP_CRON_SCHEDULE,
512        ] {
513            assert!(
514                cron::Schedule::from_str(expr).is_ok(),
515                "production cron '{expr}' should parse"
516            );
517        }
518    }
519
520    #[test]
521    fn test_lock_key_format_uses_prefix_and_task_name() {
522        // The full lock key is {key_prefix}:lock:{task_name}; verify the shape.
523        let key = format!("{}:lock:{}", "oz-relayer", TRANSACTION_CLEANUP_CRON_LOCK);
524        assert_eq!(key, "oz-relayer:lock:cron-transaction-cleanup");
525    }
526
527    #[test]
528    fn test_cronscheduler_new_stores_shutdown_rx() {
529        let rt = tokio::runtime::Builder::new_current_thread()
530            .enable_all()
531            .build()
532            .unwrap();
533        rt.block_on(async {
534            let (tx, rx) = watch::channel(false);
535            assert!(!*rx.borrow());
536            tx.send(true).unwrap();
537            assert!(*rx.borrow());
538        });
539    }
540}