openzeppelin_relayer/queues/
cron.rs1use 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
44const CRON_LOCK_TTL_MARGIN_SECS: u64 = 5;
46const CRON_LOCK_TTL_MIN_SECS: u64 = 30;
48
49pub(crate) const TRANSACTION_CLEANUP_CRON_LOCK: &str = "cron-transaction-cleanup";
62pub(crate) const SYSTEM_CLEANUP_CRON_LOCK: &str = "cron-system-cleanup";
64
65pub(crate) fn token_swap_cron_lock(relayer_id: &str) -> String {
67 format!("cron-token-swap-{relayer_id}")
68}
69
70pub struct CronScheduler {
73 app_state: Arc<ThinData<DefaultAppState>>,
74 shutdown_rx: watch::Receiver<bool>,
75 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 pub async fn start(self) -> Result<Vec<WorkerHandle>, super::QueueBackendError> {
94 let mut handles = Vec::new();
95
96 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 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 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 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
224fn 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
257fn 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 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 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 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 #[test]
391 fn test_cron_lock_keys_are_backend_neutral() {
392 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 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 assert_ne!(TRANSACTION_CLEANUP_CRON_LOCK, "transaction_cleanup");
418 assert_ne!(SYSTEM_CLEANUP_CRON_LOCK, "system_queue_cleanup");
419 }
420
421 #[test]
424 fn test_cron_lock_ttl_constants_are_sane() {
425 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 #[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 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 #[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 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}